类 (Class) 是最常用的引用类型。最简单的类声明如下:
class YourClassName
{
}
更复杂的类有如下选项:
- class关键字前面:属性(attributes)和类修饰符(class modifiers)。非嵌套修饰符有 public、internal、abstract、sealed、static、unsafe 和 partial。
- YourClassName后面:泛型类型参数(generic type parameters)、基类(base class)和接口(interfaces)。
- 花括号里面:类成员。这些是方法、属性、索引器、事件、字段、构造函数、重载操作符、嵌套类型和终结器。
字段 (Fields)
字段是 class 或 struct 的成员变量。例如:
class Octopus
{
string name;
public int Age = 10;
}
字段可以有以下修饰符:
- 静态修饰符:static
- 访问修饰符:public internal private protected
- 继承修饰符:new
- 不安全代码修饰符:unsafe
- 只读修饰符:readonly
- 线程修饰符:volatile
只读修饰符
readonly 修饰符防止字段在构造后被修改。只读字段只能在其声明中赋值,或者在包含这个字段的类型 (封闭类型:enclosing type) 的构造函数中赋值。
字段初始化
字段初始化是可选的。未初始化的字段有一个默认值 (0,\0,null,false)。字段初始化式在构造函数之前运行:
public int Age = 10;
同时声明多个字段
为方便起见,可以以逗号分隔的列表方式,声明多个相同类型的字段。例如:
static readonly int legs = 8,
eyes = 2;
方法 (Methods)
方法组合一系列语句以执行一个操作。方法可以通过指定参数(parameters)从调用方接收输入数据,并通过指定返回类型将数据输出回调用方。方法可以指定 void 返回类型,表示它不向其调用方返回任何值。方法还可以通过 ref/out 参数将数据输出回调用方。
方法的签名(signature)在声明该方法的类型中必须是唯一的。方法的签名按顺序由它的名称和参数类型组成,但不包括参数名称,也不包括返回类型。
字段可以有以下修饰符:
- 静态修饰符:static
- 访问修饰符:public internal private protected
- 继承修饰符:new virtual abstract override sealed
- 局部方法修饰符:partial
- 非托管代码修饰符:unsafe extern
- 异步代码修饰符:async
表达式体方法 (C# 6)
由单个表达式组成的方法,如下:
int Foo (int x) { return x * 2; }
可以作为一种表达式体方法(expression-bodied method),写得更简洁。一个粗箭头替换大括号和 return 关键字:
int Foo (int x) => x * 2;
表达式体函数也可以具有 void 返回类型:
void Foo (int x) => Console.WriteLine (x);
重载方法
一个类型可以重载方法 (具有多个同名方法),只要签名不同。例如,以下方法可以在同一类型中共存:
void Foo (int x) {...}
void Foo (double x) {...}
void Foo (int x, float y) {...}
void Foo (float x, int y) {...}
但是,以下方法对不能在同一类型中共存,因为返回类型和params修饰符不是方法签名的一部分:
void Foo (int x) {...}
float Foo (int x) {...} // Compile-time error
void Goo (int[] x) {...}
void Goo (params int[] x) {...} // Compile-time error
值传递 vs. 引用传递
参数是按值传递还是按引用传递也是签名的一部分。
例如,Foo(int)
可以与 Foo(ref int)
或 Foo(out int)
共存。但是,Foo(ref int)
和 Foo(out int)
不能共存:
局部方法 (C# 7)
从 C# 7 开始,可以在另一个方法中定义一个方法:
void WriteCubes()
{
Console.WriteLine (Cube (3));
Console.WriteLine (Cube (4));
Console.WriteLine (Cube (5));
int Cube (int value) => value * value * value;
}
局部方法 (在本例中为 Cube) 仅对封闭方法 (WriteCubes) 可见。
这简化了包含类型(containing type),并立即向查看该代码的任何人发出信号,表明 Cube 在其他地方没有使用。
局部方法的另一个优点是,它们可以访问封闭方法的局部变量和参数。
在第4章的“捕获外部变量”中详细描述了这种方式的影响。
局部方法可以出现在其他函数类型中,例如属性访问器、构造函数等。甚至可以将局部方法放置在其他局部方法中,或者放在使用语句块的lambda表达式中 (第4章)。局部方法可以是迭代器 (第4章) 或异步方法 (第14章)。
static 修饰符对于局部方法无效。如果封闭方法是静态的,则它们是隐式静态的。
实例构造函数 (Instance Constructors)
构造函数在 class 或 struct 上运行初始化代码。构造函数的定义类似于方法,只是方法名和返回类型被简化为封闭类型(enclosing type)的名称:
public class Panda
{
string name; // Define field
public Panda (string n) // Define constructor
{
name = n; // Initialization code (set up field)
}
}
...
Panda p = new Panda ("Petey"); // Call constructor
实例构造函数可以有以下修饰符:
- 访问修饰符:public internal private protected
- 非托管代码修饰符:unsafe extern
从 C# 7 开始,单个语句的构造函数可以写成表达式体成员:
public Panda (string n) => name = n;
重载构造函数
class 或 struct 可以重载构造函数。为了避免代码重复,一个构造函数可以使用 this
关键字调用另一个构造函数:
using System;
public class Wine
{
public decimal Price;
public int Year;
public Wine (decimal price) { Price = price; }
public Wine (decimal price, int year) : this (price) { Year = year; }
}
当一个构造函数调用另一个构造函数时,首先执行被调用函数(called constructor)。
可以将表达式(expression)传递给另一个构造函数,如下所示:
public Wine (decimal price, DateTime year) : this (price, year.Year) { }
例如,表达式本身不能使用this引用来调用实例方法。(这是强制的,因为在这个阶段,对象还没有被构造函数初始化,所以调用它的任何方法都可能失败。)但是,它可以调用静态方法。
隐式的无参数构造函数
对于类,仅当没有定义任何构造函数时,C#编译器自动生成一个无参数的public构造函数。
构造函数和字段初始化顺序
字段可以在声明中使用默认值初始化:
class Player
{
int shields = 50; // Initialized first
int health = 100; // Initialized second
}
字段初始化出现在执行构造函数之前,按照字段声明的顺序。
非公共构造函数
构造函数不必是public。使用非公共构造函数的一个常用原因是通过静态方法调用来控制实例的创建。静态方法可以用于从池中返回一个对象,而不必创建一个新对象,或者根据输入参数返回各种子类:
public class Class1
{
Class1() {} // Private constructor
public static Class1 Create (...)
{
// Perform custom logic here to return an instance of Class1
...
}
}
解构器 (C# 7)
C# 7引入了解构器(deconstructor)模式。解构函数(也称为解构方法)的作用与构造函数大致相反:构造函数通常接受一组值(作为参数)并将它们赋值给字段,而解构函数则相反,将字段赋值给一组变量。
一个解构方法必须被称为 Deconstruct
,并且有一个或多个 out
参数,例如下面的类:
class Rectangle
{
public readonly float Width, Height;
public Rectangle (float width, float height)
{
Width = width;
Height = height;
}
public void Deconstruct (out float width, out float height)
{
width = Width;
height = Height;
}
}
为了调用解构函数,使用下面的特殊语法:
var rect = new Rectangle (3, 4);
(float width, float height) = rect; // Deconstruction
Console.WriteLine (width + " " + height); // 3 4
第二行是解构调用。它创建两个局部变量,然后调用 Deconstruct 方法。这个解构调用相当于:
float width, height;
rect.Deconstruct (out width, out height);
或者:
rect.Deconstruct (out var width, out var height);
解构调用允许隐式类型,因此可以将调用简化为:
(var width, var height) = rect;
或者:
var (width, height) = rect;
如果要解构的变量已经定义,请完全省略这些类型:
float width, height;
(width, height) = rect;
这叫做解构赋值(deconstructing assignment)。
通过重载 Deconstruct 方法,可以为调用者提供一系列解构选项。
Deconstruct 方法可以是一种扩展方法(见第4章的“扩展方法”)。如果要解构未编写的类型,这是一个有用的技巧。
对象初始化器 (Object Initializers)
为了简化对象初始化,可以在构造完成后,通过对象初始化器,直接设置对象的任何可访问字段或属性。例如,对于下面的类:
public class Bunny
{
public string Name;
public bool LikesCarrots;
public bool LikesHumans;
public Bunny () {}
public Bunny (string n) { Name = n; }
}
使用对象初始化器,可以如下实例化Bunny对象:
// Note parameterless constructors can omit empty parentheses
Bunny b1 = new Bunny { Name="Bo", LikesCarrots=true, LikesHumans=false };
Bunny b2 = new Bunny ("Bo") { LikesCarrots=true, LikesHumans=false };
对象初始化器是在C# 3.0中引入的。
对象初始化器 vs. 可选参数
可以让Bunny的构造函数接受可选参数:
public Bunny (string name,
bool likesCarrots = false,
bool likesHumans = false)
{
Name = name;
LikesCarrots = likesCarrots;
LikesHumans = likesHumans;
}
这将允许以下面的方式构建一个 Bunny:
Bunny b1 = new Bunny (name: "Bo",
likesCarrots: true);
这种方法的一个优点是,可以将 Bunny 的字段(或属性)设置为只读。如果在对象的整个生命周期中没有有效的理由更改字段或属性,那么将字段或属性设置为只读是一种很好的实践。
这种方法的缺点是,每个可选参数值都被嵌入到调用位置(calling site)中。换句话说,C#将上面的构造函数调用转换成这样:
Bunny b1 = new Bunny ("Bo", true, false);
如果从另一个程序集中实例化Bunny类,接着修改Bunny,添加另一个可选参数(比如likesCats),这可能会有问题。除非引用程序集也被重新编译,否则它将继续调用带有3个参数的(现在不存在的)构造函数,并在运行时失败。
一个更微妙的问题是,如果改变其中一个可选参数的值,其他程序集中的调用程序将继续使用旧的可选值,直到程序集被重新编译。
因此,如果希望在程序集版本之间提供二进制兼容性,则应该谨慎使用公共函数中的可选参数。
this引用
this引用引用实例本身。在下面的例子中,Marry方法使用它来设置partner的Mate字段:
public class Panda
{
public Panda Mate;
public void Marry (Panda partner)
{
Mate = partner;
partner.Mate = this; //互为伴侣:this的伴侣是partner,partner的伴侣是this
}
}
this引用还可以消除字段中局部变量或参数的二义性。例如:
public class Test
{
string name;
public Test (string name) { this.name = name; }
}
this引用仅在class或struct的非静态成员中有效。
属性 (Properties)
属性从外部看起来像字段,但在内部它们包含逻辑,就像方法一样。例如,不能通过下面的代码来判断CurrentPrice是一个字段还是一个属性:
Stock msft = new Stock();
msft.CurrentPrice = 30;
msft.CurrentPrice -= 3;
Console.WriteLine (msft.CurrentPrice);
属性的声明类似于字段,但添加了 get/set
块。下面是如何将CurrentPrice实现为一个属性:
public class Stock
{
decimal currentPrice; // The private "backing" field
public decimal CurrentPrice // The public property
{
get { return currentPrice; }
set { currentPrice = value; }
}
}
get
和 set
表示属性访问器(accessor)。读取属性时,运行 get
访问器。它必须返回属性类型的值。set
访问器在属性被赋值时运行。它有一个属性类型的隐式参数,名为 value
,通常分配给私有字段 (在本例中为currentPrice)。
虽然属性的访问方式与字段相同,但它们的不同之处在于,属性赋予实现者对获取和设置其值的完全控制。这种控制使实现者能够选择所需的任何内部表示形式,而无需向属性的用户公开内部细节。在这个例子中,如果 value 超出了有效值范围,set 方法就会抛出异常。
为封装考虑,在实际应用程序中,通常更喜欢使用公共属性,而非公共字段。
属性可以有以下修饰符:
- 静态修饰符:static
- 访问修饰符:public internal private protected
- 继承修饰符:new virtual abstract override sealed
- 非托管代码修饰符:unsafe extern
只读和计算属性
如果属性只指定了一个 get
访问器,它就是只读的,如果它只指定了一个 set
访问器,它就是只写的。只写属性很少使用。
属性通常有一个专用的协助字段来存储底层数据。但是,也可以从其他数据计算属性。例如:
decimal currentPrice, sharesOwned;
public decimal Worth
{
get { return currentPrice * sharesOwned; }
}
表达式体属性 (C# 6, C# 7)
从C# 6开始,可以更简洁地使用表达式体属性(expression-bodied property)的方式,声明一个只读属性。一个粗箭头替代大括号以及 get 和 return 关键字:
public decimal Worth => currentPrice * sharesOwned;
C# 7进一步扩展了这一点,允许 set 访问器是表达式体的,语法如下:
public decimal Worth
{
get => currentPrice * sharesOwned;
set => sharesOwned = value / currentPrice;
}
自动属性
属性最常见的实现是getter和/或setter,它只是读取和写入与属性类型相同的私有字段。自动属性(automatic property)声明指示编译器提供此实现。可以通过将 CurrentPrice 声明为一个自动属性来改进本节的第一个例子:
public class Stock
{
...
public decimal CurrentPrice { get; set; }
}
编译器自动生成一个私有协助字段,该字段不能被引用。如果要将属性以只读方式公开给其他类型,则可以将 set 访问器标记为 private 或 protected。在C# 3.0中引入了自动属性。
使用自动属性时,只能通过属性访问数据。
class Rectangle
{
private int high;
public int High
{
get { return high; }
set { high = value; }
}
public int Width { get; set; }
public int Area()
{
return Width * high; // 也可以使用 return Width * High;
}
}
属性初始化器 (C# 6)
从C# 6开始,可以给自动属性添加属性初始化器,就像字段一样:
public decimal CurrentPrice { get; set; } = 123;
public int Maximum { get; } = 999; // 只读的属性
与只读字段一样,只读自动属性也可以在类型的构造函数中赋值。这在创建不可变(只读)类型时很有用。
get 和 set 可访问性
get 和 set 访问器可以具有不同的访问级别。典型的用例是一个public属性,在setter上和一个 internal 或 private 访问修饰符:
public class Foo
{
private decimal x;
public decimal X
{
get { return x; }
private set { x = Math.Round (value, 2); }
}
}
CLR属性实现
C#属性访问器内部编译为 get_XXX 和 set_XXX 方法:
public decimal get_CurrentPrice {...}
public void set_CurrentPrice (decimal value) {...}
JIT (Just-In-Time) 编译器内联了简单的非虚拟属性访问器,消除了访问属性和字段之间的性能差异。内联是一种优化,其中方法调用被替换为该方法的主体。
对于WinRT属性,编译器采用put_XXX命名约定,而不是set_XXX。
索引器 (Indexers)
索引器为访问封装了值列表或值字典的类或结构中的元素提供了一种自然的语法。索引器类似于属性,但可以通过索引参数而不是属性名称访问。string类有一个索引器,允许通过一个int索引访问它的每个char值:
索引器提供了一种自然语法,用于访问封装了值列表或字典的类或结构体中的元素。索引器类似于属性,但是通过索引实参而不是属性名称访问。string类有一个索引器,允许通过int索引访问其每个char值:
string s = "hello";
Console.WriteLine (s[0]); // 'h'
Console.WriteLine (s[3]); // 'l'
使用索引器的语法与使用数组的语法类似,只是索引实参可以是任何类型。
索引器具有与属性相同的修饰符,可以通过在方括号前插入一个问号来有条件地调用空值(参见第2章的“空操作符”):
string s = null;
Console.WriteLine (s?[0]); // Writes nothing; no error.
实现一个索引器
要编写索引器,需要定义一个名为this的属性,并在方括号中指定参数。例如:
class Sentence
{
string[] words = "The quick brown fox".Split();
public string this [int wordNum] // indexer
{
get { return words [wordNum]; }
set { words [wordNum] = value; }
}
}
下面是如何使用这个索引器:
Sentence s = new Sentence();
Console.WriteLine (s[3]); // fox
s[3] = "kangaroo";
Console.WriteLine (s[3]); // kangaroo
类型可以声明多个索引器,每个索引器的参数类型不同。一个索引器也可以有多个参数:
public string this [int arg1, string arg2]
{
get { ... } set { ... }
}
如果省略了set访问器,索引器就会变成只读的,C# 6中可以使用表达式体语法来缩短它的定义:
public string this [int wordNum] => words [wordNum];
CLR索引器的实现
索引器内部编译为 get_Item 和 set_Item 方法,如下所示:
public string get_Item (int wordNum) {...}
public void set_Item (int wordNum, string value) {...}
常量
常量是一个静态字段,它的值永远不会改变。常量在编译时被静态计算,编译器在使用它的时候会替换成它的字面值 (与C++中的宏类似)。常量可以是任何内置的数值类型,bool、char、string 或 enum 类型。
常量是用 const 关键字声明的,必须用值初始化。例如:
public class Test
{
public const string Message = "Hello World";
}
常量比 static readonly
字段有更多的限制——在可以使用的类型和字段初始化语义上都是如此。常量与 static readonly
字段的区别还在于,常量的计算发生在编译时。例如:
public static double Circumference (double radius)
{
return 2 * System.Math.PI * radius;
}
编译成:
public static double Circumference (double radius)
{
return 6.2831853071795862 * radius;
}
PI是一个常数,因为它永远不会改变。相反,static readonly
字段对于每个应用程序可以有不同的值。
当向其他程序集公开值时,若该值在更高版本中可能更改时,选择 static readonly
字段是更有利的。
未来可能发生变化的任何值在定义上都不是常数,因此不应表示为常数。
常量也可以声明为方法的局部变量。例如:
static void Main()
{
const double twoPI = 2 * System.Math.PI;
...
}
非局部常量允许使用以下修饰符:
- 访问修饰符:public internal private protected
- 继承修饰符:new
静态构造函数
静态构造函数对每种类型(type)执行一次,而不是对每个实例(instance)执行一次。一个类型只能定义一个静态构造函数,它必须是无参数的,并且与类型同名:
class Test
{
static Test() { Console.WriteLine ("Type Initialized"); }
}
运行时在使用类型之前自动调用静态构造函数。有两件事会引发这种情况:
- 实例化类型
- 访问类型中的静态成员
静态构造函数允许的修饰符是 unsafe
和 extern
。
如果静态构造函数抛出未处理的异常(第4章),该类型在应用程序的整个生命周期内都不可用。
静态构造函数和字段初始化顺序
静态字段初始化程序在调用静态构造函数之前运行。如果类型没有静态构造函数,则字段初始化程序将在使用类型之前执行,或者在运行时之前的任何时间执行。
静态字段初始化器按照字段声明的顺序运行。下面的例子说明了这一点:X被初始化为0,Y被初始化为3。
class Foo
{
public static int X = Y; // 0
public static int Y = 3; // 3
}
如果交换上面两个字段初始化器,两个字段都初始化为3。下面的示例先打印0,后打印3,因为实例化Foo的字段初始化器在X初始化为3之前执行:
class Program
{
static void Main() { Console.WriteLine (Foo.X); } // 3
}
class Foo
{
public static Foo Instance = new Foo();
public static int X = 3;
Foo() { Console.WriteLine (X); } // 0
}
静态类
一个类可以被标记为 static,表明它只能由静态成员组成,并且不能被子类化。System.Console 和 System.Math 就是静态类的例子。
终结器 (Finalizers)
终结器是仅限类的方法,在垃圾收集器回收未引用对象的内存之前执行。终结器的语法是以 ~
符号为前缀的类名称:
class Class1
{
~Class1()
{
...
}
}
这实际上是重写 Object 的 Finalize 方法的C#语法,编译器将其扩展为以下方法声明:
protected override void Finalize()
{
...
base.Finalize();
}
垃圾收集和终结器将在第12章中详细讨论。
终结器允许使用以下修饰符:
- 非托管代码修饰符:unsafe
从C# 7开始,单语句终结器可以用表达式体语法编写:
~Class1() => Console.WriteLine ("Finalizing");
部分类型和方法 (Partial Types and Methods)
部分类型允许类型定义通常跨多个文件拆分。一种常见的场景是从其他源(如Visual Studio模板或设计器)自动生成部分类,并使用其他手工编写的方法对该类进行扩充。例如:
部分类型和方法
部分类型允许将类型定义拆分——通常跨多个文件。一种常见的情况是,从其他一些源(例如Visual Studio模板或设计器)自动生成部分类,并使用其他人工编写的方法对该类进行扩充。例如:
// PaymentFormGen.cs - auto-generated
partial class PaymentForm { ... }
// PaymentForm.cs - hand-authored
partial class PaymentForm { ... }
每个参与者必须有 partial
声明。以下是不合法的:
partial class PaymentForm {}
class PaymentForm {}
参与者不能有冲突的成员。例如,具有相同参数的构造函数不能重复。部分类型完全由编译器解析,这意味着每个参与者在编译时都必须可用,并且必须位于同一程序集中。
可以在一个或多个分部类声明上指定基类,只要指定的基类是相同的。此外,每个参与者可以独立地指定要实现的接口。
编译器不保证部分类型声明之间的字段初始化顺序。
部分方法 (Partial methods)
分部类型可以包含分部方法。这使得自动生成的部分类型可以为手动创作提供可自定义的挂钩。例如:
partial class PaymentForm // In auto-generated file
{
...
partial void ValidatePayment (decimal amount);
}
partial class PaymentForm // In hand-authored file
{
...
partial void ValidatePayment (decimal amount)
{
if (amount > 100)
...
}
}
分部方法由两部分组成:定义和实现。定义通常由代码生成器编写,实现通常是手动编写的。如果没有提供实现,那么分部方法的定义将被编译(就像调用它的代码一样)。这允许自动生成的代码在提供钩子方面自由,而不必担心膨胀。分部方法必须是 void 并且是隐式 private。
部分方法是在 C# 3.0 引入的。
nameof 运算符 (C# 6)
nameof
运算符以字符串的形式返回任何符号 (类型、成员、变量等) 的名称:
int count = 123;
string name = nameof (count); // name is "count"
与简单地指定字符串相比,它的优势在于静态类型检查。Visual Studio等工具可以理解符号引用,因此如果重命名有问题的符号,它的所有引用也将被重命名。
若要指定类型成员(如字段或属性)的名称,请同时包含该类型。这适用于静态成员和实例成员:
string name = nameof (StringBuilder.Length);
上面代码计算为 “Length”。若要返回 “StringBuilder.Length”,见下面代码:
nameof (StringBuilder) + "." + nameof (StringBuilder.Length);