类是最常见的一种引用类型,最简单的类的声明如下:
而复杂的类可能包含如下内容:
· 在 class 关键字之前:类特性(attribute)和类修饰符。非嵌套的类修饰符有 public 、 internal 、 abstract 、 sealed 、 static 、 unsafe 和 partial 。
· 紧接 YourClassName :泛型参数、唯一基类与多个接口。
· 在花括号内:类成员(方法、属性、索引器、事件、字段、构造器、重载运算符、嵌套类型和终结器)。
本章涵盖除类特性、运算符函数,以及 unsafe 关键字外的上述所有内容, unsafe 关键字将在第4章介绍。以下将逐一介绍各个类成员。
字段(field)是类或结构体中的变量成员,例如:
字段可用以下修饰符进行修饰:
· 静态修饰符: static
· 访问权限修饰符: public internal private protected
· 继承修饰符: new
· 不安全代码修饰符: unsafe
· 只读修饰符: readonly
· 线程访问修饰符: volatile
私有字段的命名有两种常用的方式,一种是驼峰命名法(例如, firstName ),另一种是驼峰命名法加下划线前缀(例如, _firstName )。后者可以方便地区分私有字段与局部变量。
readonly 修饰符防止字段在构造后进行变更。只读字段只能在声明时或在其所属的类型构造器中赋值。
字段不一定要初始化,没有初始化的字段均为默认值( 0 、 '\0' 、 null 、 false )。字段初始化逻辑在构造器之前运行:
字段初始化器可以包含表达式,也可以调用其他方法:
简便起见,可以用逗号分隔的列表声明一组相同类型的字段。这是声明若干具有共同特性和修饰符的字段的简单方法:
常量是一种值永远不会改变的静态字段,常量会在编译时静态赋值,编译器会在常量使用点上直接替换该值(类似于C++的宏)。常量可以是 bool 、 char 、 string 、任何内建的数字类型或者枚举类型。
常量用关键字 const 声明,并且必须用值初始化,例如:
常量和 static readonly 字段的功能相似,但它在使用时有着更多的限制。常量能够使用的类型有限,而且初始化字段的语句含义也不同。其他与 static readonly 字段的不同之处还有:常量是在编译时进行赋值的。因此,
将编译为:
将 PI 作为常量是合理的,它的值将在编译期确定。相反, static readonly 字段的值在程序运行时可以取不同的值:
如果将 static readonly 字段提供给其他程序集使用,则可以在后续版本中更新其数值,这是 static readonly 字段的一个优势。例如,假设程序集 X 提供了如下的常量:
如果程序集 Y 引用程序集 X 并使用了这个常量,那么值 2.3 将在编译时固定在程序集 Y 中。这意味着如果 X 后来重新编译,而且其中的常量值更改为 2.4 ,那么 Y 仍将使用旧值 2.3 直至 Y 重新编译。而 static readonly 字段则不存在这个问题。
从另一个角度看,未来可能发生变化的任何值从定义上讲都不是恒定的,因此不应当表示为常量。
常量也可以在方法内声明:
非局部常量可以使用以下的修饰符:
· 访问权限修饰符: public internal private protected
· 继承修饰符: new
方法用一组语句实现某个行为。方法从调用者指定的参数中获得输入数据,并通过指定的输出类型将输出数据返回给调用者。方法可以返回 void 类型,表明它不会向调用者返回任何值。此外,方法还可以通过 ref / out 参数向调用者返回输出数据。
方法的签名在这个类型的签名中必须是唯一的。方法的签名由它的名字和一定顺序的参数类型(但不包含参数名和返回值类型)组成。
方法可以用以下修饰符修饰:
· 静态修饰符: static
· 访问权限修饰符: public internal private protected
· 继承修饰符: new virtual abstract override sealed
· 部分方法修饰符: partial
· 非托管代码修饰符: unsafe extern
· 异步代码修饰符: async
当方法仅由一个表达式构成时:
则可以用表达式体方法来简化其表现形式,即用胖箭头来取代花括号和 return 关键字:
表达式体函数也可以用 void 作为返回类型:
我们可以在一个方法中定义另一个方法:
局部方法(上述例子中的 Cube 方法)仅仅在包含它的方法(上例中的 WriteCubes 方法)内可见。这不仅简化了父类型还可以让阅读代码的人一眼看出 Cube 不会在其他地方使用。另外一个优势是局部方法可以访问父方法中的局部变量和参数,这会导致很多后果,我们将在4.3.2节详细介绍。
局部方法还可以出现在其他类型的函数中,例如,属性访问器和构造器中。你甚至可以将局部方法放在其他局部方法中或者放在使用语句块的Lambda表达式中(参见第4章)。同时,局部方法可以是迭代的(参见第4章)和异步的(参见第14章)方法。
(从C# 8开始)在局部方法中添加 static 修饰符,可以防止局部方法访问外围方法中的局部变量和参数。这有助于减少耦合,防止在局部方法中意外地引用外围方法中的变量。
在顶级语句中声明的任何方法都是局部方法。因此(除非将其标记为 static )它可以访问顶级语句中的变量:
局部方法不能重载,因此顶级语句中声明的方法(即局部方法)是无法重载的。
只要确保方法签名不同,可以在类型中重载方法(使用同一个名称定义多个方法)。例如,以下的一组方法可以同时出现在同一个类型中:
但是,下面的两对方法则不能同时出现在一个类型中,因为方法的返回值类型和 params 修饰符不属于方法签名的一部分:
参数是按值传递还是按引用传递也是方法签名的一部分。例如, Foo(int) 和 Foo(ref int) 或 Foo(out int) 可以同时出现在一个类中。但 Foo(ref int) 和 Foo(out int) 不能同时出现在一个类中:
构造器执行类或结构体的初始化代码。构造器的定义和方法的定义类似,区别仅在于构造器名和返回值只能与封装它的类型相同:
实例构造器支持以下的修饰符:
· 访问权限修饰符: public internal private protected
· 非托管代码修饰符: unsafe extern
仅包含一个语句的构造器也可以使用表达式体成员的写法,例如:
类或者结构体可以重载构造器。为了避免重复代码,构造器可以用 this 关键字调用另一个构造器:
当构造器调用另一个构造器时,被调用的构造器先执行。
还可以向另一个构造器传递表达式:
表达式内不能使用 this 引用,例如,不能调用实例方法(这是强制性的,由于这个对象当前还没有通过构造器初始化完毕,因此调用任何方法都有可能失败)。但是表达式可以调用静态方法。
C#编译器会自动为没有显式定义构造器的类生成无参数公有构造器。但是,一旦显式定义了至少一个构造器,系统就不再自动生成无参数的构造器了。
之前提到,字段可以在声明时初始化为其默认值:
字段的初始化按声明的先后顺序,在构造器之前执行。
构造器不一定都是公有的。通常,定义非公有的构造器是为了通过一个静态方法调用来控制创建类实例的过程。静态方法可以从一个池中返回对象,而不必每次创建一个新对象的实例。静态方法还可以根据不同的输入参数返回不同的子类对象:
解构器(也称为解构方法)就像构造器的反过程,构造器使用若干值作为参数,并且将它们赋值给字段,而解构器则相反,将字段反向赋值给若干变量。
解构方法的名字必须为 Deconstruct ,并且拥有一个或多个 out 参数,例如:
若要调用解构器,则需使用如下的特殊语法:
第二行是解构调用,它创建了两个局部变量并调用 Deconstruct 方法。上述解构调用等价于:
或者:
解构调用允许隐式类型推断,因此我们可以将其简写为:
或者:
在解构过程中,如果并非对所有的变量都感兴趣,则可以使用丢弃符号(_)来忽略它们:
以上写法比起定义一个不会使用的变量更能够体现出程序的本意。
如果解构中的变量已经定义过了,那么可以忽略类型声明:
上述操作也称为解构赋值。我们可以通过解构赋值来简化类的构造器:
我们还可以通过重载 Deconstruct 方法向调用者提供一系列解构方案。
Deconstruct 方法可以是扩展方法(请参见4.9节),这种做法可方便地对第三方作者的类型进行解构。
在C# 10中,我们可以在解构时同时匹配现有变量并声明新变量,例如:
为了简化对象的初始化,可以在调用构造器之后直接通过对象初始化器设置对象的可访问字段或属性,例如下面的类:
就可以用对象初始化器对 Bunny 对象进行实例化:
构造 b1 和 b2 的代码等价于:
使用临时变量是为了确保即使在初始化过程中抛出异常,也不会得到一个部分初始化的对象。
除了使用对象初始化器,还可以令 Bunny 的构造器接收可选参数:
我们可以使用如下的语句构造 Bunny 对象:
在之前C#版本中,这样做的优点是我们可以将 Bunny 的字段(或属性,之后会讲解)设置为只读。如果在对象的生命周期内无须改变字段值或属性值,则这样做是一种良好的实践。但是,在C# 9中,使用 init 修饰符可以令对象初始化器也具有上述能力。稍后讨论属性时我们再来介绍它。
可选参数有两个缺点。首先,虽然在构造器中使用可选参数可以创建只读类型,但是它无法简单地实现非破坏性更改(nondestructive mutation)。我们将在4.12节中介绍非破坏性更改,并给出上述问题的解决方案。
其次,在公有库中使用可选参数可能造成向后兼容问题。这是因为后续添加可选参数会破坏该程序集对现有消费者二进制兼容性(如果该公有库发布在NuGet上,则更应当重视上述问题:当消费者引用了包 A 和包 B ,而这两个包分别依赖于 L 的不兼容版本时问题就变得更加棘手了)。
上述问题之所以存在,是因为所有的可选参数都需要由调用者处理。换句话说,C#会将我们的构造器调用转换为:
如果另一个程序集实例化 Bunny ,则当 Bunny 类再次添加一个可选参数(如 likesCats )时就会出错。除非引用该类的程序集也重新编译,否则,它还将继续调用三个参数的构造器(现在已经不存在了),从而造成运行时错误。(还有一种难以发现的错误是,如果我们修改了某个可选参数的默认值,则另一个程序集的调用者在重新编译之前,还会继续使用旧的可选值。)
this 引用指代实例本身。在下面的例子中, Marry 方法将 partner 的 Mate 字段设定为 this :
this 引用可避免字段、局部变量或属性之间发生混淆,例如:
this 引用仅在类或结构体的非静态成员中有效。
从外表看来,属性(property)和字段很类似,但是属性内部像方法一样含有逻辑。例如,从以下代码不能判断出 CurrentPrice 到底是字段还是属性:
属性和字段的声明很类似,但是属性比字段多出了 get / set 代码块。下面是 CurrentPrice 作为属性的实现方法:
get 和 set 是属性的访问器。读属性时会运行 get 访问器,它必须返回属性类型的值。给属性赋值时会运行 set 访问器,它有一个名为 value 的隐含参数,该参数的类型和属性的类型相同。它的值一般来说会赋值给一个私有字段(上例中为 CurrentPrice )。
尽管访问属性和字段的方式是相同的,但不同之处在于,属性在获取和设置值的时候给实现者提供了完全的控制能力。这种控制能力使实现者可以选择任意的内部表示形式,而无须将属性的内部细节暴露给用户。在本例中, set 方法可以在 value 超出有效范围时抛出异常。
本书中广泛使用公有字段以免干扰读者的注意力。但是在实际应用中,为了提高封装性可能会更倾向于使用公有属性。
属性支持以下的修饰符:
· 静态修饰符: static
· 访问权限修饰符: public 、 internal 、 private 、 protected
· 继承修饰符: new 、 virtual 、 abstract 、 override 、 sealed
· 非托管代码修饰符: unsafe 、 extern
如果只定义了 get 访问器,属性就是只读的。如果只定义了 set 访问器,那么它就是只写的。一般很少使用只写属性。
通常属性会用一个专门的支持字段来存储其所代表的数据,但属性也可以从其他数据计算得来:
只读属性(就像之前的例子中那样的属性)可简写为表达式体属性。它使用胖箭头替换了花括号、 get 访问器和 return 关键字:
只需添加少许代码,就可以进一步将 set 访问器改为表达式体:
属性最常见的实现方式是使用 get 访问器或者 set 访问器读写私有字段(该字段与属性类型相同)。编译器会将自动属性声明转换为这种实现方式。因此我们可以将本节的第一个例子重新定义为:
编译器会自动生成一个后台私有字段,该字段的名称由编译器生成且无法引用。如果希望属性对其他类型暴露为只读属性,则可以将 set 访问器标记为 private 或 protected 。自动属性是在C# 3.0中引入的。
我们可以像初始化字段那样为自动属性添加属性初始化器(property initializer):
上述写法将 CurrentPrice 的值初始化为 123 。拥有初始化器的属性可以为只读属性:
就像只读字段那样,只读自动属性只可以在类型的构造器中赋值。这个功能适于创建不可变(只读)的对象。
get 和 set 访问器可以有不同的访问级别。典型的用例是将 public 属性中的 set 访问器设置成 internal 或 private 的:
注意,属性本身应当声明具有较高的访问级别(本例中为 public ),然后在需要较低级别的访问器上添加相应的访问权限修饰符。
C# 9在声明属性访问器时可以使用 init 替代 set :
只用于初始化的属性和只读属性相似,而前者可以使用对象初始化器进行赋值:
在初始化之后,属性值就无法更改了:
只用于初始化的属性甚至无法从类内部赋值。只能通过属性初始化器、构造器或者其他只用于初始化的访问器赋值。
若不使用只用于初始化的属性,则可以声明只读属性并用构造器进行初始化:
如果上述类型是公有库的类,则在构造器中添加可选参数会破坏消费端的二进制兼容性,从而造成版本管理的困难(而添加只用于初始化的属性则不会破坏兼容性)。
只用于初始化的属性有另外一个显著的优点:当它和“记录”(请参见4.12节)配合使用时可以实现非破坏性更改。
和普通的 set 访问器一样,只用于初始化的访问器也可以提供实现代码:
注意_ pitch 字段是只读的:只用于初始化的 set 访问器可以修改自身所在类中 readonly 字段的值。(如果没有这种特性,则 _pitch 字段必须可写,这样类就无法防止内部的变更了。)
将类的访问器从 init 更改为 set ,或反之从 set 更改为 init 都是二进制上的重大变更。因此引用程序集一方需要重新编译。
上述情况对于完全不可变的类型不会构成任何问题。因为它们的属性不会包含(可写的) set 访问器。
C#属性访问器在内部会编译成名为 get_XXX 和 set_XXX 的方法:
init 访问器和 set 访问器的处理方法类似,但 init 会在其 set 访问器的“modreq”元数据上额外设置一个标记(详情请参见18.2.2.1节)。
简单的非虚属性访问器会被JIT(即时)编译器内联编译,消除了属性和字段访问间的性能差距。内联是一种优化方法,它用方法的函数体替代方法调用。
索引器为访问类或者结构体中封装的列表或字典型数据元素提供了一种自然的访问接口。索引器和属性很相似,但索引器通过索引值而非属性名称来访问数据元素。例如, string 类具有索引器,可以通过 int 索引访问其中每一个 char 的值。
使用索引器的语法就像使用数组一样,不同之处在于索引参数可以是任意类型。
索引器和属性具有相同的修饰符(请参见3.1.8节),并且可以在方括号前插入?以使用 null 条件运算(请参见2.10节):
编写索引器首先要定义一个名为 this 的属性,并将参数定义放在一对方括号中,例如:
以下代码展示了索引器的使用方式:
一个类型可以定义多个参数类型不同的索引器,一个索引器也可以包含多个参数:
如果省略 set 访问器,则索引器就是只读的。同时,索引器也可以使用表达式体的语法简化其定义:
索引器在内部会编译成名为 get_Item 和 set_Item 的方法,如下所示:
我们可以在自定义类的索引器参数中使用 Index 或者 Range 类型来支持索引和范围操作(请参见2.7.2节)。例如,我们可以扩展3.1.9.1节中的例子,在 Sentence 类中加入以下索引器:
并进行如下调用:
每个类型的静态构造器只会执行一次,而不是每个实例执行一次。一个类型只能定义一个静态构造器,名称必须和类型同名,且没有参数:
运行时将在类型使用之前调用静态构造器,以下两种行为可以触发静态构造器执行:
· 实例化类型
· 访问类型的静态成员
静态构造器只支持 unsafe 和 extern 这两个修饰符。
如果静态构造器抛出了未处理的异常(参见第4章),则该类型在整个应用程序生命周期内都是不可用的。
从C# 9开始,除了静态构造器之外,我们还可以定义模块初始化器,它在每个程序集中只会执行一次(当程序集第一次加载时)。定义模块初始化器需要编写一个静态方法,并在该方法上应用[ ModuleInitializer ]特性:
静态模块初始化器会在调用静态构造器前运行。如果类型没有静态构造器,则字段会在类型被使用之前或者在运行时的任意更早时间进行初始化。
静态字段初始化器按照字段声明的先后顺序运行。在下面例子中, X 初始化为 0 ,而 Y 初始化为 3 :
如果我们交换两个字段初始化顺序,则两个字段都将初始化为3。以下示例会先输出 0 后输出 3 ,因为字段初始化器在 X 初始化为 3 之前创建了 Foo 的实例:
如果交换上面代码中加粗的两行,则两个字段上下两次都输出 3 。
标记为 static 的类无法实例化也不能被继承,它只能由 static 成员组成。 System.Console 和 System.Math 类就是静态类的绝佳示例。
终结器(finalizer)是只能够在类中使用的方法,该方法在垃圾回收器回收未引用的对象占用的内存前调用。终结器的语法是类型的名称加上 ~ 前缀。
事实上,这是C#语言重写 Object 类的 Finalize 方法的语法。编译器会将其扩展为如下的声明:
我们将在第12章详细讨论垃圾回收和终结器。
终结器允许使用以下修饰符:
· 非托管代码修饰符: unsafe
可以使用表达式体语法编写单语句终结器:
分部类型(partial type)允许一个类型分开进行定义,典型的做法是分开在多个文件中。分部类型使用的常见场景是从其他源文件自动生成分部类(例如,从Visual Studio模板或设计器),而这些类仍然需要人为为其编写方法,例如:
每一个部分必须包含 partial 声明,因此以下的写法是不合法的:
分部类型的各个组成部分不能包含冲突的成员,例如,具有相同参数的构造器。分部类型完全由编译器处理,因此各部分在编译时必须可用,并且必须编译在同一个程序集中。
可以在多个分部类声明中指定基类,只要基类是同一个基类即可。此外,每一个分部类型组成部分可以独立指定实现的接口。我们将在3.2节和3.6节详细讨论基类和接口。
编译器并不保证分部类型声明中各个组成部分之间的字段初始化顺序。
分部类型可以包含分部方法(partial method)。这些方法能够令自动生成的分部类型为手动编写的代码提供自定义钩子(hook),例如:
分部方法由定义和实现两部分组成。定义一般由代码生成器生成,而实现一般由手动编写。如果没有提供方法的实现,分部方法的定义会被编译器清除(调用它的代码部分也一样)。这样,自动生成的代码既可以提供钩子又不必担心代码过于臃肿。分部方法返回值类型必须是 void ,且该方法是隐式的 private 方法。分部方法不能包含 out 参数。
C# 9引入的扩展分部方法(extended partial method)是为反向生成代码的情形而设计的。此时,程序员定义钩子方法而代码生成器则实现该方法。使用Roslyn的源代码生成器时就可能出现上述情况。我们可以向编译器提供一个程序集,并由该程序集自动生成部分代码。
当分部方法的开头是访问修饰符时,该分部方法就成为扩展分部方法:
访问修饰符不仅影响方法的访问性,它还告诉编译器需要对该声明加以区别对待。
扩展分部方法必须含有实现,它不会像分部方法那样在没有实现时被编译器清除。在上述例子中,由于 M1 和 M2 两个方法指定了访问修饰符( public 和 private ),因此都需要进行实现。
由于扩展分部方法无法被编译器清除,因此它们可以返回任何类型,并可以包含 out 参数:
nameof 运算符返回任意符号的字符串名称(类型、成员、变量等):
相对于直接指定一个字符串,这样做的优点体现在静态类型检查中。例如,令Visual Studio这样的开发工具理解你引用的符号。这样,当符号重命名时,所有引用之处都会随之重新命名。
当指定一个类型的成员(例如,属性和字段)名称时,请务必引用其类型名称。这对静态和实例成员都有效:
上述代码将结果解析为 Length ,如果希望得到 StringBuilder.Length ,则可以这样做: