C-10-快速语法参考-全-
C#10 快速语法参考(全)
一、你好世界
选择 IDE
要开始用 C# 编码,你需要一个支持. NET 的集成开发环境(IDE),最流行的选择是微软自己的 Visual Studio。 1 22
自 2002 年 C# 1.0 首次发布以来,C# 语言经历了多次更新。在撰写本文时,C# 10 是当前版本,发布于 2021 年。每一个语言版本对应一个 Visual Studio 版本,所以为了使用 C# 10 的特性,你需要 Visual Studio 2022(17.0 或更高版本)。安装 Visual Studio 时,请确保选择“”。NET 桌面开发”的工作量,以便能够用 C# 开发。
创建项目
安装 IDE 后,继续运行它。然后,您需要创建一个新项目,它将管理 C# 源文件和其他资源。若要显示“新建项目”窗口,请转到 Visual Studio 中的“文件➤新➤项目”。从那里,选择 C# 控制台应用(。NET Framework)模板,然后单击“下一步”按钮。如果需要,配置项目的名称和位置,然后再次单击 Next 按钮。在这最后一页,请确保。NET 被选中。为了使用 C# 10 的特性,项目需要面向。NET 6.0 或更高版本。然后单击 Create 以允许项目向导创建您的项目。
您现在已经创建了一个 C# 项目。在解决方案资源管理器窗格(查看➤解决方案资源管理器)中,您可以看到该项目由一个应该已经打开的 C# 源文件(.cs
)组成。如果没有,您可以在解决方案资源管理器中双击该文件来打开它。在源文件中,有一些基本代码,您可以用下面的代码替换:
class MyApp
{
static void Main()
{
}
}
应用现在由一个名为MyApp
的类组成,该类包含一个空的Main
方法,两者都用花括号分隔。Main
方法是程序的入口点,必须采用这种格式。大小写也很重要,因为 C# 区分大小写。花括号界定了属于代码实体的内容,比如类或方法,它们必须包含在内。括号及其内容被称为代码块,或简称为代码块。
你好世界
在学习一门新的编程语言时,第一个要编写的程序通常会显示“Hello World”文本字符串。这是通过在Main
方法的花括号之间添加以下代码行来实现的:
System.Console.WriteLine("Hello World");
这一行代码使用了WriteLine
方法,该方法接受由双引号分隔的单个字符串参数。该方法位于属于System
名称空间的Console
类中。注意,点运算符(.
)用于访问名称空间和类的成员。语句必须以分号结尾,C# 中的所有语句也是如此。您的代码现在应该如下所示:
class MyApp
{
static void Main()
{
System.Console.WriteLine("Hello World");
}
}
WriteLine
方法在打印字符串的末尾添加一个换行符。要显示不带换行符的字符串,可以使用Write
方法。
智能感知
在 Visual Studio 中编写代码时,只要有多个预先确定的选项可供选择,就会弹出一个名为 IntelliSense 的窗口。这个窗口非常有用,按 Ctrl+Space 可以手动调出。它使您可以快速访问能够在程序中使用的任何代码实体,包括。NET 以及它们的描述。这是一个非常强大的功能,你应该记得使用。
二、编译并运行
Visual Studio 编译
完成“Hello World”程序后,下一步是编译并运行它。为此,请打开“调试”菜单并选择“启动而不调试”,或者只需按 Ctrl+F5。然后,Visual Studio 将编译并运行该应用,该应用在控制台窗口中显示该字符串。
控制台编译
如果您没有像 Visual Studio 这样的 IDE,您仍然可以编译程序,只要您有。已安装网络。要尝试这样做,请打开命令提示符(C:\Windows\System32\cmd.exe
)并导航到源文件所在的项目文件夹。然后,您需要找到名为csc.exe
的 C# 编译器,它位于与此处所示类似的路径中。以源文件名作为参数运行编译器,它将在当前文件夹中生成一个可执行文件。
C:\MySolution\MyProject>
\Windows\Microsoft.NET\Framework64\v3.5\
csc.exe Program.cs
如果您尝试从控制台窗口运行编译后的程序,它将显示与 Visual Studio 创建的程序相同的输出。
C:\MySolution\MyProject> Program.exe
Hello World
评论
注释用于在源代码中插入注释。C# 使用标准的 C++注释符号,包括单行和多行注释。它们只是为了增强源代码的可读性,对最终程序没有影响。单行注释从//
开始,延伸到行尾。多行注释可以跨越多行,由/*
和*/
分隔。
// single-line comment
/* multi-line
comment */
除此之外,还有两个文档注释。有一个以///
开头的单行文档注释,和一个由/**
和*/
分隔的多行文档注释。这些注释在生成类文档时使用。
/// <summary>Class level documentation.</summary>
class MyApp
{
/** <summary>Program entry point.</summary>
<param name="args">Command line arguments.</param>
*/
static void Main(string[] args)
{
System.Console.WriteLine("Hello World");
}
}
三、变量
变量用于在程序执行过程中在内存中存储数据。
数据类型
根据您需要存储的数据,有几种不同的数据类型。C# 中的简单类型由四个有符号整数类型和四个无符号、三个浮点类型以及char
和bool
组成。
数据类型
|
大小(位)
|
描述
|
| --- | --- | --- |
| sbyte``short``int``long
| eightSixteenThirty-twoSixty-four | 有符号整数 |
| byte``ushort``uint``ulong
| eightSixteenThirty-twoSixty-four | 无符号整数 |
| float``double``decimal
| Thirty-twoSixty-fourOne hundred and twenty-eight | 浮点数 |
| char
| Sixteen | Unicode 字符 |
| bool
| four | 布尔值 |
申报
在 C# 中,变量必须在被使用之前被声明(创建)。要声明一个变量,你要从你希望它保存的数据类型开始,后面跟着一个变量名。名称几乎可以是您想要的任何名称,但最好是给变量起一个与它们所包含的值密切相关的名称。
int myInt;
分配
使用等号给变量赋值,等号是赋值运算符(=
)。然后变量变成定义的或初始化的。
myInt = 10;
声明和赋值可以合并成一条语句。
int myInt = 10;
如果需要多个相同类型的变量,有一种简单的方法可以通过使用逗号操作符(,
)来声明或定义它们。
int myInt = 10, myInt2 = 20, myInt3;
一旦变量被定义(声明和赋值),就可以通过引用变量名来使用它。
System.Console.Write(myInt); // "10"
整数类型
有四种有符号整数类型可供使用,这取决于您需要变量保存多大的数字。
// Signed integers
sbyte myInt8 = 2; // -128 to +127
short myInt16 = 1; // -32768 to +32767
int myInt32 = 0; // -2³¹ to +2³¹-1
long myInt64 =-1; // -2⁶³ to +2⁶³-1
如果只需要存储正值,可以使用无符号类型。
// Unsigned integers
byte uInt8 = 0; // 0 to 255
ushort uInt16 = 1; // 0 to 65535
uint uInt32 = 2; // 0 to 2³²-1
ulong uInt64 = 3; // 0 to 2⁶⁴-1
除了标准的十进制记数法,整数也可以用十六进制记数法来赋值。从 C# 7.0 开始,也有了二进制表示法。十六进制数以0x
为前缀,二进制数以0b
为前缀。
int myHex = 0xF; // 15 in hexadecimal (base 16)
int myBin = 0b0100; // 4 in binary (base 2)
C # 7.0 版本还增加了数字分隔符(_
),提高了长数字的可读性。从 C# 7.2 开始,这个数字分隔符可以出现在数字中的任何位置,也可以出现在数字的开头。
int myBin = 0b_0010_0010; // 34 in binary notation (0b)
浮点类型
浮点类型可以存储不同精度级别的实数。C# 中的常量浮点数总是以双精度形式保存,因此为了将这样的数字赋给一个浮点变量,需要附加一个F
字符来将数字转换为float
类型。这同样适用于小数的M
字符。
float myFloat = 3.14F; // 7 digits of precision
double myDouble = 3.14; // 15-16 digits of precision
decimal myDecimal = 3.14M; // 28-29 digits of precision
在数据类型之间进行转换的一种更常见、更有用的方法是使用显式强制转换。通过将所需的数据类型放在要转换的变量或常量之前的括号中,执行显式强制转换。这将在赋值之前将值转换为指定的类型,在本例中为float
。
myFloat = (float) myDecimal; // explicit cast
前面显示的精度指的是类型可以容纳的总位数。例如,当试图给一个float
分配多于七个数字时,最低有效位将被四舍五入。
myFloat = 12345.6789F; // rounded to 12345.68
浮点数可以使用十进制或指数记数法进行赋值,如下例所示:
myDouble = 3e2; // 3*10² = 300
字符类型
char
类型可以包含由单引号分隔的单个 Unicode 字符。
char c = 'a'; // Unicode char
布尔类型
bool
类型可以存储布尔值,该值可以是真或假。这些值由关键字true
和false
指定。
bool b = true; // bool value
变量作用域
变量的作用域指的是代码块,在该代码块中可以无限制地使用该变量。例如,局部变量是在方法中声明的变量。这样的变量只有在声明之后,才能在该方法的代码块中使用。一旦方法的范围结束,局部变量将被销毁。
static void Main()
{
int localVar; // local variable
}
除了局部变量,C# 还有字段和参数类型变量,这些将在后面的章节中介绍。但是,与 C++不同,C# 没有全局变量。
四、运算符
运算符是用来对值进行运算的特殊符号。它们可以分为五种类型:算术、赋值、比较、逻辑和按位运算符。
算术运算符
算术运算符包括四种基本算术运算,以及用于获得除法余数的模数运算符(%
)。
float x = 3 + 2; // addition (5)
x = 3 - 2; // subtraction (1)
x = 3 * 2; // multiplication (6)
x = 3 / 2; // division (1)
x = 3 % 2; // modulus (1)
请注意,除法符号给出了不正确的结果。这是因为它对两个整数值进行运算,因此会对结果进行舍入并返回一个整数。要获得正确的值,需要将其中一个数字转换为浮点数。
x = 3 / (float)2; // 1.5
赋值运算符
下一组是赋值操作符。最重要的是赋值操作符(=
)本身,它给变量赋值。
int i = 0; // assignment
赋值运算符和算术运算符的一个常见用途是对变量进行运算,然后将结果保存回同一个变量中。使用组合赋值操作符可以缩短这些操作。
i += 5; // i = i+5;
i -= 5; // i = i-5;
i *= 5; // i = i*5;
i /= 5; // i = i/5;
i %= 5; // i = i%5;
递增和递减运算符
另一种常见的操作是将变量加 1 或减 1。这可以用增量(++
)和减量(--
)操作符来简化。
x++; // x = x+1;
x--; // x = x-1;
这两个运算符都可以用在变量之前或之后。
x++; // post-increment
x--; // post-decrement
++x; // pre-increment
--x; // pre-decrement
无论使用哪个变量,变量的结果都是相同的。不同的是,后运算符在改变变量之前返回原始值,而前运算符先改变变量,然后返回值。
int x, y;
x = 5; y = x++; // y=5, x=6
x = 5; y = ++x; // y=6, x=6
比较运算符
比较运算符比较两个值并返回true
或false
。它们主要用于指定条件,是评估为true
或false
的表达式。
bool b = (2 == 3); // equal to (false)
b = (2 != 3); // not equal to (true)
b = (2 > 3); // greater than (false)
b = (2 < 3); // less than (true)
b = (2 >= 3); // greater than or equal to (false)
b = (2 <= 3); // less than or equal to (true)
逻辑运算符
逻辑运算符通常与比较运算符一起使用。如果左右两边都为真,则逻辑与(&&
)计算为true
,如果左右两边都为真,则逻辑或(||
)计算为true
。逻辑非(!
)运算符用于反转布尔结果。请注意,对于“逻辑与”和“逻辑或”,如果左侧已经确定了结果,则不会计算运算符的右侧。
bool b = (true && false); // logical and (false)
b = (true || false); // logical or (true)
b = !(true); // logical not (false)
按位运算符
按位运算符可以处理整数中的单个位。例如,按位 and ( &
)运算符生成结果位1
,如果该运算符两边的相应位都已设置。
int x = 5 & 4; // and (0b101 & 0b100 = 0b100 = 4)
x = 5 | 4; // or (0b101 | 0b100 = 0b101 = 5)
x = 5 ^ 4; // xor (0b101 ^ 0b100 = 0b001 = 1)
x = 4 << 1; // left shift (0b100 << 1 = 0b1000 = 8)
x = 4 >> 1; // right shift (0b100 >> 1 = 0b10 = 2)
x = ~4; // invert (~0b00000100 = 0b11111011 = -5)
这些位操作符有速记赋值操作符,就像算术操作符一样。
int x=5; x &= 4; // and (0b101 & 0b100 = 0b100 = 4)
x=5; x |= 4; // or (0b101 | 0b100 = 0b101 = 5)
x=5; x ^= 4; // xor (0b101 ^ 0b100 = 0b001 = 1)
x=5; x <<= 1; // left shift (0b101 << 1 = 0b1010 = 10)
x=5; x >>= 1; // right shift (0b101 >> 1 = 0b10 = 2)
运算符先例
在 C# 中,表达式通常从左到右计算。但是,当表达式包含多个运算符时,这些运算符的优先级决定了它们的求值顺序。下表列出了优先顺序。同样的顺序也适用于许多其他语言,如 C++和 Java。
|在…之前
|
操作员
|
在…之前
|
操作员
|
| --- | --- | --- | --- |
| 1
| ++ -- ! ~
| 7
| &
|
| 2
| * / %
| 8
| ^
|
| 3
| + -
| 9
| |
|
| 4
| << >>
| 10
| &&
|
| 5
| < <= > >=
| 11
| ||
|
| 6
| == !=
| 12
| = op=
|
例如,逻辑 and ( &&
)的绑定弱于关系运算符,而关系运算符的绑定又弱于算术运算符。
bool x = 2+3 > 1*4 && 5/5 == 1; // true
为了使事情更清楚,括号可以用来指定表达式的哪一部分将首先被求值。括号是所有运算符中优先级最高的。
bool x = ((2+3) > (1*4)) && ((5/5) == 1); // true
五、字符串
字符串数据类型用于存储字符串常量。它们由双引号分隔。
string a = "Hello";
System.Console.WriteLine(a); // "Hello"
串并置
串联运算符(+
)可以将字符串组合在一起。它还有一个伴随的赋值操作符(+=
),将一个字符串追加到另一个字符串,并创建一个新字符串。
string b = a + " World"; // Hello World
a += " World"; // Hello World
当其中一个操作数不是字符串类型时,串联运算符会隐式地将非字符串类型转换为字符串,从而使下面的赋值有效。
int i = 1;
string c = i + " is " + 1; // 1 is 1
使用ToString
方法隐式执行字符串转换。所有类型。NET 具有此方法,它提供变量或表达式的字符串表示形式。如下例所示,字符串转换也可以显式进行。
string d = i.ToString() + " is " + 1.ToString(); // 1 is 1
另一种编译字符串的方法是使用字符串插值。这个特性是在 C# 6 中添加的,它使得放在花括号中的表达式可以在字符串中进行计算。要执行字符串插值,需要在字符串前放置一个美元符号($
)。
string s1 = "Hello";
string s2 = "World";
string s = $"{s1} {s2}"; // Hello World
转义字符
一个语句可以跨多行,但是一个字符串常量必须在一行中。为了分割它,字符串常量必须首先使用连接操作符进行分隔。
string myString
= "Hello " +
"World";
为了向字符串本身添加新行,使用了转义字符(\n
)。
myString = "Hello\nWorld";
这种反斜杠符号用于书写特殊字符,如反斜杠或双引号。在特殊字符中还有一个 Unicode 字符符号,用于书写任何字符。
|性格;角色;字母
|
意义
|
性格;角色;字母
|
意义
|
| --- | --- | --- | --- |
| \n
| 新行 | \f
| 换页 |
| \t
| 横表 | \a
| 警报声音 |
| \v
| 垂直标签 | \'
| 单引号 |
| \b
| 退格 | \"
| 双引号 |
| \r
| 回车 | \\
| 反斜线符号 |
| \0
| 空字符 | \uFFFF
| Unicode 字符(四位十六进制数字) |
通过在字符串前添加一个@
符号,可以忽略转义字符。这被称为逐字字符串,例如,可以用来使文件路径更可读。
string s1 = "c:\\Windows\\System32\\cmd.exe";
string s2 = @"c:\Windows\System32\cmd.exe";
字符串比较
比较两个字符串的方法很简单,就是使用等于运算符(==
)。这不会像在 Java 等其他语言中那样比较内存地址。
string greeting = "Hi";
bool b = (greeting == "Hi"); // true
字符串成员
字符串类型是String
类的别名。因此,它提供了许多与字符串相关的方法。例如,Replace
、Insert
和Remove
这样的方法。需要注意的重要一点是,没有改变字符串的方法。看似修改字符串的方法实际上总是返回一个全新的字符串。这是因为String
类是不可变的。除非替换整个字符串实例,否则不能更改字符串变量的内容。
string a = "String";
string b = a.Replace("i", "o"); // Strong
b = a.Insert(0, "My "); // My String
b = a.Remove(0, 3); // ing
b = a.Substring(0, 3); // Str
b = a.ToUpper(); // STRING
int i = a.Length; // 6
StringBuilder 类
StringBuilder
是一个可变的字符串类。由于替换一个字符串的性能代价,当一个字符串需要多次修改时,StringBuilder
类是一个更好的选择。
System.Text.StringBuilder sb = new
System.Text.StringBuilder("Hello");
这个类有几个方法可以用来操作字符串的实际内容,比如Append
、Remove
和Insert
。
sb.Append(" World"); // Hello World
sb.Remove(0, 5); // World
sb.Insert(0, "Bye"); // Bye World
要将一个StringBuilder
对象转换回常规字符串,可以使用ToString
方法。
string s = sb.ToString(); // Bye World
六、数组
一个数组是一个数据结构,用于存储所有具有相同数据类型的值的集合。
数组声明
要声明一个数组,需要将一组方括号附加到数组将包含的数据类型上,后跟数组的名称。数组可以用任何数据类型来声明,它的所有元素都将是该类型。
int[] x; // integer array
数组分配
数组是用关键字new
分配的,后跟数据类型和一组包含数组长度的方括号。这是数组可以包含的固定数量的元素。一旦创建了数组,元素将自动分配给该数据类型的默认值,在本例中为零。
int[] x = new int[3];
数组赋值
要填充数组元素,可以一次引用一个元素,然后赋值。通过将元素的索引放在方括号中来引用数组元素。请注意,第一个元素的索引从零开始。
x[0] = 1;
x[1] = 2;
x[2] = 3;
或者,可以使用花括号符号一次性赋值。如果数组被同时声明,关键字new
和数据类型可以被省略。
int[] y = new int[] { 1, 2, 3 };
int[] z = { 1, 2, 3 };
数组访问
一旦数组元素被初始化,就可以通过引用方括号内的元素索引来访问它们。
System.Console.Write(x[0] + x[1] + x[2]); // "6"
矩形阵列
C# 中的多维数组有两种:矩形和锯齿状。矩形阵列的所有子阵列长度相同,并且使用逗号分隔维度。
string[,] x = new string[2, 2];
与一维数组一样,它们可以一次填充一个,也可以在分配过程中一次全部填充。
x[0, 0] = "00"; x[0, 1] = "01";
x[1, 0] = "10"; x[1, 1] = "11";
string[,] y = { { "00", "01" }, { "10", "11" } };
交错阵列
交错数组是数组的数组,它们可以有不规则的维度。一次分配一个维度,因此可以将子阵列分配给不同的大小。
string[][] a = new string[2][];
a[0] = new string[1]; a[0][0] = "00";
a[1] = new string[2]; a[1][0] = "10"; a[1][1] = "11";
可以在分配过程中赋值。
string[][] b = { new string[] { "00" },
new string[] { "10", "11" } };
这些都是二维数组的例子。如果需要两个以上的维度,可以为矩形数组添加更多的逗号,或者为交错数组添加更多的方括号。
七、条件语句
条件语句用于根据不同的条件执行不同的代码块。
如果语句
只有当括号内的条件被评估为true
时,if
语句才会执行。条件可以包括任何比较和逻辑运算符。
// Get a random integer (0, 1 or 2)
int x = new System.Random().Next(3);
if (x < 1) {
System.Console.Write(x + " < 1");
}
为了测试其他条件,if
语句可以被任意数量的else if
子句扩展。只有当所有先前的条件都为假时,才会测试每个附加条件。
else if (x > 1) {
System.Console.Write(x + " > 1");
}
if
语句的末尾可以有一个else
子句,如果前面的所有条件都为假,则执行该子句。
else {
System.Console.Write(x + " == 1");
}
至于花括号,如果只需要有条件地执行一条语句,就可以省去。但是,包含它们被认为是一种好的做法,因为它们可以提高可读性。
if (x < 1)
System.Console.Write(x + " < 1");
else if (x > 1)
System.Console.Write(x + " > 1");
else
System.Console.Write(x + " == 1");
交换语句
switch
语句检查一个值和一系列case
标签之间的相等性,然后将执行传递给匹配的case
。该语句可以包含任意数量的case
子句,并且可以以一个默认标签结束,用于处理所有其他情况。
int x = new System.Random().Next(4);
switch (x)
{
case 0: System.Console.Write(x + " is 0"); break;
case 1: System.Console.Write(x + " is 1"); break;
default: System.Console.Write(x + " is >1"); break;
}
注意,每个case
标签后面的语句没有用花括号括起来。相反,语句以关键字break
结束,以脱离开关。C# 中的 Case 子句必须以跳转语句结束,例如break
,因为无意的跳转是一个常见的编程错误。一个例外是 case 子句完全为空,在这种情况下,允许执行到下一个标签。
switch (x)
{
case 0:
case 1: System.Console.Write("x is 0 or 1"); break;
}
Goto 语句
为了使非空 case 子句发生失败,必须使用后跟一个case
标签的goto
jump 语句显式指定这种行为。这将导致执行跳转到该标签。
case 0: goto case 1;
Goto
也可以在开关之外使用,以跳转到同一方法范围内的标签。然后,控制可以被转移到嵌套范围之外,但不能转移到嵌套范围内。然而,强烈建议不要以这种方式使用goto
,因为这使得跟踪执行流程变得更加困难。
myLabel:
// ...
goto myLabel; // jump to label
开关表达式
C# 8 引入了比常规 switch 语句更简洁的 switch 表达式。当每个 case 都是赋值表达式而不是语句时,可以使用它,如下例所示:
int x = new System.Random().Next(4);
string result = x switch {
0 => "zero",
1 => "one",
_ => "more than one"
};
System.Console.WriteLine("x is " + result);
如果测试的表达式与箭头左侧的模式匹配,则开关表达式返回箭头右侧的表达式(= >)。注意,switch 表达式中没有关键字case
或break
,默认情况下用下划线(_)表示。
三元运算符
除了if
和switch
语句,还有三元运算符(?:
)。这个运算符有三个表达式。如果第一个求值为true
,则返回第二个表达式,如果为false
,则返回第三个。
// Get a number between 0.0 and 1.0
double d = new System.Random().NextDouble();
d = (d < 0.5) ? 0 : 1; // ternary operator (?:)
八、循环
C# 中有四种循环结构。这些用于多次执行一个代码块。就像有条件的if
语句一样,如果代码块中只有一个语句,循环的花括号可以省去。
当循环
只有当条件为真时,while
循环才会遍历代码块,并且只要条件保持为真,循环就会继续。注意,条件只在每次迭代(循环)开始时检查。
int i = 0;
while (i < 10) {
System.Console.Write(i++); // 0-9
}
Do-While 循环
do-while
循环的工作方式与while
循环相同,除了它在代码块之后检查条件,因此总是至少运行一次代码块。请记住,这个循环以分号结束。
int j = 0;
do {
System.Console.Write(j++); // 0-9
} while (j < 10);
For 循环
for
循环用于遍历代码块指定的次数。它使用三个参数。第一个参数初始化一个计数器,并且总是在循环之前执行一次。第二个参数保存循环的条件,并在每次迭代之前进行检查。第三个参数包含计数器的增量,在每次迭代结束时执行。
for (int k = 0; k < 10; k++) {
System.Console.Write(k); // 0-9
}
for
回路可能有几种变化。例如,可以使用逗号运算符将第一个和第三个参数分成几个语句。
for (int k = 0, m = 5; k < 10; k++, m--) {
System.Console.Write(k+m); // 5 (10x)
}
还可以选择省略一个或多个参数。例如,第三个参数可以被移动到循环体中。
for (int k = 0; k < 10;) {
System.Console.Write(k++); // 0-9
}
Foreach 循环
foreach
循环提供了一种简单的方法来遍历数组。在每次迭代中,数组中的下一个元素被赋给指定的变量(迭代器),循环继续执行,直到遍历完整个数组。
int[] a = { 1, 2, 3 };
foreach (int m in a) {
System.Console.Write(m); // "123"
}
注意迭代器变量是只读的,因此不能用来改变数组中的元素。
中断并继续
有两个特殊的关键字可以在循环中使用— break
和continue
。break
关键字结束循环结构,而continue
跳过当前迭代的剩余部分,并在下一次迭代的开始处继续。
for (int n = 0; n < 10; n++) {
if (n == 5) break; // end loop
if (n == 3) continue; // start next iteration
System.Console.Write(n); // "0124"
}
九、方法
方法是可重用的代码块,只有在被调用时才会执行。
定义方法
通过键入void
后跟方法名、一组括号和一个代码块,可以在类内部创建一个方法。void
关键字意味着这个方法不会返回值。方法的命名约定与类相同——一个描述性的名称,每个单词最初都大写。
class MyApp
{
void Print()
{
System.Console.WriteLine("Hello World");
}
}
C# 中的所有方法必须属于一个类,并且它们是唯一可以执行语句的地方。C# 没有全局函数,全局函数是在类之外定义的方法。
调用方法
先前定义的方法将打印出一条文本消息。要调用它,必须首先使用关键字new
创建一个MyApp
类的实例。然后在实例名后使用点运算符来访问其成员,包括MyPrint
方法。
class MyApp
{
static void Main()
{
MyApp m = new MyApp();
m.Print(); // Hello World
}
void Print()
{
System.Console.WriteLine("Hello World");
}
}
方法参数
方法名后面的括号用于向方法传递参数。为此,必须首先在方法定义中以逗号分隔的声明列表的形式指定相应的参数。
void MyPrint(string s1, string s2)
{
System.Console.WriteLine(s1 + s2);
}
一个方法可以被定义为接受任意数量的参数,并且它们可以有任意的数据类型。只要确保使用相同类型和数量的参数调用该方法。
static void Main()
{
MyApp m = new MyApp();
m.Print("Hello", " World"); // "Hello World"
}
准确地说,参数出现在方法定义中,而参数出现在方法调用中。然而,这两个术语有时会被错误地互换使用。
Params 关键字
要获取特定类型的可变数量的参数,可以添加一个带有params
修饰符的数组作为列表中的最后一个参数。传递给该方法的指定类型的任何额外参数将自动存储在该数组中。
void Print(params string[] s)
{
foreach (string x in s)
System.Console.WriteLine(x);
}
方法重载
只要参数的类型或数量不同,就可以用相同的名称声明多个方法。这被称为方法重载,可以在System.Console.WriteLine
方法的实现中看到,例如,它有 18 个方法定义。这是一个强大的特性,允许一个方法处理各种参数,而程序员不需要知道使用不同的方法。
void Print(string s)
{
System.Console.WriteLine(s);
}
void Print(int i)
{
System.Console.WriteLine(i);
}
可选参数
从 C# 4.0 开始,通过在方法声明中为参数提供默认值,可以将参数声明为可选的。当调用该方法时,可以省略这些可选参数以使用默认值。
class MyApp
{
void Sum(int i, int j = 0, int k = 0)
{
System.Console.WriteLine(1*i + 2*j + 3*k);
}
static void Main()
{
new MyApp().Sum(1, 2); // 5
}
}
命名参数
C# 4.0 还引入了命名参数,允许使用相应参数的名称传递参数。此功能通过允许参数无序传递,而不是依赖于它们在参数列表中的位置,来补充可选参数。因此,可以指定任何可选参数,而不必为之前的每个可选参数指定值。
static void Main()
{
new MyApp().Sum(1, k: 2); // 7
}
可选参数和必需参数都可以命名,但是命名的参数必须放在未命名的参数之后。这种顺序限制在 C# 7.2 中有所放松,允许命名参数后跟位置参数,前提是命名参数位于正确的位置。
static void Main()
{
new MyApp().Sum(i: 2, 1); // 4
}
通过识别每个参数所代表的内容,命名参数对于提高代码可读性非常有用。
返回语句
方法可以返回值。然后用该方法将返回的数据类型替换void
关键字,并将return
关键字添加到带有指定返回类型的参数的方法体中。
string GetPrint()
{
return "Hello";
}
Return
是一个跳转语句,它导致方法退出并将值返回到调用该方法的地方。例如,GetPrint
方法可以作为参数传递给WriteLine
方法,因为该方法的计算结果是一个字符串。
static void Main()
{
MyApp m = new MyApp();
System.Console.WriteLine(m.GetPrint()); // "Hello World"
}
return
语句也可以在void
方法中使用,以便在到达结束块之前退出。
void Method()
{
return;
}
值类型和引用类型
C# 中有两种数据类型:值类型和引用类型。值类型的变量直接包含它们的数据,而引用类型的变量包含对它们的数据的引用。C# 中的引用类型包括类、接口、数组和委托类型。值类型包括简单类型,以及struct
、enum
和可空值类型。引用类型变量通常是使用new
关键字创建的,尽管这并不总是必要的,例如,在字符串对象的情况下。
引用类型的变量一般称为对象,虽然严格来说,对象是变量引用的数据。使用引用类型,多个变量可以引用同一个对象,因此通过一个变量执行的操作将影响引用同一个对象的任何其他变量。相反,对于值类型,每个变量将存储自己的值,对一个变量的操作不会影响另一个变量。
按值传送
当传递值类型的参数时,只传递变量的本地副本。这意味着,如果副本被更改,它不会影响原始变量。
void Set(int i) { i = 10; }
static void Main()
{
MyApp m = new MyApp();
int x = 0; // value type
m.Set(x); // pass value of x
System.Console.Write(x); // 0
}
通过引用传递
对于引用数据类型,C# 使用真引用传递。这意味着当传递引用类型时,不仅可以更改其状态,还可以替换整个对象,并将更改传播回原始对象。
void Set(int[] i) { i = new int[] { 10 }; }
static void Main()
{
MyApp m = new MyApp();
int[] y = { 0 }; // reference type
m.Set(y); // pass object reference
System.Console.Write(y[0]); // 10
}
Ref 关键字
值类型的变量可以通过在调用方和方法声明中使用ref
关键字来引用传递。这将导致变量通过引用传递,因此更改它将更新原始值。
void Set(ref int i) { i = 10; }
static void Main()
{
MyApp m = new MyApp();
int x = 0; // value type
m.Set(ref x); // pass reference to value type
System.Console.Write(x); // 10
}
从 C# 7.0 开始,值类型可以通过引用返回。然后在返回类型和返回值之前添加关键字ref
。请记住,返回的变量必须具有超出方法范围的生存期,因此它不能是方法的局部变量。
class Container
{
public int iField = 5;
public ref int GetField()
{
return ref iField;
}
}
调用者可以决定是通过值(作为副本)还是通过引用(作为别名)来检索返回的变量。注意,当通过引用进行检索时,在方法调用和变量声明之前都使用了ref
关键字。
class MyApp
{
static void Main()
{
Container c = new Container();
ref int iAlias = ref c.GetField(); // reference
int iCopy = c.GetField(); // value copy
iAlias = 10;
System.Console.WriteLine(c.iField); // "10"
}
}
Out 关键字
有时,您可能希望通过引用传递一个未赋值的变量,并在方法中对其赋值。但是,使用未赋值的局部变量会产生编译时错误。对于这种情况,可以使用out
关键字。它的功能与ref
相同,只是编译器允许使用未赋值的变量,并确保变量在方法中被赋值。
void Set(out int i) { i = 10; }
static void Main()
{
MyApp m = new MyApp();
int x; // value type
m.Set(out x); // pass reference to unset value type
System.Console.Write(x); // 10
}
有了 C# 7.0,在方法调用的参数列表中声明out
变量成为可能。此功能允许以下列方式简化前面的示例:
static void Main()
{
MyApp m = new MyApp();
m.Set(out int x);
System.Console.Write(x); // 10
}
本地方法
从 C# 7.0 开始,一个方法可以在另一个方法中定义。当一个方法仅被另一个方法调用时,这对于限制该方法的范围很有用。为了说明,这里使用了一个嵌套方法来执行倒计时。注意,这个嵌套方法调用它自己,因此被称为一个递归方法。
class CountDownManager
{
void CountDown()
{
int x = 10;
Recursion(x);
System.Console.WriteLine("Done");
void Recursion(int i)
{
if (i <= 0) return;
System.Console.WriteLine(i);
System.Threading.Thread.Sleep(1000); // wait 1 second
Recursion(i - 1);
}
}
static void Main()
{
new MyClass().CountDown();
}
}
十、类
一个类是一个用来创建对象的模板。它们由成员组成,其中主要的两个是字段和方法。字段是保存对象状态的变量,而方法定义对象能做什么。
class Rectangle
{
int x, y;
int GetArea() { return x * y; }
}
对象创建
要从定义类的外部使用类的实例成员,必须首先创建该类的对象。这是通过使用new
关键字来完成的,这将在系统内存中创建一个新对象。
class MyApp
{
static void Main()
{
// Create an object of Rectangle
Rectangle r = new Rectangle();
}
}
从 C# 9 开始,在 new 表达式之后指定的类型可以被省略,因为编译器可以从上下文中确定对象的类型。这被称为目标类型的新表达式。
Rectangle r = new();
一个对象也被称为一个实例。该对象将包含自己的一组字段,这些字段保存的值不同于该类的其他实例的值。
访问对象成员
除了创建对象之外,在类外部可访问的类成员需要在类定义中声明为public
。像public
这样的能见度修改器将在第十三章中讨论。
class Rectangle
{
// Make members accessible for instances of the class
public int x, y;
public int GetArea() { return x * y; }
}
成员访问运算符(.
)用在对象名称之后,以引用其可访问成员。
static void Main()
{
Rectangle r = new Rectangle();
r.x = 10;
r.y = 5;
int a = r.GetArea(); // 50
}
构造器
该类可以有一个构造函数。这是一种用于实例化(构造)对象的特殊方法。它总是与该类同名,并且没有返回类型,因为它隐式返回该类的新实例。要从另一个类访问它,需要用public
访问修饰符声明它。
public Rectangle() { x = 10; y = 5; }
当创建类的新实例时,将调用构造函数方法,在此示例中,该方法将字段设置为指定的初始值。
static void Main()
{
Rectangle r = new Rectangle(); // calls constructor
}
构造函数可以有一个参数列表,就像任何其他方法一样。如下例所示,这可用于使字段的初始值取决于创建对象时传递的参数。
class Rectangle
{
public int x, y;
public Rectangle(int width, int height)
{
x = width; y = height;
}
static void Main()
{
Rectangle r = new Rectangle(20, 15);
}
}
这个关键字
在构造函数内部,以及在属于对象的其他方法中,可以使用一个名为this
的特殊关键字。该关键字是对该类的当前实例的引用。例如,假设构造函数的参数与相应的字段同名。这些字段仍然可以通过使用关键字this
来访问,即使它们被参数所掩盖。
class Rectangle
{
public int x, y;
public Rectangle(int x, int y)
{
this.x = x; // set field x to parameter x
this.y = y;
}
}
构造函数重载
为了支持不同的参数列表,可以重载构造函数。在下一个示例中,如果类在没有任何参数的情况下被实例化,这些字段将被赋予默认值。对于一个参数,两个字段都将被设置为指定的值,而对于两个参数,每个字段都将被分配一个单独的值。试图用错误的参数数量或错误的数据类型创建对象将导致编译时错误,这与任何其他方法一样。
class Rectangle
{
public int x, y;
public Rectangle() { x = 10; y = 5; }
public Rectangle(int a) { x = a; y = a; }
public Rectangle(int a, int b) { x = a; y = b; }
}
构造函数链接
关键字this
也可以用来从一个构造函数调用另一个构造函数。这就是所谓的构造函数链,它允许更多的代码重用。注意,关键字作为方法调用出现在构造函数体之前和冒号之后。
class Rectangle
{
public int x, y;
public Rectangle() : this(10, 5) {}
public Rectangle(int a) : this(a, a) {}
public Rectangle(int a, int b) { x = a; y = b; }
}
初始字段值
如果一个类中有需要被赋予初始值的字段,比如在前面的例子中,这些字段可以在声明的同时被初始化。这可以使代码更简洁。初始值将在创建对象时分配,在调用构造函数之前。
class Rectangle
{
public int x = 10, y = 20;
}
这种类型的赋值称为字段初始化器。这种赋值不能引用另一个实例字段。
默认构造函数
即使没有定义构造函数,也可以创建一个类。这是因为编译器会自动为这样的类添加一个默认的无参数构造函数。默认构造函数将实例化该对象,并将每个字段设置为其默认值。
class Rectangle {}
class MyApp
{
static void Main()
{
// Calls default constructor
Rectangle r = new Rectangle();
}
}
对象初始化器
从 C# 3.0 开始,创建对象时,可以在实例化语句中初始化对象的公共字段。然后添加一个代码块,其中包含以逗号分隔的字段赋值列表。这个对象初始化器块将在构造函数被调用后被处理。
class Rectangle
{
public int x, y;
}
class MyApp
{
static void Main()
{
// Use object initializer
Rectangle r = new Rectangle() { x = 10, y = 5 };
}
}
如果构造函数没有参数,则可以删除括号。使用目标类型的 new 表达式时,这是不允许的。
Rectangle r1 = new Rectangle { x = 0, y = 0 };
Rectangle r2 = new() { x = 0, y = 0 };
部分类别
通过使用partial
类型修饰符,可以将一个类定义分割成单独的源文件。编译器会将这些分部类组合成最终的类型。分部类的所有部分都必须有关键字partial
并共享相同的访问级别。
// File1.cs
public partial class PartialClass {}
// File2.cs
public partial class PartialClass {}
当一个类的一部分是自动生成的时候,在多个源文件中拆分类是非常有用的。例如,Visual Studio 的图形用户界面生成器使用此功能将自动生成的代码与用户定义的代码分开。分部类还可以让多个程序员更容易同时处理同一个类。
垃圾收集工
。NET 有一个垃圾收集器,当对象不再可访问时,它会定期释放对象使用的内存。这将程序员从繁琐且容易出错的手动内存管理任务中解放出来。当一个对象不再被引用时,它就有资格被销毁。例如,当局部对象变量超出范围时,就会出现这种情况。请记住,在 C# 中不能显式释放对象。
static void Main()
{
if (true) {
string s = "";
}
// String object s becomes inaccessible
// here and eligible for destruction
}
终结器
除了构造函数,一个类还可以有一个终结器。终结器用于释放由对象分配的任何非托管资源。它是在对象被销毁之前自动调用的,不能显式调用。终结器的名称与类名相同,但前面有一个波浪号(~
)。一个类只能有一个终结器,它不接受任何参数也不返回值。
class Component
{
public System.ComponentModel.Component comp;
public Component()
{
comp = new System.ComponentModel.Component();
}
// Finalizer
~Component()
{
comp.Dispose();
}
}
一般来说。NET 垃圾收集器自动管理对象的内存分配和释放。但是,当一个类使用非托管资源(如文件、网络连接和用户界面组件)时,应该使用终结器在不再需要这些资源时释放它们。
空类型和可空类型
null
关键字用于表示空引用,即不引用任何对象的引用。在 C# 8 之前,它只能赋给引用类型的变量,而不能赋给值类型的变量。
string s = null; // warning as of C# 8
面向对象编程语言中最常见的错误之一是取消引用设置为 null 的变量,这将导致 null 引用异常,因为没有有效的实例可以取消引用。
int length = s.Length; // error: NullReferenceException
为了帮助避免这个问题,C# 8 引入了可空类型和不可空类型之间的区别。可空类型是通过在类型后附加一个问号(?
)来创建的。从 C# 8 开始,只有这样的类型可以被赋值null
而没有编译器警告。
string? s1 = null; // nullable reference type
string s2 = ""; // non-nullable reference type
为了安全地访问可能为空的对象的实例成员,应该首先执行空引用检查。例如,可以使用等于运算符(==
)来完成该测试。如果没有这样的测试,编译器会发出一个警告,就像 C# 8 一样。
class MyApp
{
public string? s; // null by default
static void Main()
{
MyApp o = new MyApp();
if (o.s == null) {
o.s = ""; // create a valid object (empty string)
}
int length = o.s.Length; // 0
}
}
另一种选择是使用三元运算符来指定一个合适的值,以防遇到空字符串。
string? s = null;
int length = (s != null) ? s.Length : 0; // 0
可为空的值类型
与引用类型一样,值类型可以通过在它的基础类型上附加一个问号(?
)来保存值null
以及它的正常值范围。这允许简单类型以及其他struct
类型指示一个未定义的值。例如,bool?
是一个可空类型,可以保存值true
、false
和null
。
bool? b = null; // nullable bool type
零合并算子
零合并运算符(??
)如果不是null
则返回左操作数,否则返回右操作数。这个条件运算符为将可空类型赋给不可空类型提供了一个简单的语法。
int? i = null;
int j = i ?? 0; // 0
可空类型的变量不应显式转换为不可空类型。如果变量的值是null
,那么这样做会导致运行时错误。
int? i = null;
int j = (int)i; // error
C# 8 引入了零合并赋值操作符(??=
),将零合并操作符和赋值操作符结合起来。如果左边的操作数计算结果为 null,则运算符将右边的值赋给左边的操作数。
int? i = null;
i ??= 3; // assign i=3 if i==null
// same as i = i ?? 3;
零条件运算符
在 C# 6.0 中,引入了空条件运算符(?.
)。该运算符提供了一种在访问对象成员时执行空检查的简洁方法。它的工作方式类似于常规的成员访问操作符(.
),只是如果遇到空引用,则返回值 null,而不是导致异常发生。
string s = null;
int? length = s?.Length; // null
每当出现空引用时,将此运算符与空合并运算符结合使用对于分配默认值非常有用。
string s = null;
int length = s?.Length ?? 0; // 0
空条件运算符的另一个用途是与数组一起使用。问号可以放在数组的方括号之前,如果数组未初始化,那么表达式将计算为null
。请注意,这不会检查引用的数组索引是否超出范围。
string[] s = null;
string s3 = s?[3]; // null
零宽容算子
C# 8 引入了空宽容操作符(!
)。这个后缀操作符声明操作符左边的引用类型应该被忽略,以防它是 null,并且不应该被警告。它没有运行时效果,仅用于禁止编译器发出警告。
string s1 = null; // warning: non-nullable type
string s2 = null!; // warning suppressed
换句话说,null-forgiving 操作符允许您故意将对象变量设置为 null,向编译器保证该变量在被解引用之前将被正确初始化。
默认值
引用类型的默认值是null
。对于简单的数据类型,缺省值如下:数值类型变成了0
,char 具有表示零的 Unicode 字符(\0000
),而bool
是false
。默认值将由编译器自动分配给字段。但是,显式指定字段的默认值被认为是好的编程方式,因为这使得代码更容易理解。对于局部变量,编译器不会设置默认值。取而代之的是,编译器强迫程序员给所使用的任何局部变量赋值,以避免与使用未赋值变量相关的问题。
class Box
{
int x; // field is assigned default value 0
void test()
{
int x; // local variable must be assigned if used
}
}
类型推理
从 C# 3 开始,可以用var
声明局部变量,让编译器根据变量的赋值自动确定变量的类型。记住var
不是一个动态类型,所以以后改变赋值不会改变编译器推断的底层类型。以下两个声明是等效的:
class Example {}
var o = new Example(); // implicit type
Example o = new Example(); // explicit type
请注意,类型推断不能与 C# 9 中引入的目标类型的新表达式一起使用,因为编译器无法确定对象类型。
Example a = new();
var b = new(); // error: no target type
何时使用var
取决于个人喜好。如果变量的类型从赋值中显而易见,那么使用var
可能更有利于缩短声明并提高可读性。如果不确定变量的类型,可以在 IDE 中将鼠标悬停在该变量上以显示其类型。请记住,var
只能在一个局部变量同时被声明和初始化时使用。
匿名类型
匿名类型是在没有显式定义类的情况下创建的类型。它们提供了一种简洁的方法来形成一个临时对象,该对象只在本地范围内需要,因此在其他地方不应该可见。使用 new 运算符创建匿名类型,后跟一个对象初始值设定项块。
var v = new { first = 1, second = true };
System.Console.WriteLine(v.first); // "1"
编译器根据赋值自动确定字段类型。它们将是只读的,因此它们的值在初始赋值后不能更改。注意,需要使用var
的类型推断来保存匿名类型的引用。
十一、继承
继承允许一个类获得另一个类的成员。在下面的例子中,类Square
继承自Rectangle
,由冒号指定。然后Rectangle
成为Square
的基类,?? 又成为Rectangle
的派生类。除了自己的成员,Square
还获得了Rectangle
中所有可访问的成员,除了任何构造函数或析构函数。
// Base class (parent class)
class Rectangle
{
public int x = 10, y = 10;
public int GetArea() { return x * y; }
}
// Derived class (child class)
class Square : Rectangle {}
对象类别
C# 中的类只能从一个基类继承。如果没有指定基类,该类将隐式继承自System.Object
。因此,这是所有其他类的根类。
class Rectangle : System.Object {}
C# 有一个统一的类型系统,所有的数据类型都直接或间接地继承自Object
。这不仅适用于类,也适用于其他数据类型,如数组和简单类型。例如,int
关键字只是System.Int32
结构类型的别名。同样,object
是System.Object
类的别名。
System.Object o = new object();
因为所有类型都继承自Object
,所以它们都共享一组公共的方法。其中一个方法是ToString
,它返回当前对象的字符串表示。方法通常返回类型的名称,这对调试非常有用。
System.Console.WriteLine( o.ToString() ); // "System.Object"
向下投射和向上投射
从概念上讲,派生类是其基类的特化。这意味着Square
是一种Rectangle
也是一种Object
,因此它可以用在任何需要Rectangle
或Object
的地方。如果Square
的实例被创建,它可以被向上转换为Rectangle
,因为派生类包含了基类中的所有内容。
Square s = new Square();
Rectangle r = s; // upcast
该对象现在被视为一个Rectangle
,因此只有Rectangle
的成员可以被访问。当对象被向下转换回一个Square
时,特定于Square
类的所有内容都将被保留。这是因为Rectangle
只包含了Square
;它没有以任何方式改变Square
对象。
Square s2 = (Square)r; // downcast
向下转换必须是显式的,因为不允许将实际的Rectangle
向下转换为Square
。
Rectangle r2 = new Rectangle();
Square s3 = (Square)r2; // error
拳击
C# 的统一类型系统允许将值类型的变量隐式转换为Object
类的引用类型。这个操作被称为装箱,一旦值被复制到对象中,它就被视为引用类型。
int value = 5;
object obj = value; // boxing
取消订阅
装箱的反义词是拆箱。这会将装箱的值转换回其值类型的变量。取消装箱操作必须是显式的。如果没有将对象取消装箱为正确的类型,将会发生运行时错误。
value = (int)obj; // unboxing
is 和 as 关键字
有两个操作符可用于在转换对象时避免异常:is
和as
。首先,如果左侧对象可以被转换为右侧类型而不会导致异常,那么is
操作符返回true
。
Rectangle q = new Square();
if (q is Square) { Square o = (Square)q; } // condition is true
用于避免对象转换异常的第二个操作符是as
操作符。这个操作符提供了另一种编写显式强制转换的方法,不同之处在于,如果失败,引用将被设置为null
。
Rectangle r = new Rectangle();
Square o = r as Square; // invalid cast, returns null
当使用as
操作符时,在null
值和错误类型之间没有区别。此外,该运算符仅适用于引用类型变量。模式匹配提供了一种克服这些限制的方法。
模式匹配
C# 7.0 引入了模式匹配,它将is
操作符的使用扩展到测试变量的类型,并在验证后将其赋给该类型的新变量。这提供了一种在类型之间安全转换变量的新方法,并且用下面更方便的语法在很大程度上代替了使用as
操作符:
Rectangle q = new Square();
if (q is Square mySquare) { /* use mySquare here */ }
当像mySquare
这样的模式变量被引入到if
语句中时,它在封闭块的作用域中也变得可用。因此,该变量甚至可以在if
语句结束后使用。对于其他条件语句或循环语句,情况并非如此。
object obj = "Hello";
if (!(obj is string text)) {
return; // exit if obj is not a string
}
System.Console.WriteLine(text); // "Hello"
扩展的is
表达式不仅可以用于引用类型,还可以用于值类型和常量,如下例所示:
class MyApp
{
void Test(object o)
{
if (o is 5)
System.Console.WriteLine("5");
else if (o is int i)
System.Console.WriteLine("int:" + i);
else if (o is null)
System.Console.WriteLine("null");
}
static void Main()
{
MyApp c = new MyApp();
c.Test(5); // "5"
c.Test(1); // "int:1"
c.Test(null); // "null"
}
}
模式匹配不仅适用于if
语句,也适用于switch
语句,使用稍微不同的语法。要匹配的类型和要赋值的变量放在case
关键字之后。前面的示例方法可以重写如下:
void Test(object o)
{
switch(o)
{
case 5:
System.Console.WriteLine("5"); break;
case int i:
System.Console.WriteLine("int:" + i); break;
case null:
System.Console.WriteLine("null"); break;
}
}
注意,在执行模式匹配时,case
表达式的顺序很重要。匹配数字5
的第一个案例必须出现在更一般的int
案例之前,以便进行匹配。
C# 的后续版本继续扩展了模式的使用方式。一种常见的模式是使用以下直观的语法来执行空值检查:
string? s = null;
// ...
if (s is not null)
{
// s can be safely dereferenced
}
从 C# 9 开始,模式可以包括逻辑操作符——and
、or
和not
——以及关系操作符,为创建模式提供了丰富的语法。以下方法使用带有这些运算符的模式匹配来确定字符是否为字母:
bool IsLetter(char c)
{
return c is >= 'a' and <= 'z' or >= 'A' and <= 'Z';
}
十二、重新定义成员
派生类中的成员可以重定义其基类中的成员。对于所有类型的继承成员都可以这样做,但是它最常用于给实例方法新的实现。为了给一个方法一个新的实现,该方法在子类中被重新定义,使用与它在基类中相同的签名。签名包括方法的名称、参数和返回类型。
class Rectangle
{
public int x = 1, y = 10;
public int GetArea() { return x * y; }
}
class Square : Rectangle
{
public int GetArea() { return 2 * x; }
}
隐藏成员
必须指定该方法是打算隐藏还是覆盖继承的方法。默认情况下,新方法会隐藏它,但编译器会给出警告,提示应该显式指定该行为。
要删除警告,需要使用new
修饰符。这表明意图是隐藏继承的方法并用新的实现替换它。
class Square : Rectangle
{
public new int GetArea() { return 2 * x; }
}
重写成员
在覆盖一个方法之前,必须首先将virtual
修饰符添加到基类的方法中。此修饰符允许在派生类中重写方法。
class Rectangle
{
public int x = 1, y = 10;
public virtual int GetArea() { return x * y; }
}
然后可以使用override
修饰符来改变继承方法的实现。
class Square : Rectangle
{
public override int GetArea() { return 2 * x; }
}
隐藏和覆盖
override
和new
的区别是在Square
被上抛到Rectangle
时表现出来的。如果该方法用new
修饰符重新定义,那么这允许访问之前在Rectangle
中定义的隐藏方法。另一方面,如果使用override
修饰符重新定义方法,那么向上转换仍然会调用在Square
中定义的版本。简而言之,new
修饰符在类的层次结构中向下重新定义方法,而override
在层次结构中向上和向下重新定义方法。
密封关键字
为了防止被重写的方法在继承自派生类的类中被进一步重写,可以将该方法声明为sealed
来否定virtual
修饰符。
class Square : Rectangle
{
public sealed override int GetArea()
{
return 2 * x;
}
}
一个类也可以被声明为sealed
以防止任何类继承它。
sealed class NonInheritable {}
基本关键字
有一种方法可以访问父方法,即使它已经被重新定义。这是通过使用base
关键字引用基类实例来完成的。无论该方法是隐藏的还是被重写的,仍然可以通过使用该关键字来访问它。
class Triangle : Rectangle
{
public override int GetArea() { return base.GetArea()/2; }
}
base
关键字也可以用来从派生类构造函数中调用基类构造函数。然后,该关键字被用作构造函数体之前的方法调用,以冒号为前缀。
class Rectangle
{
public int x = 1, y = 10;
public Rectangle(int a, int b) { x = a; y = b; }
}
class Square : Rectangle
{
public Square(int a) : base(a,a) {}
}
当派生类构造函数没有对基类构造函数的显式调用时,编译器将自动插入对无参数基类构造函数的调用,以确保基类被正确构造。
class Square : Rectangle
{
public Square(int a) {} // : base() implicitly added
}
请注意,如果基类定义了非无参数的构造函数,编译器将不会创建默认的无参数构造函数。因此,在派生类中定义构造函数,而不显式调用已定义的基类构造函数,将导致编译时错误。
class Base { public Base(int a) {} }
class Derived : Base {} // compile-time error
十三、访问级别
每个类成员都有一个可访问性级别,它决定了该成员在哪里可见。C# 中有六种:public
、protected
、internal
、protected internal
、private
、private protected
,最后一种是在 C# 7.2 中添加的。类成员的默认访问级别是private
。
私有访问
无论访问级别如何,所有成员都可以在声明它们的类(定义类)中访问。这是唯一可以访问私有成员的地方。
public class Base
{
// Unrestricted access
public int iPublic;
// Defining assembly or derived class
protected internal int iProtInt;
// Derived class within defining assembly
private protected int iPrivProt;
// Defining assembly
internal int iInternal;
// Derived class
protected int iProtected;
// Defining class only
private int iPrivate;
void Test()
{
iPublic = 0; // allowed
iProtInt = 0; // allowed
iPrivProt = 0; // allowed
iInternal = 0; // allowed
iProtected = 0; // allowed
iPrivate = 0; // allowed
}
}
受保护的访问
受保护的成员可以从派生类中访问,但不能从任何其他类访问。
class Derived : Base
{
void Test()
{
iPublic = 0; // allowed
iProtInt = 0; // allowed
iPrivProt = 0; // allowed
iInternal = 0; // allowed
iProtected = 0; // allowed
iPrivate = 0; // inaccessible
}
}
内部访问
可以在本地程序集中的任何位置访问内部成员,但不能从另一个程序集中访问。程序集是. NET 项目的编译单元,可以是可执行程序(.exe
)或库(.dll
)。
// Defining assembly
class AnyClass
{
void Test(Base b)
{
b.iPublic = 0; // allowed
b.iProtInt = 0; // allowed
b.iPrivProt = 0; // inaccessible
b.iInternal = 0; // allowed
b.iProtected = 0; // inaccessible
b.iPrivate = 0; // inaccessible
}
}
在 Visual Studio 中,项目(程序集)包含在解决方案中。通过在“解决方案资源管理器”窗口中右击解决方案节点并选择“添加➤新项目”,可以向解决方案中添加第二个项目。
为了使第二个项目能够引用第一个项目中的可访问类型,您需要添加一个引用。为此,右键单击第二个项目的“引用”节点,然后单击“添加引用”。在“项目”下,选择第一个项目的名称,然后单击“确定”添加引用。
受保护的内部访问
受保护的内部访问意味着受保护的或内部的。因此,受保护的内部成员可以在当前程序集中的任何位置访问,或者在从封闭类派生的程序集外部的类中访问。
// Other assembly
class Derived : Base
{
void Test()
{
iPublic = 0; // allowed
iProtInt = 0; // allowed
iPrivProt = 0; // inaccessible
iInternal = 0; // inaccessible
iProtected = 0; // allowed
iPrivate = 0; // inaccessible
}
}
私人保护访问
私有受保护成员只能在从定义类型派生的类型的定义程序集中访问。换句话说,该访问级别将成员的可见性限制为受保护和内部。
// Defining assembly
class Derived : Base
{
void Test()
{
iPublic = 0; // allowed
iProtInt = 0; // allowed
iPrivProt = 0; // allowed
iInternal = 0; // allowed
iProtected = 0; // allowed
iPrivate = 0; // inaccessible
}
}
公共访问
public
修饰符允许从任何可以引用成员的地方进行无限制的访问。
// Other assembly
class AnyClass
{
void Test(Base b)
{
b.iPublic = 0; // allowed
b.iProtInt = 0; // inaccessible
b.iPrivProt = 0; // inaccessible
b.iInternal = 0; // inaccessible
b.iProtected = 0; // inaccessible
b.iPrivate = 0; // inaccessible
}
}
顶级访问级别
顶级成员是在任何其他类型之外声明的类型。在 C# 中,可以在顶层声明以下类型:class
、interface
、struct
、enum
和delegate
。默认情况下,这些未包含的成员具有内部访问权限。为了能够使用另一个程序集的顶级成员,该成员必须被标记为public
。这是顶级成员唯一允许的其他访问级别。
internal class InternalClass {}
public class PublicClass {}
内部类
类可以包含内部类,可以设置为六个访问级别中的任何一个。访问级别对内部类的影响与对其他成员的影响相同。如果该类不可访问,则不能实例化或继承。默认情况下,内部类是私有的,这意味着它们只能在定义它们的类中使用。
class MyClass
{
// Inner classes (nested classes)
public class PublicClass {}
protected internal class ProtIntClass {}
private protected class PrivProtClass {}
internal class InternalClass {}
protected class ProtectedClass {}
private class PrivateClass {}
}
访问级别指南
作为指南,在选择访问级别时,通常最好尽可能使用最严格的级别。这是因为成员可以被访问的位置越多,它可以被错误访问的位置就越多,这使得代码更难调试。使用限制性访问级别还会使修改一个类变得更容易,而不会破坏使用该类的任何其他程序员的代码。
十四、静态
static
关键字可用于声明无需创建类实例就能访问的字段和方法。静态(类)成员只存在于一个副本中,该副本属于类本身,而实例(非静态)成员是作为每个新对象的新副本创建的。这意味着静态方法不能使用实例成员,因为这些方法不是实例的一部分。另一方面,实例方法可以使用静态成员和实例成员。
class Circle
{
// Instance variable (one per object)
public float r = 10F;
// Static/class variable (only one instance)
public static float pi = 3.14F;
// Instance method
public float GetArea()
{
return ComputeArea(r);
}
// Static/class method
public static float ComputeArea(float a)
{
return pi*a*a;
}
}
访问静态成员
要从类外部访问静态成员,先使用类名,然后使用点运算符。该操作符与用于访问实例成员的操作符相同,但是要访问它们,需要一个对象引用。对象引用不能用于访问静态成员。
class MyApp
{
static void Main()
{
float f = Circle.ComputeArea(Circle.pi);
}
}
静态方法
静态成员的优点是它们可以被其他类使用,而不必创建该类的实例。因此,当只需要变量的一个实例时,应该将字段声明为静态的。如果方法执行独立于任何实例变量的泛型函数,则应该将它们声明为静态的。一个很好的例子是System.Math
类,它提供了大量的数学方法。这个类只包含静态成员和常量。
static void Main()
{
double pi = System.Math.PI;
}
静态字段
静态字段的优势在于,它们在应用的整个生命周期中都存在。因此,静态字段可以用来记录一个方法被调用的次数。
static int count = 0;
public static void Dummy()
{
count++;
}
静态字段的默认值在第一次使用之前只设置一次。
静态类
如果一个类只包含静态成员和常量字段,它也可以被标记为static
。静态类不能被继承或实例化到对象中。尝试这样做将导致编译时错误。
static class MyCircle {}
静态构造函数
静态构造函数可以执行初始化类所需的任何操作。通常,这些操作涉及初始化静态字段,这些字段在声明时无法初始化。如果它们的初始化需要不止一行或一些其他逻辑被初始化,这可能是必要的。
class MyClass
{
static int[] array = new int[5];
static MyClass()
{
for(int i = 0; i < array.Length; i++)
array[i] = i;
}
}
与常规的实例构造函数不同,静态构造函数只运行一次。当创建类的实例或引用类的静态成员时,这将自动发生。静态构造函数不能被直接调用,也不能被继承。如果静态字段也有初始值设定项,那么这些初始值将在静态构造函数运行之前被赋值。
静态局部函数
局部函数自动捕获其封闭范围的上下文,使其能够引用自身外部的成员,如父方法的局部变量。
string GetName()
{
string name = "John";
return LocalFunc();
string LocalFunc() { return name; }
}
从 C# 8 开始,static 修饰符可以应用于局部函数来禁用这种行为。然后,编译器将确保静态局部函数不会引用其自身范围之外的任何成员。以这种方式限制访问有助于简化调试,因为您将知道本地函数不会修改任何外部变量。
string GetName()
{
string name = "John";
return LocalFunc(name);
static string LocalFunc(string s) { return s; }
}
扩展方法
C# 3.0 中增加的一个特性是扩展方法,它提供了一种在现有类的定义之外添加新实例方法的方式。扩展方法必须在静态类中定义为static
,关键字this
用在第一个参数上来指定扩展哪个类。
static class MyExtensions
{
// Extension method
public static int ToInt(this string s) {
return Int32.Parse(s);
}
}
extension 方法对于其第一个参数类型的对象是可调用的,在本例中是string
,就好像它是那个类的实例方法一样。不需要对静态类的引用。
class MyApp
{
static void Main() {
string s = "10";
int i = s.ToInt();
}
}
因为扩展方法有一个对象引用,所以它可以使用它正在扩展的类的实例成员。但是,它不能使用由于其访问级别而不可访问的任何类的成员。扩展方法的好处在于,它们使您能够向类中“添加”方法,而不必修改或派生原始类型。
十五、属性
C# 中的属性提供了通过称为访问器的特殊方法读写字段来保护字段的能力。它们通常被声明为public
,具有与它们要保护的字段相同的数据类型,后跟属性名和定义get
和set
访问器的代码块。属性的命名约定是使用 PascalCase,与方法相同。
class Time
{
private int sec;
public int Seconds
{
get { return sec; }
set { sec = value; }
}
}
请注意,上下文值关键字对应于分配给属性的值。属性是作为方法实现的,但是使用起来就像是字段一样。
static void Main()
{
Time t = new Time();
t.Seconds = 5;
int s = t.Seconds; // 5
}
财产优势
由于在先前定义的属性中没有特殊的逻辑,它在功能上与公共字段相同。然而,一般来说,由于属性带来的许多优点,公共字段永远不应该在真实世界的编程中使用。
首先,属性允许开发人员改变属性的内部实现,而不破坏任何正在使用它的程序。这对于已发布的类尤其重要,因为其他开发人员可能正在使用这些类。例如,在Time
类中,字段的数据类型可能需要从int
更改为byte
。使用属性,这种转换可以在后台处理。但是,对于公共字段,更改已发布类的基础数据类型可能会中断任何正在使用该类的程序。
class Time
{
private byte sec;
public int Seconds
{
get { return (int)sec; }
set { sec = (byte)value; }
}
}
属性的第二个优点是,它们允许在允许更改之前对数据进行验证。例如,可以通过以下方式防止向seconds
字段分配负值:
class Time
{
private int sec;
public int Seconds
{
get { return sec; }
set
{
if (value > 0)
sec = value;
else
sec = 0;
}
}
}
属性不必与实际字段相对应。他们也可以计算自己的价值。数据甚至可以来自类的外部,比如来自数据库。也没有什么可以阻止程序员在访问器中做其他事情,比如保存一个更新计数器。
public int Hour
{
get
{
return sec / 3600;
}
set
{
sec = value * 3600;
count++;
}
}
private int count = 0;
只读和只写属性
任何一个访问器都可以省略。如果没有set
访问器,该属性将变为只读,而通过省去get
访问器,该属性将变为只写。
// Read-only property
private int Seconds
{
public get { return sec; }
}
// Write-only property
private int Seconds
{
public set { sec = value; }
}
属性访问级别
可以限制访问者的访问级别。例如,为了防止从类外部修改属性,可以将set
访问器设为私有。
private set { sec = value; }
属性本身的访问级别也可以更改,以限制两个访问者。默认情况下,访问器是公共的,属性本身是私有的。
private int Seconds
{
public get { return sec; }
public set { sec = value; }
}
自动实现的属性
get
和set
访问器直接对应于一个字段的属性是很常见的。因此,有一种简化的方式来编写这样的属性,即省去访问器代码块和私有字段。这种语法是在 C# 3.0 中引入的,被称为自动实现的属性。
class Time
{
public int Seconds { get; set; }
}
C# 6 中的自动属性增加了两个额外的功能。首先,可以将初始值设置为声明的一部分。第二,通过省略set
访问器,可以将 autoproperty 设置为只读。这样的属性只能在构造函数中设置,或者作为声明的一部分设置,如下所示:
class Time
{
// Read-only auto-property with initializer
public System.DateTime created { get; }
= System.DateTime.Now;
}
从 C# 9 开始,set 访问器可以由 init 访问器替换,只允许在对象构造期间设置属性。此访问器类型仅允许用于实例属性,不允许用于静态属性。
class Time
{
public int Seconds { get; init; }
}
这个 init-only setter 可以在构造函数中初始化,作为声明的一部分,或者如下例所示使用对象初始化器。对象初始值设定项允许将值赋给任何可访问的字段或属性,而不必调用构造函数。
static void Main()
{
Time t = new Time() { Seconds = 5; }
t.Seconds = 5; // Error: Seconds not settable
}
十六、索引器
索引器允许一个对象被当作一个数组。它们以与属性相同的方式声明,只是使用了关键字this
而不是名称,并且它们的访问器接受参数。在下面的例子中,索引器对应于一个名为data
的对象数组,因此索引器的类型被设置为object
:
class Array
{
object[] data = new object[10];
public object this[int index]
{
get { return data[index]; }
set { data[index] = value; }
}
}
get
访问器从对象数组中返回指定的元素,而set
访问器将值插入到指定的元素中。有了索引器,就可以创建该类的一个实例,并将其用作数组来获取和设置元素。
static void Main()
{
Array a = new Array();
a[5] = "Hello World";
object o = a[5]; // Hello World
}
索引器参数
索引器的参数列表类似于方法的参数列表,只是它必须至少有一个参数,并且不允许使用ref
或out
修饰符。例如,如果有一个二维数组,列和行索引可以作为单独的参数传递。
class Array
{
object[,] data = new object[10, 10];
public object this[int i, int j]
{
get { return data[i, j]; }
set { data[i, j] = value; }
}
}
index 参数不必是整数类型。对象也可以像索引参数一样被传递。然后可以使用get
访问器返回传递的对象所在的索引位置。
class Array
{
object[] data = new object[10];
public int this[object o]
{
get { return System.Array.IndexOf(data, o); }
}
}
索引器重载
这两种功能都可以通过重载索引器来提供。参数的类型和数量将决定调用哪个索引器。
class Array
{
object[] data = new object[10];
public int this[object o]
{
get { return System.Array.IndexOf(data, o); }
}
public object this[int i]
{
get { return data[i]; }
set { data[i] = value; }
}
}
请记住,在真正的程序中,范围检查应该包含在访问器中,以避免因试图超出数组长度而导致的异常。
public object this[int i]
{
get {
return (i >= 0 && i < data.Length) ? data[i] : null;
}
set {
if (i >= 0 && i < data.Length)
data[i] = value;
}
}
范围和指数
C# 8 引入了两个新的操作符来对集合(比如数组)进行切片。范围运算符(x..y
)指定元素范围的开始和结束索引。这种操作的结果可以直接用于循环或存储在系统中。范围类型。
int[] b = { 1, 2, 3, 4, 5 };
foreach (int n in b[1..3]) {
System.Console.Write(n); // "23"
}
System.Range range = 0..3; // 1st to 3rd
foreach (int n in b[range]) {
System.Console.Write(n); // "123"
}
在 C# 8 中引入的第二个操作符被命名为 hat 操作符(^
)。它用作前缀,从数组末尾开始计算索引。可以使用该系统存储索引。索引类型。
string s = "welcome";
System.Index first = 0;
System.Index last = ¹;
System.Console.WriteLine($"{s[first]}, {s[last]}"); // "w, e"
这两个运算符可以在同一个表达式中组合使用,如下例所示。请注意,范围运算符的起点或终点都可以省略,以包含所有剩余的元素。
string s = "welcome";
System.Console.WriteLine(s[⁴..]); // "come"
十七、接口
接口用于指定派生类必须实现的成员。它们是用关键字interface
定义的,后跟一个名称和一个代码块。他们的命名惯例是以大写字母I
开始,然后每个单词都大写。
interface IMyInterface {}
接口签名
接口代码块只能包含方法、属性、索引器和事件的签名,以及 C# 8 的默认实现。指定签名时,接口成员的主体被分号替换。接口成员不能有任何限制性访问修饰符,因为它们总是公共的。
interface IMyInterface
{
// Interface method
int GetArea();
// Interface property
int Area { get; set; }
// Interface indexer
int this[int index] { get; set; }
// Interface event
event System.EventHandler MyEvent;
}
界面示例
在下面的例子中,用一个名为Compare
的方法定义了一个名为IComparable
的接口:
interface IComparable
{
int Compare(object o);
}
接下来定义的类Circle
通过使用与继承相同的符号来实现这个接口。然后,Circle
类必须定义Compare
方法,该方法将返回圆半径之间的差值。除了与接口中定义的成员具有相同的签名之外,实现的成员必须是公共的。
class Circle : IComparable
{
int r;
public int Compare(object o)
{
return r - (o as Circle).r;
}
}
尽管一个类只能从一个基类继承,但它可以实现任意数量的接口。这是通过在基类之后的逗号分隔列表中指定接口来实现的。
功能界面
演示了接口的第一个用途,即定义一个类可以共享的特定功能。它允许程序员在不知道类的实际类型的情况下使用接口成员。举例来说,下面的方法采用两个IComparable
对象并返回最大的一个。这个方法将为实现IComparable
接口的同一个类的任何两个对象工作,因为这个方法只使用通过那个接口公开的功能。
static object Largest(IComparable a, IComparable b)
{
return (a.Compare(b) > 0) ? a : b;
}
类接口
使用接口的第二种方法是为一个类提供一个实际的接口,通过这个接口可以使用这个类。这样的接口定义了使用该类的程序员需要的功能。
interface IMyClass
{
void Exposed();
}
class MyClass : IMyClass
{
public void Exposed() {}
public void Hidden() {}
}
然后,程序员可以通过这个接口查看类的实例,方法是将对象封装在接口类型的变量中。
IMyInterface m = new MyClass();
这种抽象提供了两个好处。首先,它使其他程序员更容易使用该类,因为他们现在只能访问与他们相关的成员。其次,它使类更加灵活,因为只要遵循接口,它的实现就可以改变,而不会被使用该类的其他程序员注意到。
默认实现
C# 8 增加了为接口成员创建默认实现的能力。考虑以下简单日志记录接口的示例:
interface ILogger
{
void Info(string message);
}
class ConsoleLogger : ILogger
{
public void Info(string message)
{
System.Console.WriteLine(message);
}
}
通过提供一个默认的实现,这个现有的接口可以用一个新的成员来扩展,而不会破坏任何使用该接口的类。
interface ILogger
{
void Info(string message);
void Error(string message)
{
System.Console.WriteLine(message);
}
}
十八、抽象
抽象类提供了部分实现,其他类可以在此基础上构建。当一个类被声明为抽象类时,这意味着除了正常的类成员之外,该类还可以包含必须在派生类中实现的不完整成员。
抽象成员
任何需要主体的成员都可以被声明为抽象的,比如方法、属性和索引器。这些成员没有实现,只指定它们的签名,而它们的主体用分号替换。
abstract class Shape
{
// Abstract method
public abstract int GetArea();
// Abstract property
public abstract int area { get; set; }
// Abstract indexer
public abstract int this[int index] { get; set; }
// Abstract event
public delegate void MyDelegate();
public abstract event MyDelegate MyEvent;
// Abstract class
public abstract class InnerShape {};
}
抽象示例
在下面的例子中,该类有一个名为GetArea
的抽象方法:
abstract class Shape
{
protected int x = 100, y = 100;
public abstract int GetArea();
}
如果一个类是从这个抽象类派生的,那么它将被强制重写这个抽象成员。这不同于virtual
修饰符,它指定可以有选择地覆盖成员。
class Rectangle : Shape
{
public override int GetArea() { return x * y; }
}
派生类也可以被声明为抽象的,在这种情况下,它不必实现任何抽象成员。
abstract class Rectangle : Shape {}
抽象类也可以从非抽象类继承。
class NonAbstract {}
abstract class Abstract : NonAbstract {}
如果基类有虚成员,这些虚成员可以被重写为抽象成员,以强制进一步的派生类为它们提供新的实现。
class Vehicle
{
void virtual Move() {}
}
abstract class Car : Vehicle
{
void abstract override Move() {}
}
抽象类可以用作接口来保存由派生类构成的对象。
Shape s = new Rectangle();
不可能实例化抽象类。即便如此,抽象类可能有构造函数,可以通过使用base
关键字从派生类中调用这些构造函数。
Shape s = new Shape(); // compile-time error
抽象类和接口
抽象类在许多方面类似于接口。两者都可以定义派生类必须实现的成员签名,但是它们都不能被实例化。关键区别首先是抽象类可以包含抽象和非抽象成员,而接口只能包含抽象成员和默认实现。第二,一个类可以实现任意数量的接口,但只能从一个类继承,不管是不是抽象的。
// Defines default functionality and definitions
abstract class Shape
{
protected int x = 100, y = 100;
public abstract int GetArea();
}
// Class is a Shape
class Rectangle : Shape { /* ... */ }
// Defines an interface or a specific functionality
interface IComparable
{
int Compare(object o);
}
// Class can be compared
class MyClass : IComparable { /* ... */ }
抽象类就像非抽象类一样,可以扩展一个基类并实现任意数量的接口。但是,接口不能从类继承。它可以从另一个接口继承,这有效地将两个接口合并为一个。
十九、命名空间
命名空间提供了一种将相关顶级成员分组到层次结构中的方法。它们也用于避免命名冲突。没有包含在命名空间中的顶级成员(如类)被认为属于默认命名空间。它可以通过包含在命名空间块中而移动到另一个命名空间。命名空间的命名约定与类的相同,每个单词最初都是大写的。
namespace MyNamespace
{
class MyClass {}
}
嵌套命名空间
名称空间可以嵌套任意多级,以进一步定义名称空间层次结构。
namespace Product
{
namespace Component
{
class MyClass {}
}
}
更简洁的写法是用点分隔名称空间。
namespace Product.Component
{
class MyClass {}
}
请注意,在项目中的另一个类中再次声明同一个命名空间的效果与两个命名空间包含在同一个块中的效果相同,即使该类位于另一个源代码文件中。
命名空间访问
要从另一个命名空间访问一个类,需要指定它的完全限定名。
namespace Product.Component
{
public class MyClass {}
}
namespace OtherProduct
{
class MyApp
{
static void Main()
{
Product.Component.MyClass myClass;
}
}
}
文件范围的命名空间
C# 10 为源文件只包含一个名称空间的典型情况引入了一种不太冗长的名称空间格式。然后,可以在文件的开头将命名空间指定为声明,而不必将其所有成员括在花括号({})中。
namespace Product.Component;
public class MyClass {} // belongs to Product.Component
在文件范围的命名空间声明之后定义的任何实体都将属于该命名空间。
使用指令
通过在名称空间中包含一个using
指令,可以缩短完全限定名。然后,可以在代码文件中的任何位置访问该命名空间的成员,而不必在每个引用前添加命名空间。在代码文件中,必须将using
指令放在所有其他成员之前。
using Product.Component;
拥有对这些成员的直接访问权限意味着,如果当前命名空间中存在冲突的成员签名,则包含的命名空间中的成员将被隐藏。因此,要在下面的示例中使用导入的命名空间中的类,由于命名冲突,必须再次指定完全限定名:
using Product1.Component;
namespace Product1.Component
{
public class MyClass
{
public static int x;
}
}
namespace Product2
{
public class MyClass
{
static void Main()
{
int x = Product1.Component.MyClass.x;
}
}
}
为了简化这种引用,可以将using
指令改为将名称空间分配给一个别名。
using MyAlias = Product1.Component;
// ...
int x = MyAlias.MyClass.x;
更简单的方法是使用相同的别名符号,将完全限定类名定义为代码文件的新类型。
using MyType = Product1.Component.MyClass;
// ...
int x = MyType.x;
在 C# 6 中增加了一个using static
指令。此指令仅将该类型的可访问静态成员导入当前命名空间。在下面的例子中,由于using static
指令的原因,Math
类的静态成员可以被无限制地使用:
using static System.Math;
public class Circle
{
public double Radius { get; set; }
public double Area
{
get { return PI * Pow(radius, 2); }
}
}
源文件通常以多个 using 指令开始,这些指令在许多文件中是相同的。这在 C# 10 中用全局修饰符解决了,它使得 using 指令应用于项目中的所有源文件。这样,在项目的任何源文件中,常用的命名空间只需指定一次。
// Usable in all source files
global using System.IO;
global using System.Collections;
global using System.Threading;
顶级语句
C# 9 增加了顶级语句,允许省略 Main 方法及其周围的类。这使得将规范的“Hello World”程序简化为一行代码成为可能。
System.Console.WriteLine("Hello World"); // "Hello World"
省略的类和 Main 方法会自动生成,所以这只是编译器的一个特性。在顶层键入的任何语句都将被移到 Main 方法中。用法指令可以出现在顶级语句之前,任何类型定义或命名空间都必须放在顶级语句之下。
using System;
// Moved to Main method
Person p = new() { Name = "Sam" };
Console.WriteLine($"Hi {p.Name}"); // "Hi Sam"
class Person
{
public string? Name { get; set; }
}
顶层语句对于编写短小精悍的程序很有用。请注意,由于一个程序只能有一个 Main 方法,所以一个项目只能有一个包含顶级语句的文件。
二十、枚举类型
一个枚举是一种特殊的值类型,由一系列命名的常量组成。要创建一个,可以使用enum
关键字,后跟一个名称和一个代码块,该代码块包含一个以逗号分隔的常量元素列表。
enum State { Running, Waiting, Stopped };
此枚举类型可用于创建保存这些常量的变量。为了给enum
变量赋值,从enum
访问元素,就好像它们是一个类的静态成员一样。
State state = State.Running;
枚举示例
switch
语句提供了一个枚举何时有用的好例子。与使用普通常量相比,枚举的优点是允许程序员清楚地指定允许哪些常量值。这提供了编译时类型安全,并且 IntelliSense 还使这些值更容易记住。
switch (state)
{
case State.Running: break;
case State.Waiting: break;
case State.Stopped: break;
}
枚举常量值
通常不需要知道enum
常量所代表的实际常量值,但有时这很有用。默认情况下,第一个元素的值为0
,每个后续元素的值都要高一个。
enum State
{
Running, // 0
Waiting, // 1
Stopped // 2
};
这些默认值可以通过给常量赋值来覆盖。这些值可以通过表达式计算得出,并且不必是唯一的。
enum State
{
Running = 0, Waiting = 3, Stopped = Waiting + 1
};
枚举常量类型
常量元素的底层类型被隐式地指定为int
,但是这可以通过在枚举名称后使用一个冒号,后跟所需的整数类型来更改。
enum MyEnum : byte {};
枚举访问级别和范围
枚举的访问级别与类的访问级别相同。默认情况下,它们是内部的,但也可以声明为公共的。虽然枚举通常是在顶层定义的,但是它们也可以包含在一个类中。在类中,默认情况下他们拥有私有访问权限,并且可以设置为任何一种访问级别。
枚举方法
一个枚举常量可以被转换成一个int
并且ToString
方法可以用来获得它的名字。
static void Main()
{
State state = State.Run;
int i = (int)state; // 0
string t = state.ToString(); // "Run"
}
在System.Enum
类中有几个枚举方法可用,比如GetNames()
获得包含enum
常量名称的数组。注意,这个方法将一个类型对象(System.Type
)作为它的参数,它是使用typeof
操作符检索的。
enum Colors { Red, Green };
static void Main()
{
foreach (string name in System.Enum.GetNames(typeof(Colors)))
{
System.Console.Write(name); // "RedGreen"
}
}
二十一、异常处理
异常处理允许程序员处理程序中可能出现的意外情况。例如,考虑使用System.IO
名称空间中的StreamReader
类打开一个文件。要查看该类可能引发的异常类型,可以将光标悬停在 Visual Studio 中的类名上。例如,你可能会看到System.IO
例外FileNotFoundException
和DirectoryNotFoundException
。如果这些异常中的任何一个发生,程序将终止并显示一条错误消息。
using System;
using System.IO;
class ErrorHandling
{
static void Main()
{
// Run-time error
StreamReader sr = new StreamReader("missing.txt");
}
}
Try-Catch 语句
为了避免程序崩溃,必须使用try-catch
语句捕捉异常。该语句由一个包含可能导致异常的代码的try
块和一个或多个catch
子句组成。如果try
块成功执行,程序将在try-catch
语句后继续运行。然而,如果出现异常,执行将被传递到能够处理该异常类型的第一个catch
模块。
try {
StreamReader sr = new StreamReader("missing.txt");
}
catch {
Console.WriteLine("File not found");
}
捕捉块
由于前面的catch
块没有被设置为处理任何特定的异常,它将捕获所有的异常。这相当于捕获了System.Exception
类,因为所有的异常都源自这个类。
catch (Exception) {}
为了捕捉更具体的异常,需要将那个catch
块放在更一般的异常之前。
catch (FileNotFoundException) {}
catch (Exception) {}
catch
块可以有选择地定义一个异常对象,该对象可以用来获得关于异常的更多信息,比如错误的描述。
catch (Exception e) {
Console.WriteLine("Error: " + e.Message);
}
异常过滤器
C# 6 中增加了异常过滤器,允许catch
块包含条件。使用when
关键字将条件附加到catch
块。只有当条件评估为true
时,匹配的异常才会被捕获,如下例所示:
try {
StreamReader sr = new StreamReader("missing.txt");
}
catch (FileNotFoundException e)
when (e.FileName.Contains(".txt")) {
Console.WriteLine("Missing file: " + e.FileName);
}
使用异常过滤器时,相同的异常类型可能出现在多个catch
子句中。此外,在某些情况下,更一般的异常可以放在更具体的异常之前。在下一个示例中,通过调用日志记录方法作为异常筛选器来记录所有异常。因为该方法返回false
,所以一般的异常不会被捕获,从而允许另一个catch
块处理该异常。
using System;
using System.IO;
static class ErrorHandling
{
// Extension method
public static bool LogException(this Exception e)
{
Console.Error.WriteLine($"Exception: {e}");
return false;
}
static void Main()
{
try {
var sr = new StreamReader("missing.txt");
}
catch (Exception e) when (LogException(e)) {
// Never reached
}
catch (FileNotFoundException) {
// Actual handling of exception
}
}
}
最终阻止
作为try-catch
语句的最后一个子句,可以添加一个finally
块。该块用于清理在try
块中分配的某些资源。通常,一旦不再需要有限的系统资源和图形组件,就需要以这种方式释放它们。无论是否有异常,finally
块中的代码将一直执行。即使try
块以跳转语句结束,比如return
,情况也是如此。
在前面使用的例子中,如果在try
块中打开的文件被成功打开,那么它应该被关闭。这将在下一个代码段中正确完成。为了能够从finally
子句访问StreamReader
对象,必须在try
块之外声明它。请记住,如果您忘记关闭流,垃圾收集器最终会为您关闭它,但最好自己动手。
StreamReader sr = null;
try {
sr = new StreamReader("missing.txt");
}
catch (FileNotFoundException) {}
finally {
if (sr != null) sr.Close();
}
前面的语句称为try-catch-finally
语句。也可以省去catch
块来创建一个try-finally
语句。该语句不会捕捉任何异常。相反,它将确保正确处置在try
块中分配的任何资源。如果分配的资源不抛出任何异常,这可能是有用的。例如,这样一个类在System.Drawing
名称空间中将是Bitmap
。
using System.Drawing;
// ...
Bitmap b = null;
try {
b = new Bitmap(100, 50);
System.Console.WriteLine(b.Width); // "100"
}
finally {
if (b != null) b.Dispose();
}
注意,当使用控制台项目时,需要手动添加对System.Drawing
程序集的引用,以便可以访问这些成员。为此,请在“解决方案资源管理器”窗口中右击“引用”文件夹,然后选择“添加引用”。然后从“程序集➤框架”中,选择System.Drawing
程序集,并单击“确定”将其引用添加到您的项目中。
using 语句
using
语句为编写try-finally
语句提供了更简单的语法。该语句以using
关键字开始,后面是括号中指定的要获取的资源。然后,它包含一个代码块,其中可以使用所获得的资源。当代码块执行完毕,自动调用对象的Dispose
方法进行清理。这个方法来自于System.IDisposable
接口,所以指定的资源必须实现这个接口。以下代码执行与上一示例相同的功能,但代码行更少:
using System.Drawing;
// ...
using (Bitmap b = new Bitmap(100, 50)) {
System.Console.WriteLine(b.Width); // "100"
} // disposed
C# 8 通过允许使用声明进一步简化了资源管理。这消除了对花括号的需要,因为当资源处理程序超出范围时,它将被自动处理掉。
void MyBitmap()
{
using Bitmap b = new Bitmap(100, 50);
System.Console.WriteLine(b.Height); // "50"
} // disposed
抛出异常
当出现方法无法恢复的情况时,它可以生成一个异常,通知调用方该方法已失败。这是通过使用关键字throw
后跟一个从System.Exception
派生的类的新实例来完成的。
static void MakeError()
{
throw new System.DivideByZeroException("My Error");
}
然后,异常将沿调用方堆栈向上传播,直到被捕获。如果调用者捕捉到异常,但无法从中恢复,那么可以只使用throw
关键字来重新抛出异常。如果没有更多的try-catch
语句,程序将停止执行并显示错误信息。
static void Main()
{
try {
MakeError();
}
catch {
throw; // rethrow error
}
}
作为一个语句,throw
关键字不能在需要表达式的上下文中使用,例如在三元语句中。C# 7.0 改变了这一点,允许将throw
也用作表达式。这扩展了可能引发异常的位置,例如在以下空合并表达式中:
using System;
class MyClass
{
private string _name;
public string name
{
get => _name;
set => _name = value ?? throw new
ArgumentNullException(nameof(name)+" was null");
}
static void Main()
{
MyClass c = new MyClass();
c.name = null; // exception: name was null
}
}
注意这里使用的nameof
表达式,它是在 C# 6 中引入的。该表达式将括号内的符号转换为字符串。如果重命名属性,这样做的好处就会显现出来,因为 IDE 可以找到并重命名该符号。如果使用了字符串,情况就不是这样了。
二十二、运算符重载
运算符重载允许在一个或两个操作数属于某个类的情况下重新定义和使用运算符。如果操作正确,这可以简化代码,并使用户定义的类型像简单类型一样易于使用。
运算符重载示例
在这个例子中,有一个名为Number
的类,它有一个整数字段和一个用于设置该字段的构造函数。还有一个静态的Add
方法,将两个Number
对象加在一起,并将结果作为一个新的Number
对象返回。
class Number
{
public int value;
public Number(int i) { value = i; }
public static Number Add(Number a, Number b) {
return new Number(a.value + b.value);
}
}
可以使用Add
方法将两个Number
实例添加在一起。
Number a = new Number(10), b = new Number(5);
Number c = Number.Add(a, b);
二元运算符重载
运算符重载的作用是简化语法,从而为类提供更直观的接口。要将Add
方法转换为加法符号的重载方法,请将方法名替换为operator
关键字,后跟要重载的运算符。关键字和操作符之间的空格可以选择省略。注意,要使一个操作符重载方法工作,它必须同时被定义为public
和static
。
class Number
{
public int value;
public Number(int i) { value = i; }
public static Number operator +(Number a, Number b) {
return new Number(a.value + b.value);
}
}
由于该类现在重载加法符号,因此该运算符可用于执行所需的计算。
Number a = new Number(10), b = new Number(5);
Number c = a + b;
一元运算符重载
加法是二元运算符,因为它需要两个操作数。要重载一元运算符,如 increment ( ++
),可以使用单个方法参数。
public static Number operator ++(Number a)
{
return new Number(a.value + 1);
}
请注意,这将重载增量运算符的后缀和前缀版本。
Number a = new Number(10);
a++;
++a;
返回类型和参数
重载一元运算符时,返回类型和参数类型必须是封闭类型。另一方面,当重载大多数二元运算符时,返回类型可以是任何类型,除了void
,并且只有一个参数必须是封闭类型。这意味着可以用其他方法参数进一步重载二元运算符,例如,允许将一个Number
和一个int
相加。
public static Number operator +(Number a, int b)
{
return new Number(a.value + b);
}
过载运算符
C# 允许重载几乎所有的运算符,如下表所示。组合赋值运算符不能显式重载。相反,当它们对应的算术运算符或按位运算符重载时,它们会被隐式重载。
|二元运算符
|
一元运算符
|
不可超越
|
| --- | --- | --- |
| + - * / % (+= -= *= /= %=)``& | ^ << >> (&= |= ^= <<= >>=)``== != > < >= <=
| + - ! ~ ++ -- true false
| && || = . [ ] ( ) :: ?: ?? -> => new as is sizeof typeof nameof
|
比较运算符以及true
和false
必须成对重载。例如,重载等于运算符意味着不等于运算符也必须重载。
真假运算符重载
注意上表中的true
和false
被认为是操作符。通过重载它们,一个类的对象可以在条件语句中使用,其中该对象需要被评估为布尔类型。重载它们时,返回类型必须是bool
。
class Number
{
public int value;
public Number(int i) { value = i; }
public static bool operator true(Number a) {
return (a.value != 0);
}
public static bool operator false(Number a) {
return (a.value == 0);
}
}
class MyApp
{
static void Main()
{
Number number = new Number(10);
if (number) System.Console.Write("true");
else System.Console.Write("false");
}
}
二十三、自定义转换
本章介绍如何为对象定义自定义类型转换。如下例所示,一个名为Number
的类是用一个int
属性和一个构造函数创建的。通过自定义类型转换,允许一个int
被隐式转换成这个类的对象成为可能。
class Number
{
public Number(int i) { Value = i; }
public int Value { get; init; }
}
隐式转换方法
为此,需要向类中添加一个隐式转换方法。此方法的签名看起来类似于一元运算符重载所使用的签名。它必须声明为public static
并包含operator
关键字。但是,指定的不是运算符符号,而是返回类型,这是转换的目标类型。单个参数将保存要转换的值。还包含了implicit
关键字,它指定该方法用于执行隐式转换。
public static implicit operator Number(int value)
{
return new Number(value);
}
有了这个方法,一个int
可以被隐式地转换成一个Number
对象。
Number number = 5; // implicit conversion
可以添加另一个转换方法来处理相反方向的转换,从一个Number
对象到一个int
。
public static implicit operator int(Number number)
{
return number.Value;
}
显式转换方法
为了防止编译器进行潜在的非预期的对象类型转换,可以将转换方法声明为explicit
而不是implicit
。
public static explicit operator int(Number number)
{
return number.Value;
}
explicit
关键字意味着程序员必须指定一个显式强制转换来调用类型转换方法。特别是,如果转换的结果导致信息丢失,或者如果转换方法可能引发异常,则应该使用显式转换方法。
Number number = 5;
int value = (int)number; // explicit conversion
二十四、结构体
C# 中的struct
关键字用于创建值类型。一个struct
类似于一个类,因为它表示一个主要由字段和方法成员组成的结构。然而,struct
是值类型,而类是引用类型。因此,struct
变量直接存储struct
的数据,而类变量只存储对内存中分配的对象的引用。
结构变量
与类共享大部分相同的语法。例如,下面的struct
被命名为Point
,由两个公共字段组成:
struct Point
{
public int x, y;
}
给定这个struct
定义,Point
类型的变量可以使用new
操作符以熟悉的方式初始化。
Point p = new Point();
当以这种方式创建一个struct
变量时,将调用默认的构造函数,将字段设置为它们的默认值。与类不同,struct
s 也可以不使用new
操作符进行实例化。这些字段将保持未分配状态。然而,类似于试图使用局部未初始化变量时,编译器不允许读取字段,直到它们被初始化。
Point q;
int y = q.x; // compile-time error
结构构造函数
除了不能包含析构函数之外,类可以包含相同的成员。当定义一个构造函数时,编译器将强制所有的struct
字段都被赋值,以避免与未赋值变量相关的问题。
struct Point
{
public int x, y;
public Point(int x, int y)
{
this.x = x;
this.y = y;
}
}
给定这个定义,下面的语句都将创建一个字段初始化为零的Point
。请注意,无参数构造函数是由编译器自动提供的,它将字段初始化为默认值。
Point p1 = new Point();
Point p2 = new Point(0, 0);
或者,default 关键字可用于初始化所有字段都设置为默认值的结构,与编译器的无参数构造函数相同。从 C# 10 开始,无参数构造函数也可以是用户定义的,在这种情况下,它的行为可以不同于默认的初始化,如下所示:
// Top-level statements
Size s1 = default(Size); // s1.size = 0
Size s2 = new Size(); // s2.size = 1
struct Size
{
public int size;
public Size() { this.size = 1; }
}
结构字段初始化器
可以为struct
中的字段分配初始值。在 C# 10 之前,这只允许声明为const
或static
的字段。
struct MyStruct
{
public int x = 1, y = 1; // allowed (C# 10)
public int z { get; set; } = 1; // allowed (C# 10)
public static int myStatic = 5; // allowed
public const int myConst = 10; // allowed
}
结构继承
一个struct
不能从另一个struct
或类继承,也不能是基类。这也意味着struct
成员不能被声明为protected
、private protected
或protected internal
,并且struct
方法不能被标记为virtual
。Struct
s 隐式继承自System.ValueType
,后者又继承自System.Object
。虽然struct
不支持用户定义的继承,但是它们可以像类一样实现接口。
结构指南
类型通常用于表示轻量级的类,这些类封装了一小组相关的变量。使用struct
而不是类的主要原因是为了获得值类型语义。例如,简单类型实际上都是struct
类型。对于这些类型,赋值复制值比复制引用更自然。
由于性能原因,s 也很有用。一个struct
在内存方面比一个类更有效率。它不仅比一个类占用更少的内存,而且也不需要像引用类型对象那样为它分配内存。此外,一个类需要两个内存空间,一个用于变量,一个用于对象,而struct
只需要一个。这对于操作大量数据结构的程序来说有很大的不同。请记住,使用struct
s 进行赋值和参数传递通常比使用引用类型代价更高,因为这种操作需要复制整个struct
。
二十五、记录
C# 9 引入了记录类型,它用基于值的相等行为定义了引用类型。它们可以通过两种不同的方式创建。首先,以与类相同的方式,但是改为使用 record 关键字。
public record Person {}
记录可以包含类可以包含的任何内容,但是它们主要用于封装不可变的数据,即一旦对象被创建就不能改变的数据。因此,它们通常只包含 init 属性来存储它们的数据,因为这保持了记录的不变性。
public record Person
{
public string name { get; init; }
public int age { get; init; }
}
可以使用对象初始化器块创建该记录的实例,就像使用类或结构一样。
var p = new Person { name = "John", age = 22 };
创建记录的第二种方法是使用所谓的位置参数形式,如下所示。当使用这种更简洁的语法时,编译器将自动生成 init-only 属性以及这些属性的构造函数。
public record Person(string name, int age);
第二种方法保持记录不变,并隐式生成一个用于设置指定参数的构造函数。
var p = new Person("Sam", 20);
定义记录时,位置参数可以与常规声明形式结合使用。
public record Person(string name, int age)
{
public string? country { get; init; }
}
创建此记录的实例时,必须只指定位置参数。如果未指定,任何其他属性将被设置为默认值。
var p1 = new Person("Eric", 15);
var p2 = new Person("Elena", 27) { country = "Greece" };
记录行为
尽管 record 是一种引用类型,但编译器会自动实现方法来强制基于值的相等性。如果两个记录实例的所有字段和属性的值都相等,则这两个记录实例相等。这与类不同,类中两个对象变量只有引用同一个对象才相等,即所谓的引用相等。
var p1 = new Person("Jack", 30);
var p2 = new Person("Jack", 30);
bool b1 = p1.Equals(p2); // true
bool b2 = (p1 == p2); // true
bool b3 = (p1 != p2); // false
记录支持继承,允许新的记录类型向现有记录类型添加属性。这是使用记录而不是结构的主要原因之一。一个记录只能从另一个记录或系统继承。对象类。注意,构造基本记录所需的参数需要从派生记录的参数列表中传递。
public record Person(string name);
public record Student(string name, string subject) : Person(name);
static void Main()
{
var student = new Student("Daryn", "Math");
}
记录类型也有一个编译器生成的 ToString 方法。此方法返回所有公共字段和属性的名称和值。
var s = new Student("Ace", "Law");
s.ToString(); // "Student { name = Ace, subject = Law }"
不可变记录实例的属性不能被修改,但是可以通过使用带有表达式的将它们复制到新记录中。这个表达式使得改变被复制的不可变记录中的特定属性成为可能,即所谓的非破坏性突变。
var s = new Student("Jay", "Bio");
var c1 = s with {}; // copy record
var c2 = s with { name = "Sara" }; // copy and alter record
记录 struts
从 C# 10 开始,可以使用记录结构声明来声明值类型记录。引用类型记录可以选择以 class 关键字作为后缀,以阐明这两种类型之间的区别。
public readonly record struct Pet(string name); // value type record
public record Fruit(string name); // reference type record
public record class Person(string name); // reference type record
这里包含 readonly 关键字是为了使记录结构不可变,因为与记录类(引用类型记录)不同,它们在默认情况下不是不可变的。像记录类一样,记录结构可以用标准属性语法、位置参数或两者的组合来定义。
// Positional parameters and standard properties
public readonly record struct Pet(string name)
{
public int age { get; init; } = 0;
}
对于记录结构类型,生成一个无参数的构造函数,它将每个字段设置为其默认值,就像使用常规结构一样。与结构类一样,记录结构也生成一个主构造函数,其参数与记录声明的位置参数相匹配。
var p1 = new Pet("Lucy"); // primary constructor
var p2 = new Pet(); // parameterless constructor
var p3 = new Pet("Jack") { age = 15 }; // constructor and initializer
记录指南
记录对于简洁地定义包含很少或没有行为的类型的数据非常有用。不可变记录尤其有助于防止在数据对象被传递和被其他方法无意中更改时引入的潜在错误。
当您想要基于值的相等和比较,但是想要使用引用变量以便在传递记录对象时不复制值时,record 类是更可取的。相比之下,当您需要记录的特性(包括继承)时,record struct 非常有用,但它具有基于值的语义,类似于可以有效复制的小型结构。
二十六、预处理器
C# 包括一组预处理指令,主要用于条件编译。虽然 C# 编译器没有单独的预处理器,但与 C 和 C++编译器一样,这里显示的指令会像有预处理器一样进行处理。也就是说,它们似乎是在实际编译发生之前被处理的。
|管理的
|
描述
|
| --- | --- |
| #if``#elif``#else``#endif
| 如果否则如果其他如果…就会结束 |
| #define``#undef
| 符号定义符号未定义 |
| #error``#warning``#line
| 产生错误生成警告设置行号 |
| #region``#endregion
| 标记部分开始标记部分结束 |
预处理器语法
预处理器指令很容易与普通编程代码区分开来,因为它们以一个散列符号(#
)开始。除了单行注释之外,它们必须总是占据一个独立的行。可以选择在散列符号前后包含空白。
#line 1 // set line number
条件编译符号
条件编译符号是使用后跟符号名称的#define
指令创建的。当一个符号被定义时,它将导致一个使用该条件的条件表达式被评估为true
。从创建该符号的那一行开始,该符号将只在当前源文件中定义。
#define Symbol
#undef
(未定义)指令可以禁用先前定义的符号。
#undef Symbol
条件编译
#if
和#endif
指令指定了基于给定条件将被包含或排除的一段代码。最常见的情况是,这个条件是一个条件编译符号。
#if Symbol
// ...
#endif
就像 C# if
语句一样,#if
指令可以选择包含任意数量的#elif
( else if
)指令和一个最终的#else
指令。条件指令也可以嵌套在另一个条件节中。在更长的条件中,向#endif
指令添加注释是一个很好的做法,有助于跟踪它们对应于哪个#if
指令。
#if Professional
// ...
#elif Advanced || Enterprise
// ...
#else
#if Debug
// ...
#endif // Debug
#endif // Professional
诊断指令
有两种诊断指令:#error
和#warning
。#error
指令用于通过产生编译错误来中止编译。该指令可以选择接受一个提供错误描述的参数。
#if Professional && Enterprise
#error Build cannot be both Professional and Enterprise
#endif
与 error 类似,#warning
指令会生成一条编译警告消息。该指令不会停止编译。
#if !Professional && !Enterprise
#warning Build should be Professional or Enterprise
#endif
行指令
另一个影响编译器输出的指令是#line
。该指令用于更改行号,也可以更改编译过程中出现错误或警告时显示的源文件名。这在使用将源文件合并成中间文件,然后编译的程序时非常有用。
#line 500 "MyFile"
#error MyError // MyError on line 500
区域指令
最后两条指令是#region
和#endregion
。它们限定了可以使用 Visual Studio 的大纲功能展开或折叠的代码部分。
#region MyRegion
#endregion
正如条件指令一样,区域可以嵌套任意多级。
#region MyRegion
#region MySubRegion
#endregion
#endregion
二十七、委托
委托是一种用于引用方法的类型。这允许将方法赋给变量并作为参数传递。委托的声明指定委托类型的对象可以引用的方法签名。按照惯例,委托的名字是每个单词的首字母大写,然后在名字的末尾加上Delegate
。
delegate void PrintDelegate(string str);
匹配委托签名的方法可以分配给这种类型的委托对象。
class MyApp
{
static void Print(string s)
{
System.Console.WriteLine(s);
}
static void Main()
{
PrintDelegate d = Print;
}
}
这个委托对象的行为就像它是方法本身一样,不管它是引用静态方法还是实例方法。对对象的方法调用将由委托转发给方法,任何返回值都将通过委托传递回来。
PrintDelegate d = Print;
d("Hello"); // "Hello"
这里用来实例化委托的语法实际上是 C# 2.0 中引入的简化符号。实例化委托的向后兼容方式是使用常规引用类型初始化语法。
PrintDelegate d = new PrintDelegate(Print);
匿名方法
C# 2.0 还引入了匿名方法,可以将匿名方法分配给委托对象。匿名方法是通过使用关键字delegate
后跟方法参数列表和主体来指定的。这可以简化委托的实例化,因为不必为了实例化委托而定义单独的方法。
PrintDelegate f = delegate(string s)
{
System.Console.WriteLine(s);
};
λ表达式
C# 3.0 更进一步,引入了 lambda 表达式。它们实现了与匿名方法相同的目标,但是语法更简洁。lambda 表达式被写成一个参数列表,后跟 lambda 运算符(=>
)和一个表达式。
delegate int IntDelegate(int i);
class MyApp
{
static void Main()
{
// Anonymous method
IntDelegate a = delegate(int x) { return x * x; };
// Lambda expression
IntDelegate b = (int x) => x * x;
a(5); // 25
b(5); // 25
}
}
lambda 必须与委托的签名匹配。通常,编译器可以从上下文中确定参数的数据类型,因此不需要指定它们。如果 lambda 只有一个输入参数,括号也可以省略。
IntDelegate c = x => x * x;
如果不需要输入参数,则必须指定一组空括号。
delegate void EmptyDelegate();
// ...
EmptyDelegate d = () =>
System.Console.WriteLine("Hello");
只执行一条语句的 lambda 表达式称为表达式 lambda 。lambda 的表达式也可以用花括号括起来,以允许它包含多个语句。这种形式叫做语句λ。
IntDelegate e = (int x) => {
int y = x * x;
return y;
};
表达式主体成员
Lambda 表达式提供了一种定义类成员的快捷方式,当成员只包含一个表达式时。这被称为表达式体定义。考虑下面的类:
class Person
{
public string name { get; } = "John";
public void PrintName() {
System.Console.WriteLine(name);
}
}
这些成员体可以重写为表达式体,这样更容易阅读。
class Person
{
public string name => "John";
public void PrintName() =>
System.Console.WriteLine(name);
}
在 C# 6 中为方法和get
属性增加了对实现成员体作为 lambda 表达式的支持。C# 7.0 扩展了允许成员的列表,包括构造函数、析构函数、set
属性和索引器。为了说明这一点,下面是一个将表达式体用于同时具有set
和get
访问器的构造函数和属性的示例:
class Person
{
private string name;
public string Name
{
get => name;
set => name = value;
}
public Person(string n) => Name = n;
}
类型推理
从 C# 10 开始,使用类型推断让编译器自动推断 lambda 的委托类型成为可能。这使得存储对 lambda 的引用成为可能,而无需首先声明合适的委托类型或显式使用. NET 中可用的预定义委托类型之一。
var pow = (int x) => x * x;
请注意,在使用类型推断时必须指定参数类型,因为编译器不知道类型。如果返回类型不明确,也必须显式指定,如下例所示:
var select = object (bool b) => b ? 0 : "one";
捕捉外部变量
lambda 可以引用其周围范围内的变量。当以这种方式捕获变量时,该变量将保留在范围内,直到委托被销毁。
static void Main()
{
int x = 0;
var updateX = (int i) => x = i; // capture local var x
System.Console.WriteLine(x); // "0"
updateX(5);
System.Console.WriteLine(x); // "5"
}
从 C# 9 开始,static 修饰符可以应用于 lambda 表达式和匿名方法,以防止局部变量或实例成员被意外捕获。这样的 lambda 仍然可以引用其周围范围内的静态和常量成员。
class MyApp
{
static int y = 5;
static void Main()
{
var f1 = static (int x) => x + MyApp.y; // ok
int z = 0;
var f2 = static (int x) => x + z; // error
}
}
多播代理
委托对象可能引用多个方法。这种对象称为多播委托,它引用的方法包含在所谓的调用列表中。要将另一个方法添加到委托的调用列表中,可以使用加法运算符或加法赋值运算符。
class MyApp
{
delegate void StringDelegate();
static void Hi() { System.Console.Write("Hi"); }
static void Bye() { System.Console.Write("Bye"); }
static void Main()
{
StringDelegate del = Hi;
del = del + Hi;
del += Bye;
}
}
类似地,要从调用列表中删除一个方法,需要使用减法或减法赋值操作符。
del -= Hi;
当调用多播委托对象时,调用列表中的所有方法都将按照它们被添加到列表中的顺序用相同的参数调用。
del(); // "HiBye"
如果委托返回值,则只返回最后调用的方法的值。同样,如果委托有一个out
参数,它的最终值将是最后一个方法分配的值。
委托签名
如上所述,如果一个方法与委托的签名相匹配,那么它就可以被分配给委托对象。但是,方法不必与签名完全匹配。委托对象还可以引用一个方法,该方法具有比委托中定义的返回类型更派生的返回类型,或者具有作为相应委托的参数类型的祖先的参数类型。
class Base {}
class Derived : Base {}
delegate Base ChildDelegate(Derived d);
class MyApp
{
static Derived Test(Base o)
{
return new Derived();
}
static void Main()
{
ChildDelegate del = Test;
}
}
作为参数的委托
委托的一个重要属性是它们可以作为方法参数传递。为了展示这样做的好处,将定义两个简单的类。第一个是名为PersonDB
的数据存储类,它有一个包含几个名字的数组。它还有一个方法,该方法将委托对象作为其参数,并为数组中的每个名称调用该委托。
delegate void ProcessPersonDelegate(string name);
class PersonDB
{
string[] list = { "John", "Sam", "Dave" };
public void Process(ProcessPersonDelegate del)
{
foreach(string s in list) del(s);
}
}
第二个类是Client
,会用到存储类。它有一个创建PersonDB
实例的Main
方法,它用一个在Client
类中定义的方法调用该对象的Process
方法。
class Client
{
static void Main()
{
PersonDB p = new PersonDB();
p.Process(PrintName);
}
static void PrintName(string name)
{
System.Console.WriteLine(name);
}
}
这种方法的好处在于,它允许将数据存储的实现与数据处理的实现分开。storage 类只处理存储,不知道对数据进行了什么处理。这允许以更通用的方式编写存储类,而不是该类必须实现客户端可能希望对数据执行的所有潜在处理操作。有了这个解决方案,客户端可以简单地将自己的处理代码插入到现有的存储类中。
二十八、事件
事件使一个对象能够在感兴趣的事情发生时通知其他对象。引发事件的对象称为发布者,处理事件的对象称为订阅者。
出版者
为了演示事件的使用,将首先创建一个发布者。这将是一个继承自ArrayList
的类,但是这个版本将在列表中添加一个项目时引发一个事件。在创建事件之前,需要一个委托来保存订阅者。这可以是任何类型的委托,但是标准的设计模式是使用接受两个参数的void
委托。第一个参数指定事件的源对象,第二个参数是一个类型,它是或者继承自System.EventArgs
类。这个参数通常包含事件的细节,但是在这个例子中不需要传递任何事件数据,所以基类EventArgs
将被用作参数的类型。
public delegate void
EventHandlerDelegate(object sender,
System.EventArgs e);
class Publisher : System.Collections.ArrayList
{
// ...
}
事件关键字
定义了委托后,可以在Publisher
类中使用event
关键字后跟委托和事件名称来创建事件。event
关键字创建了一种特殊的委托,只能从声明它的类中调用。它的访问级别是公共的,因此允许其他类订阅该事件。事件关键字后面的委托称为事件委托。事件的名称通常是动词。在这种情况下,将在添加项目后引发事件,因此使用动词“Add”的过去式,即“added”。如果创建了一个前置事件,它在实际事件之前引发,那么将使用动词的动名词(–ing)形式,在本例中为“添加”。
public event EventHandlerDelegate Added;
或者,可以使用预定义的System.EventHandler
委托来代替这个定制事件委托。这个委托与前面定义的委托相同,它用在。NET 类库来创建没有事件数据的事件。
事件调用方
要调用事件,可以创建一个事件调用方。该方法的命名约定是在事件名称前加上单词On
,在本例中变成了OnAdded
。该方法具有受保护的访问级别,以防止它被不相关的类调用,并且它被标记为虚拟的,以允许派生类重写它。它将事件参数作为它的一个参数,在本例中是EventArgs
类型。只有当事件不为 null 时,方法才会引发事件,也就是说只有当事件有任何已注册的订户时。为了引发该事件,this
实例引用作为发送方被传递,而EventArgs
对象是被传递给该方法的对象。
protected virtual void OnAdded(System.EventArgs e)
{
if (Added != null) Added(this, e);
}
引发事件
现在这个类有了一个事件和一个调用它的方法,最后一步是覆盖ArrayList
的Add
方法,让它引发事件。在这个方法的重写版本中,首先调用基类的Add
方法,并存储结果。然后用OnAdded
方法引发该事件,向其传递System.EventArgs
类中的Empty
字段,该字段表示没有数据的事件。最后,将结果返回给调用者。
public override int Add(object value)
{
int i = base.Add(value);
OnAdded(System.EventArgs.Empty);
return i;
}
完整的Publisher
类现在如下所示:
class Publisher : System.Collections.ArrayList
{
public delegate void
EventHandlerDelegate(object sender,
System.EventArgs e);
public event EventHandlerDelegate Added;
protected virtual void OnAdded(System.EventArgs e)
{
if (Added != null) Added(this, e);
}
public override int Add(object value)
{
int i = base.Add(value);
OnAdded(System.EventArgs.Empty);
return i;
}
}
订户
要使用Publisher
类,将创建另一个订阅该事件的类。
class Subscriber
{
//...
}
此类包含一个事件处理程序,它是一个与事件委托具有相同签名的方法,用于处理事件。处理程序的名称通常与事件名称相同,后面跟有EventHandler
后缀。
class Subscriber
{
public void AddedEventHandler(object sender,
System.EventArgs e)
{
System.Console.WriteLine("AddEvent occurred");
}
}
订阅事件
Publisher
和Subscriber
类现在已经完成。为了演示它们的用法,添加了一个Main
方法,其中创建了Publisher
和Subscriber
类的对象。为了将Subscriber
对象中的处理程序注册到Publisher
对象中的事件,事件处理程序被添加到事件中,就好像它是一个委托。但是,与委托不同的是,事件不能从其包含类的外部直接调用。相反,该事件只能由Publisher
类引发,在这种情况下,当一个项目被添加到该对象时就会发生。
class MyApp
{
static void Main()
{
Subscriber s = new Subscriber();
Publisher p = new Publisher();
p.Added += s.AddedEventHandler;
p.Add(10); // "AddEvent occurred"
}
}
二十九、泛型
泛型指的是类型参数的使用,它提供了一种设计代码模板的方法,这些模板可以操作不同的数据类型。具体来说,可以创建泛型方法、类、接口、委托和事件。
通用方法
在下面的示例中,有一个交换两个整数参数的方法:
static void Swap(ref int a, ref int b)
{
int temp = a;
a = b;
b = temp;
}
要使它成为一个可以处理任何数据类型的泛型方法,首先需要在方法名后面添加一个类型参数,用尖括号括起来。类型参数的命名约定是,它们应该以大写字母T
开头,然后每个描述参数的单词都要大写。然而,在这种情况下,描述性的名称不会增加太多的价值,通常只是用大写字母T
来命名类型参数。
static void Swap<T>(ref int a, ref int b)
{
int temp = a;
a = b;
b = temp;
}
类型参数现在可以用作方法中的任何其他类型,因此完成泛型方法需要做的第二件事是用类型参数替换将要成为泛型的数据类型。
static void Swap<T>(ref T a, ref T b)
{
T temp = a;
a = b;
b = temp;
}
调用泛型方法
泛型方法现在完成了。要调用它,需要在方法参数之前的尖括号中指定所需的类型参数。
int a = 0, b = 1;
Swap<int>(ref a, ref b);
在这种情况下,也可以像调用常规方法一样调用泛型方法,而无需指定类型参数。这是因为编译器可以自动确定类型,因为泛型方法的参数使用类型参数。但是,如果不是这种情况,或者要使用除编译器选择的类型参数之外的另一个类型参数,则需要显式指定类型参数。
Swap(ref a, ref b);
每当在运行时第一次调用泛型时,都会实例化该泛型的一个专用版本,该版本用指定的类型实参替换了每次出现的类型形参。将调用的是这个生成的方法,而不是泛型方法本身。用相同的类型参数再次调用泛型方法将重用这个实例化的方法。
Swap<int>(ref a, ref b); // create & call Swap<int>
Swap<int>(ref a, ref b); // call Swap<int>
当使用新类型调用泛型方法时,将实例化另一个专用方法。
long c = 0, d = 1;
Swap<long>(ref c, ref d); // create & call Swap<long>
泛型类型参数
通过在尖括号之间添加更多的类型参数,可以将泛型定义为接受多个类型参数。根据泛型方法定义的类型参数的数量,泛型方法也可以被重载。
static void Dictionary<K, V>() {}
static void Dictionary<K>() {}
缺省值
当使用泛型时,可能出现的一个问题是如何将默认值赋给类型参数,因为该值取决于类型。解决方案是使用default
关键字,后跟括号中的类型参数。无论使用哪个类型参数,该表达式都将返回默认值。
static void Reset<T>(ref T a)
{
a = default(T);
}
C# 7.1 中增强了默认表达式。从这个版本开始,当编译器可以根据上下文推断类型时,可以省略提供给 default 的类型。
static void Reset<T>(ref T a)
{
a = default; // same as default(T)
}
通用类
泛型类允许类成员使用类型参数。它们的定义方式与泛型方法相同,即在类名后添加一个类型参数。
class Point<T>
{
public T x, y;
}
为了从泛型类实例化一个对象,使用了标准的符号,但是在两个类名后面都指定了类型参数。请注意,与泛型方法不同,泛型类必须总是用显式指定的类型参数进行实例化。
Point<short> p = new Point<short>();
泛型类继承
泛型类的继承方式略有不同。泛型类可以从非泛型类(也称为具体类)继承。其次,它可以从另一个指定了类型参数的泛型类继承,即所谓的封闭构造基类。最后,它可以从一个开放构造的基类继承,该基类是一个泛型类,其类型参数未指定。
class BaseConcrete {}
class BaseGeneric<T>{}
class Gen1<T> : BaseConcrete {} // concrete
class Gen2<T> : BaseGeneric<int>{} // closed constructed
class Gen3<T> : BaseGeneric<T> {} // open constructed
从开放构造基类继承的泛型类必须定义基类的所有类型参数,即使派生的泛型类不需要它们。这是因为当子类被实例化时,只能发送子类的类型参数。
class BaseMultiple<T, U, V> {}
class Gen4<T, U> : BaseMultiple<T, U, int> {}
这也意味着非泛型类只能从封闭的构造基类继承,而不能从开放的基类继承,因为非泛型类在实例化时不能指定任何类型参数。
class Con1 : BaseGeneric<int> {} // ok
class Con2 : BaseGeneric<T> {} // error
通用接口
用类型参数声明的接口成为泛型接口。通用接口和常规接口有两个相同的目的。创建它们或者是为了公开将被其他类使用的类的成员,或者是为了强制一个类实现特定的功能。实现泛型接口时,必须指定类型参数。泛型接口可以由泛型和非泛型类实现。
// Generic functionality interface
interface IGenericCollection<T>
{
void store(T t);
}
// Non-generic class implementing generic interface
class Box : IGenericCollection<int>
{
public int myBox;
public void store(int i) { myBox = i; }
}
// Generic class implementing generic interface
class GenericBox<T> : IGenericCollection<T>
{
public T myBox;
public void store(T t) { myBox = t; }
}
通用委托
可以用类型参数定义委托。例如,下面的泛型委托使用其类型参数来指定可引用方法的参数。从这个委托类型中,可以创建一个委托对象,该对象可以引用任何采用单个参数的void
方法,而不管其类型如何。
class MyApp
{
public delegate void PrintDelegate<T>(T arg);
public static void Print(string s)
{
System.Console.Write(s);
}
static void Main()
{
PrintDelegate<string> d = Print;
}
}
一般事件
泛型委托可用于定义泛型事件。例如,不使用典型的设计模式,即事件的发送者是Object
类型,类型参数可以允许指定发送者的实际类型。这将使参数成为强类型,从而允许编译器强制对该参数使用正确的类型。
delegate void EventDelegate<T, U>(T sender, U eventArgs);
event EventDelegate<MyApp, System.EventArgs> myEvent;
泛型和对象
一般来说,应该避免使用Object
类型作为通用容器。像ArrayList
这样的Object
容器之所以存在于。NET 类库是因为泛型直到 C# 2.0 才引入。与Object
类型相比,泛型不仅确保了编译时的类型安全,还消除了与将值类型装箱和拆箱到Object
容器中相关的性能开销。
// Object container class
class Box { public object o; }
// Generic container class (preferred)
class Box<T> { public T o; }
class MyApp
{
static void Main()
{
// .NET object container
System.Collections.ArrayList a;
// .NET generic container (preferred)
System.Collections.Generic.List<int> b;
}
}
限制
当定义泛型类或方法时,可以对类或方法实例化时可能使用的类型参数的种类应用编译时强制限制。这些限制被称为约束,并使用where
关键字指定。总而言之,有六种约束。
首先,可以使用struct
关键字将类型参数限制为值类型。
class C<T> where T : struct {} // value type
其次,可以通过使用class
关键字将参数约束为引用类型。
class D<T> where T : class {} // reference type
第三,约束可以是类名。这将把类型限制为该类或它的一个派生类。
class B {}
class E<T> where T : B {} // be/derive from base class
第四,该类型可以被约束为另一个类型参数或从另一个类型参数派生。
class F<T, U> where T : U {} // be/derive from U
第五个约束是指定一个接口。这将把类型参数限制为仅实现指定接口的类型,或者是接口类型本身的类型。
interface I {}
class G<T> where T : I {} // be/implement interface
最后,类型参数可以被约束为只有那些具有公共无参数构造函数的类型。
class H<T> where T : new() {} // parameterless constructor
多重约束
通过在逗号分隔的列表中指定多个约束,可以将这些约束应用于类型参数。此外,为了约束多个类型参数,可以添加额外的where
子句。注意,如果使用了类或struct
约束,它必须出现在列表的第一位。此外,如果使用无参数构造函数约束,它必须是列表中的最后一个。
class J<T, U>
where T : class, I
where U : I, new() {}
为什么使用约束
除了将泛型方法或类的使用仅限于某些参数类型之外,应用约束的另一个原因是增加约束类型支持的允许操作和方法调用的数量。不受约束的类型只能使用System.Object
方法。但是,通过应用基类约束,该基类的可访问成员也变得可用。
class Person
{
public string name;
}
class PersonNameBox<T> where T : Person
{
public string box;
public void StorePersonName(T a)
{
box = a.name;
}
}
下面的示例使用无参数构造函数约束。此约束允许实例化类型参数的新对象。
class Base<T> where T : new() {}
请注意,如果一个类对其类型参数有约束,并且该类的一个子类有一个受基类约束的类型参数,则该约束也必须应用于子类的类型参数。
class Derived<T> : Base<T>
where T : Base<T>, new() {}
三十、常量
通过在数据类型前添加const
关键字,C# 中的变量可以变成编译时常量。这个修饰符意味着变量不能被改变,因此必须在声明变量的同时给它赋值。任何向常量赋值的尝试都会导致编译时错误。
局部常量
局部常量必须在声明的同时初始化。
static void Main()
{
const int a = 10; // compile-time constant
}
const
修饰符创建了一个编译时常量,因此编译器会用它的值替换常量的所有用法。因此,赋值必须在编译时已知。因此,const
修饰符只能与简单类型一起使用,也可以与枚举和字符串类型一起使用。
常量字段
可以对字段应用const
修饰符,使字段不可更改。
class Box
{
const int b = 5; // compile-time constant field
}
常量字段不能有static
修饰符。它们是隐式静态的,访问方式与静态字段相同。
int a = Box.b;
只读
另一个类似于const
的变量修饰符是readonly
,它创建一个运行时常量。这个修改器可以应用于字段,并且,像const
一样,它使字段不可更改。
class Box
{
readonly int c = 3; // run-time constant field
}
因为readonly
字段是在运行时分配的,所以它可以被分配一个直到运行时才知道的动态值。
readonly int d = System.DateTime.Now.Hour;
与const
不同,readonly
可以应用于任何数据类型。
readonly int[] e = { 1, 2, 3 }; // readonly array
此外,readonly
字段不能仅在声明时初始化。也可以在构造函数中给它赋值。
class Box
{
readonly string s;
public Box() { s = "Hello World"; }
}
从 C# 7.2 开始,readonly
修饰符不仅可以应用于字段,还可以应用于struct
s。将一个struct
声明为readonly
将对struct
的成员强制不变性,要求所有字段和属性都成为readonly
。
readonly struct Container
{
public readonly int value;
public int Property { get; }
public Container(int v, int p)
{
value = v;
Property = p;
}
}
C# 7.2 中的另一个新增功能是,当通过引用使用ref
修饰符返回值类型时,可以将方法的返回值标记为readonly
。这将不允许调用者修改返回值,前提是返回值也被指定为一个readonly
引用,而不仅仅是一个副本。
class MyApp
{
readonly static int i;
static ref readonly int GetValue() { return ref i; }
static void Main()
{
ref readonly int a = ref GetValue();
a = 5; // error: readonly variable
}
}
在参数中
类似于ref
参数修饰符,C# 7.2 增加了in
修饰符,它提供了传递参数作为readonly
引用的能力。方法中任何试图修改in
参数(或者在struct
的情况下修改其成员)的代码都会在编译时失败,因此参数必须在方法调用之前初始化。
class MyApp
{
static void Test(in int num)
{
num = 5; // error: readonly parameter
}
static void Main()
{
int i = 10;
Test(i); // passed by readonly reference
Test(2); // allowed, temporary variable created
}
}
像ref
修饰符一样,in
修饰符防止对值类型进行不必要的复制。出于性能原因,这很有用,特别是当将一个大的struct
对象传递给一个被多次调用的方法时。
不变的准则
一般来说,如果不需要重新分配变量,最好总是将变量声明为const
或readonly
。这确保了变量不会在程序的任何地方被错误地改变,这反过来有助于防止错误。当一个变量不打算被修改时,它也清楚地传达给其他开发人员。
三十一、异步方法
一个异步方法是一个可以在完成执行之前返回的方法。任何执行潜在的长时间运行任务的方法,比如访问 web 资源或读取文件,都可以变成异步的,以提高程序的响应能力。这在图形应用中尤其重要,因为任何在用户界面线程上花费很长时间执行的方法都会导致程序在等待该方法完成时没有响应。
Async 和 Await 关键字
在 C# 5 中引入的关键字async
和await
允许用类似于同步(常规)方法的简单结构编写异步方法。async
修饰符指定该方法是异步的,因此它可以包含一个或多个 await 表达式。await 表达式由关键字await
和一个 await 方法调用组成。
class MyApp
{
async void AsyncWriter()
{
System.Console.Write("A");
await System.Threading.Tasks.Task.Delay(2000);
System.Console.Write("C");
}
}
该方法将同步运行,直到到达 await 表达式,此时该方法被挂起,执行返回到调用方。等待的任务被安排在同一线程的后台运行。在这种情况下,任务是一个定时延迟,将在 2000 毫秒后完成。一旦任务完成,异步方法的剩余部分将执行。
从 Main 调用 async 方法将输出“A ”,然后是“B ”,延迟后是“C”。请注意,这里使用 ReadKey 方法是为了防止控制台程序在异步方法完成之前退出。
static void Main()
{
new MyApp().AsyncWriter();
System.Console.Write("B");
System.Console.ReadKey();
}
异步返回类型
在 C# 5 中,一个异步方法可以有三种内置的返回类型:Task await
关键字挂起自己,直到任务完成。void 类型主要用于定义异步事件处理程序,因为事件处理程序需要 void 返回类型。
自定义异步方法
为了异步调用一个方法,必须将它包装在另一个返回已启动任务的方法中。举例来说,下面的方法定义、启动并返回一个任务,该任务在返回字母“Y”之前需要执行 2000 毫秒。为了简明起见,这里通过使用λ表达式来定义任务。
using System.Threading.Tasks;
using System.Threading;
class MyApp
{
Task<string> MyTask()
{
return Task.Run<string>( () => {
Thread.Sleep(2000);
return "Y";
});
}
// ...
}
可以从异步方法中异步调用此任务方法。这些方法的命名约定是在方法名后面加上“Async”。本例中的异步方法等待任务的结果,然后打印出来。
async void TaskAsync()
{
string result = await MyTask();
System.Console.Write(result);
}
异步方法的调用方式与常规方法相同,如下面的 Main 方法所示。程序的输出将是“XY”。
static void Main()
{
new MyApp().TaskAsync();
System.Console.Write("X");
System.Console.ReadKey();
}
扩展退货类型
C# 7.0 减少了对异步方法返回类型的限制。当异步方法返回常量结果或可能同步完成时,这可能很有用,在这种情况下,Task 对象的额外分配可能会成为不希望的性能成本。条件是返回的类型必须实现 GetAwaiter 方法,该方法返回一个 Awaiter 对象。来利用这个新功能。NET 提供了 ValueTask
举个例子,下面的 PowTwo 异步方法给出了参数的二次幂的结果(a 2 )。如果参数小于正负 10,它将同步执行,因此返回 ValueTask < double >类型,以便在这种情况下不必分配任务对象。注意,这里的 Main 方法有 async 修饰符。从 C# 7.1 开始,这是允许的,并且当 Main 方法直接调用 async 方法时,这种情况也可以使用。
using System.Threading.Tasks;
public class MyApp
{
static async Task Main()
{
double d = await PowTwo(10);
System.Console.WriteLine(d); // "100"
}
private static async ValueTask<double> PowTwo(double a)
{
if (a < 10 && a > -10) {
return System.Math.Pow(a, 2);
}
return await Task.Run(() => System.Math.Pow(a, 2));
}
}
若要在 Visual Studio 2022 之前的版本中使用 ValueTask 类型,需要向项目中添加一个 NuGet 包。NuGet 是一个软件包管理器,它为 Visual Studio 提供免费的开源扩展。通过在解决方案资源管理器中右键单击引用(依赖项)并选择“管理 NuGet 包”来添加包。切换到“浏览”选项卡,搜索“任务”以找到系统。线程.任务.扩展包。选择此软件包,然后单击安装。
异步流
C# 8 中增加了异步流,允许异步方法返回多个结果。这拓宽了它们的可用性,使异步方法能够在数据可用时返回数据。异步流(生产者方法)使用 yield return 语句。这会将结果返回给调用方,然后继续执行该方法,允许该方法在产生每个结果之间进行异步调用。
using System.Collections.Generic;
using System.Threading.Tasks;
class MyApp
{
static async IAsyncEnumerable<int> Streamer(int count)
{
int sum = 0;
for (int i = 0; i <= count; i++)
{
sum = sum + i;
yield return sum; // return a result
// Simulate waiting for more data
await Task.Delay(1000);
}
// end stream
}
}
为了异步流的目的,C# 8 增加了通用枚举器接口的异步版本。IAsyncEnumerable
static async Task Main()
{
await foreach (int data in Streamer(3))
{
System.Console.Write(data + " "); // "0 1 3 6"
}
}