变量表示存储着可变值的存储位置。变量可以是局部变量、参数(value、ref或out)、字段(实例或静态)以及数组元素。
栈和堆是存储变量的地方,它们分别具有不同的生命周期语义。
栈是存储局部变量和参数的内存块。逻辑上,栈会在函数进入和退出时增加或减少。考虑下面的方法(为了避免干扰,该范例省略了输入参数检查):
这个方法是递归的,即它调用其自身。每一次进入这个方法的时候,就在栈上分配一个新的 int ,而每一次离开这个方法,就会释放一个 int 。
堆是保存对象(例如引用类型的实例)的内存块,新创建的对象会分配在堆上并返回其引用。在程序执行过程中,堆会被新创建的对象不断填充。运行时的垃圾回收器会定期从堆上释放对象以确保应用程序有内存可用。只要对象没有被“存活”的对象引用,它就可以被释放。
以下例子中,我们创建了一个 StringBuilder 对象并将其引用赋值给 ref1 变量,之后在其中写入内容。 StringBuilder 对象在后续没有使用的情况下可立即被垃圾回收器释放。
之后,我们创建另一个 StringBuilder 对象赋值给 ref2 ,再将引用复制给 ref3 。虽然 ref2 之后便不再使用,但是由于 ref3 保持着同一个 StringBuilder 对象的引用,因此在 ref3 使用完毕之前它不会被垃圾回收器回收:
值类型的实例(和对象的引用)就存储在变量声明的地方。如果将值类型声明为类中的字段或数组中的元素,则该实例会存储在堆上。
C#无法像C++那样显式删除对象,未引用的对象最终将被垃圾回收器回收。
静态字段也会存储在堆上。与分配在堆上的对象(可以被垃圾回收)不同,这些变量将一直存活直至进程结束。
C#强制执行明确赋值策略,实践中这意味着在 unsafe 或互操作(interop)上下文之外无法访问未初始化的内存。明确赋值有如下三种含义:
· 局部变量在读取之前必须赋值。
· 调用方法时必须提供函数的实际参数(除非标记为可选参数,参见2.8.4.8节)。
· 运行时将自动初始化其他变量(例如,字段和数组元素)。
例如,以下示例将产生编译时错误:
字段和数组元素会自动初始化为其类型的默认值。以下代码输出 0 就是因为数组元素会隐式赋为默认值:
以下代码输出 0 ,因为(静态和实例)字段都会隐式赋值为默认值:
所有类型的实例都有默认值,预定义类型的默认值是按位取0的内存所表示的值。
default 关键字可用于获得任意类型的默认值:
若能够进行类型推定,则无须指定类型信息:
自定义值类型(例如, struct )的默认值等同于每一个字段都取其默认值。
方法可以包含一连串参数(parameter),在调用方法时必须为这些参数提供实际值(argument)。在下面的例子中, Foo 方法仅有一个类型为 int 的参数 p :
使用 ref 、 in 和 out 修饰符可以控制参数的传递方式:
默认情况下,C#中的参数默认按值传递,这是最常用的方式。这意味着在把参数值传递给方法时将创建一份参数值的副本:
为 p 赋一个新的值并不会改变 x 的值,因为 p 和 x 分别存储在不同的内存位置中。
按值传递引用类型参数复制的是引用而非对象本身。下例中, Foo 方法中的 StringBuilder 对象和实例化的 sb 变量所指的是同一个对象,但是它们的引用是不同的。换句话说,变量 sb 和 fooSB 是引用同一个 StringBuilder 对象的不同变量:
由于 fooSB 是引用的一份副本,因此将它赋值为 null 并不会把 sb 也赋值为 null (然而,如果在声明和调用 fooSB 时使用 ref 修饰符,则 sb 会变成 null )。
在C#中,若按引用传递参数则应使用 ref 修饰符。在下面的例子中, p 和 x 指向同一块内存位置:
现在给 p 赋新值将改变 x 的值。注意, ref 修饰符在声明和调用时都是必需的 ,这样就清楚地表明了程序将如何执行。
ref 修饰符对于实现交换方法是必要的(3.9节将介绍如何编写适用于所有类型的“交换”方法):
无论参数是引用类型还是值类型,都可以按引用传递或按值传递。
out 参数和 ref 参数类似,但在以下几点上不同:
· 无须在传入函数之前进行赋值。
· 必须在函数结束之前赋值。
out 修饰符通常用于获得方法的多个返回值,例如:
与 ref 参数一样, out 参数按引用传递。
C#允许在调用含有 out 参数的方法时直接声明变量。因此我们可以将前面例子中的头两行代码简化为:
当调用含有多个 out 参数的方法时,若我们并非关注所有参数的值,那么可以使用下划线来“丢弃”那些不感兴趣的参数:
此时,编译器会将下划线认定为一个特殊的符号,称为丢弃符号。一次调用可以引入多个丢弃符号。假设 SomeBigMethod 定义了7个 out 参数,除第4个之外其他的全部被丢弃:
出于向后兼容性的考虑,如果在作用域内已经有一个以下划线为名称的变量的话,这个语言特性就失效了:
按引用传递参数是为现存变量的存储位置起了一个别名而不是创建一个新的存储位置。在下面的例子中,变量 x 和 y 代表相同的实例:
in 参数和 ref 参数相似,而前者的参数值无法在方法内更改(如果更改,则会产生一个编译时错误)。这个修饰符非常适用于向方法传递大型值类型对象。因为此时编译器不仅可以避免在参数传递时对参数进行复制操作而造成开销,还可以保护参数的原始值不被修改。
in 修饰符是重载的一个重要组成部分:
若希望调用第二个重载方法,则调用者必须使用 in 修饰符:
当调用不会造成歧义时:
则 in 修饰符是可选的:
需要说明的是为了使上述示例有实际的意义,我们将 SomeBigStruct 定义为struct类型(请参见3.4节)。
当使用 params 参数修饰符修饰方法中的最后一个参数时,方法就能够接受任意数量的指定类型参数。参数类型必须声明为(一维)数组,例如:
若 params 参数表中没有任何参数,则会创建一个包含零个元素的数组作为参数。
除上述调用方式外也可以将普通的数组提供给 params 参数,因此示例中的第一行从语义上等价于:
方法、构造器和索引器(参见第3章)中都可以声明可选参数。只要在参数声明中提供默认值,这个参数就是可选参数:
可选参数在调用方法时可以省略:
上述调用实际上将默认值 23 传递到可选参数 x 中。编译器会在调用端将值 23 传递到编译好的代码中。上例中调用 Foo 的代码语义上等价于:
这是因为编译器总是用默认值替代可选参数而造成的结果。
若公共方法对其他程序集可见,则在添加可选参数时双方均需重新编译。就像是必须提供参数的方法一样。
可选参数的默认值必须由常量表达式、无参数的值类型构造器或者 default 表达式指定,可选参数不能标记为 ref 或者 out [3] 。
必填参数必须在可选参数方法声明和调用之前出现( params 参数例外,它总是最后出现)。下面的例子将 1 显式传递给参数 x ,而将默认值 0 传递给参数 y :
我们也可以反其道而行之,联合使用命名参数与可选参数传递默认值给 x 而传递显式值给 y 。
除了用位置确定参数外,还可以用名称来确定参数:
命名参数能够以任意顺序出现。下面两种调用 Foo 的方式在语义上是一样的:
上述写法的不同之处的是参数表达式将按调用端参数出现的顺序计算。通常,这种不同只出现在拥有副作用的、非独立的表达式中。例如,下面的代码将输出 0 , 1 :
当然,在实践中应当避免这种代码。
命名参数和可选参数可以混合使用:
然而这里有一个限制,除非参数均出现在正确的位置,否则按位置传递的参数必须出现在命名参数之前。因此我们可以按照如下方式调用 Foo 方法:
但以下调用是不行的:
命名参数适于和可选参数混合使用。例如,考虑下面的方法:
我们可以用以下方式在调用 Bar 时仅提供 d 参数的值:
这个特性在调用COM API时非常有用,我们将在第24章详细讨论。
引用局部变量是C#中一个令人费解的特性,即定义一个用于引用数组中某一个元素或对象中某一个字段的局部变量(该特性是C# 7引入的):
在这个例子中, numRef 是 numbers[2] 的引用。当我们更改 numRef 的值时,也相应更改了数组中的元素值:
引用局部变量的目标只能是数组的元素、对象字段或者局部变量,而不能是属性(参见第3章)。引用局部变量适用于在特定的场景下进行小范围优化,并通常和引用返回值合并使用。
我们将在第23章将讨论 Span<T> 和 ReadOnlySpan<T> 类型。这两种类型使用引用返回值实现了高效的索引器。除此场景之外,引用返回值鲜有使用。该特性主要用于微观性能改进。
从方法中返回的引用局部变量,称为引用返回值(ref return):
如果在调用端忽略 ref 修饰符,则该调用将会返回一个普通的值:
当我们定义属性或者索引器时也可以使用引用返回值:
注意,这些属性即使不定义 set 访问器也是隐式可写的:
为了避免修改,可使用 ref readonly :
ref readonly 修饰符在保持了返回引用所带来的性能提升之余还阻止了修改操作。当然,由于 x 是字符串类型(引用类型),因此这种更改在本例中的影响微乎其微。不论字符串有多长,我们唯一改进的地方是避免了一次32位或者64位引用的拷贝。如果使用自定义值类型(请参见3.4节),则需要将结构体标记为 readonly (否则编译器将执行一次防御性质的拷贝)才会切实地改善性能。
若属性或索引器的返回类型为引用返回值,则无法在其中定义 set 访问器。
我们通常会一次性完成变量的声明和初始化。如果编译器能够从初始化表达式中推断出变量的类型,就能够使用 var 关键字来代替类型声明,例如:
它们完全等价以下代码:
因为是完全等价的,所以隐式类型变量仍是静态类型的。例如,下面的代码将产生编译时错误:
注意,当无法直接从变量声明语句中看出变量类型时, var 关键字将降低代码的可读性。例如:
变量 x 的类型是什么呢?
在4.10节我们将介绍必须使用 var 的场景。
另一种减少重复书写的方式是使用目标类型 new 表达式(C# 9):
上述代码和以下代码是完全等价的:
该功能的原理是,如果编译器能够明确地推断类型名称,则可以不指定类型名直接调用 new 。这个功能在变量声明和初始化代码并不位于一处时非常有用。例如,当我们需要在构造器中初始化字段时:
目标类型 new 表达式也适合在以下场景中使用: