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 以及它们的描述。这是一个非常强大的功能,你应该记得使用。
Footnotes 12
www.visualstudio.com/vs/community/
二、编译并运行
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 {}
}
访问级别指南
作为指南,在选择访问级别时,通常最好尽可能使用最严格的级别。这是因为成员可以被访问的位置越多,它可以被错误访问的位置就越多,这使得代码更难调试。使用限制性访问级别还会使修改一个类变得更容易,而不会破坏使用该类的任何其他程序员的代码。