肆叁小灶第六讲,介绍了 .NET 开发与 C# 基础,来源于三系联合暑培讲义。

0. 准备工作

0.1 先修知识

我们希望你已经具备以下基础:

  • 熟悉 C 语言
  • 了解至少一门面向对象编程语言(如 Java, C++, Python)

如果你具备以下基础,将更有利于你学习 C# 和 .NET 开发:

  • Day 1 Linux 基础
  • Day 2 Git & GitHub

此外,本讲是以下后续讲座的基础:

  • Day 4 多线程与异步
  • Day 7 WPF 与 Avalonia
  • Day 15 Unity 与 WebGL

0.2 环境搭建

笔者使用的是 Windows 环境,如果你使用的是 Linux 或 macOS,请参考相关文档进行环境搭建。

  • 请访问 .NET 官网 下载并安装最新版本的 .NET SDK。
  • IDE 选择:推荐使用 Visual Studio 或 Visual Studio Code。Visual Studio 提供了更强大的功能和调试支持,而 Visual Studio Code 则更轻量级。

如果你选择使用 Visual Studio,以下是环境搭建步骤:

  • 请访问 Visual Studio 官网 下载并安装 Visual Studio Community。
  • 在 Visual Studio Installer 中,选择 “.NET 桌面开发” 工作负载。

如果你选择使用 Visual Studio Code,以下是环境搭建步骤:

  • 请访问 Visual Studio Code 官网 下载并安装 Visual Studio Code。
  • 打开 Visual Studio Code,点击左侧扩展图标,搜索并安装 “C#”, “C# Dev Kit” 和 “.NET Install Tool” 扩展。

在安装完成后,打开终端,输入以下命令来验证安装是否成功:

1
dotnet --version

如果安装成功,你将看到 .NET SDK 的版本号。

0.3 C# & .NET 简介

.NET 是一个跨平台的开发框架,由微软开发。它提供了丰富的类库和工具,使得开发者可以更高效地构建应用程序。它支持多种编程语言,其中就包括 C#。C# 是一种现代的、面向对象的编程语言,广泛用于开发桌面应用、Web 应用、移动应用和游戏等。

在生成一个 .NET 程序时,程序员编写的代码暂时不翻译成本地的机器语言,而是先翻译成一种其他的
语言,叫做“微软中间语言(MSIL, Microsoft Intermediate Language)”。在执行该微软中间语言的可执行文件时,将启动对应 .NET 框架的“公共语言运行时(CLR, Common Language Runtime)”,由该 CLR 将 MSIL 编译为机器码执行,称作 JIT 编译(Just-In-Time Compilation)。

而与之相对应地,我们熟知的 C++ 是直接编译成机器码的,这种方式称为 AOT 编译(Ahead-Of-
Time Compilation):预处理器处理源代码中的预处理指令(如 #include、#define 等)生成
预处理后的代码,编译器将预处理后的代码翻译为机器语言生成目标文件(.obj 或 .o 文件)、
链接器将目标文件和库文件链接在一起,生成最终的可执行文件(.exe 或 .out 文件)、操作系统加载并执行生成的可执行文件。

可⻅,CLR 就仿佛一台独立于物理机器的“虚拟机”,MSIL 语言的程序可以在 CLR 上运行。由于这个机制,我们可以得到很多便利,例如可以轻松实现垃圾回收、跨平台等等。

0.4 创建一个 C# 程序

我们介绍两种创建 C# 程序的方式:使用命令行和使用 Visual Studio。

0.4.1 使用命令行

  • 打开终端,输入以下命令创建一个新的控制台应用程序:
1
dotnet new console -n HelloWorld
  • 进入新创建的目录:
1
cd HelloWorld
  • 打开 Program.cs 文件,你会看到以下代码:
1
Console.WriteLine("Hello, World!");
  • 运行程序:
1
dotnet run

你应该会看到输出 Hello, World!。

0.4.2 使用 Visual Studio

  • 打开 Visual Studio,选择 “创建新项目”。
  • 在语言栏中选择 “C#”,然后选择 “控制台应用”,点击 “下一步”。
  • 输入项目名称(如 HelloWorld),选择保存位置,点击 “下一步”
  • 勾选”不启用顶级语句”,然后点击 “创建”。
  • 在 Program.cs 文件中,你会看到以下代码:
1
2
3
4
5
6
7
8
9
10
11
using System;
namespace HelloWorld
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("Hello, World!");
}
}
}
  • 点击工具栏上的 “运行” 按钮(或按 F5 键)来运行程序。
  • 你应该会在打开的终端中看到输出 Hello, World!。

0.5 C# 程序入口

可以发现,0.4.1 和 0.4.2 中的代码有些不同。使用命令行创建的程序是一个顶级语句(Top-Level Statements)程序,而使用 Visual Studio 创建的程序是一个传统的 C# 程序。

顶级语句是 C# 9.0 引入的特性,对于初学者来说,这种方式更简洁易懂,但在更复杂的应用程序中,我们仍然需要使用传统的类和方法结构。

0.5.1 传统结构

1
2
3
4
5
6
7
8
9
10
11
using System;
namespace HelloWorld
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("Hello, World!");
}
}
}

上面是 0.4.2 一节中生成的使用传统结构的代码。第一行 using System 是在包含 System 命名空间,System 是很多 .NET 类库所在的命名空间。包含该命名空间,可以使用该命名空间的内容。其中,Console 是 System 命名空间的一个类,因此该语句应为 System.Console.WriteLine("Hello World!")。但由于我们使用了 using System,因此可省去 System。

HelloWorld 是我们自定义的一个命名空间,Program 是一个类,而 Main 是该类的一个静态方法(关于类和静态方法的概念我们将会在后面提到),称为“Main 方法”。一般来说,一个应用程序以 Main 方法作为入口点,且一个程序通习惯上只有一个类定义一个 Main 方法作为程序入口点(如果有多个类具有 Main 方法需要在项目配置中手动指定入口点)。

一般来说,习惯上 Main 方法都被定义在 Program 类中。

0.5.2 顶级语句

在 C# 9.0(.NET 5.0)中,应用程序也可以没有 Main 方法,但是需要有且仅有一个文件具有“顶级语句(Top-level statements)”。所谓顶级语句就是在所有的命名空间定义之前的语句。若程序无 Main 方法,则以顶级语句作为入口点。如下面 0.4.1 一节中生成的代码:

1
Console.WriteLine("Hello, World!");

C# 编译器会自动将这个顶级语句转换为一个隐式的 Main 方法,并输出 Hello, World!。值得一提的是,上面的代码并没有引入 using System 语句,但仍然可以使用 Console 类,这是因为 C# 编译器会自动引入一些常用的命名空间。

0.6 C# 项目结构

一个包含多文件的 C# 项目的目录结构一般如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
ProjectName/
├── ProjectName.csproj # 项目文件,包含项目的配置和依赖信息
├── Program.cs # 主程序文件,包含程序入口点
├── Program.sln # 解决方案文件
├── ModuleName1/ # 模块 1
│ ├── ScriptName1.cs
│ └── ScriptName2.cs
├── ModuleName2/ # 模块 2
│ ├── ScriptName3.cs
│ └── ScriptName4.cs
├── Resources/ # 静态资源文件夹
│ ├── Images/
│ │ ├── image1.png
│ │ └── image2.jpg
│ └── Data/
│ ├── data1.json
│ └── data2.xml
├── bin/ # 编译输出目录(自动生成)
├── obj/ # 编译中间文件目录(自动生成)
├── README.md # 项目说明文档
└── .gitignore # Git 忽略文件配置

其中,Program.cs 是主程序文件,包含程序的入口点;ModuleName1 和 ModuleName2 是两个模块,分别包含各自的脚本文件;Resources 文件夹用于存放静态资源,如图片和数据文件;bin 和 obj 文件夹是编译过程中自动生成的输出目录和中间文件目录;README.md 是项目说明文档;.gitignore 是 Git 忽略文件配置。

如需在一个代码文件中引用其他代码文件中的类或方法,可以使用 using 语句引入命名空间。例如,在 Program.cs 中引用 ModuleName1/ScriptName1.cs 中定义的命名空间 Namespace1 下的类 Class1,可以使用以下方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
using Namespace1;
namespace ProjectName
{
class Program
{
static void Main(string[] args)
{
// 创建 Namespace1.Class1 的实例
Class1 obj = new Class1();
obj.SomeMethod(); // 调用 Class1 中的方法
}
}
}

可见,在 C# 中,命名空间的识别是基于命名空间名称而不是文件路径。只要两个文件在同一个项目中,编译器会自动扫描所有 .cs 文件来查找命名空间。

1. 变量与数据类型

C# 是一种面向对象的强类型语言,变量必须先声明后使用。C# 类型系统的特点是,一切类型均继承自 System.Object 类,即 object 类型是一切类型的基类。C# 的类型分为两种:值类型和引用类型。其中,值类型与 C 语言类似,直接在内存上储存数值;而引用类型则与 Python 类似,变量存储的是对象的引用。

1.1 值类型

C# 中的值类型包括:

  • 基本数据类型:int, float, double, char, bool 等。
  • 枚举类型:使用 enum 关键字定义的枚举类型。
  • 结构体类型:使用 struct 关键字定义的结构体类型。

1.1.1 基本数据类型

基本数据类型中,char 类型与 C 语言中的 char 略有不同:在 C# 中,char 类型是一个 16 位的 Unicode 字符,而在 C 语言中,char 类型是一个 8 位的 ASCII 字符;其余基本数据类型与 C 语言类似。例如:

1
2
3
4
5
int age = new int();
// 注意,C# 中的所有类型都是类,int() 就是 int 类的构造方法(函数)
age = 20; // 赋值,这两行也可直接写成 int age = 20;
float height; // float height = new float() 的简略写法
// C# 会自动回收变量,无需手动 delete

注意,C# 默认可以将低精度类型隐式转换到高精度类型,而将高精度类型转换到低精度类型需要强制类型转换。例如:

1
2
3
int a = 10;
float b = a; // 隐式转换,int 到 float
int c = (int)b; // 强制转换,float 到 int

1.1.2 枚举类型

枚举类型则用于定义一个新的数据类型,该类型的所有可能值就是其枚举成员。为了后续便于比较和赋值,enum 为每个可能的值分配了一个整数值。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
enum Student  // 定义 Student 类型
{
Alice = 101,
Bob, // 不声明时会自动赋值为前一个枚举值加 1,即 102
Charlie = 201
}

Student student = Student.Alice;
// Student.Alice 是 Student 类型的三个可能取值之一
if (student == Student.Bob)
// 注意,等式的两边都是 Student 类型而非 int 类型
{
Console.WriteLine("Student is Bob");
}

1.1.3 结构体类型

C# 中的结构体类型也与 C 语言中的结构体相似,可以包含多个字段。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Point
{
public int X;
public int Y;
public Point(int x = 0, int y = 0) // Point 类的构造方法
{
X = x;
Y = y;
}
}

Point p1; // 即 Point p1 = new Point(0, 0);
p1.X = 10; // 访问结构体的字段
Point p2 = new Point(5, 5); // 使用构造方法创建结构体实例

1.1.4 元组类型

此外,C# 还支持元组类型,提供了简洁的语法来将多个数据元素分组成一个轻型数据结构,与 Python 中的元组和 C++ 中的 std::tuple 类似,可看作一个轻量级的结构体。元组最常用的场景是作为方法的返回值,将多个返回值打包成一个元组返回。例如:

1
2
3
4
5
6
7
8
(double, int) person1 = (1.75, 70);
double height1 = person1.Item1; // 访问元组的第一个元素

(double Height, int Weight) person2 = (1.80, 75);
double height2 = person2.Height; // 访问元组的命名元素

var person3 = (Height: 1.65, Weight: 60); // 使用 var 定义元组
double height3 = person3.Height;

上面使用的 var 关键字是 C# 的类型推断功能,编译器会根据右侧的值自动推断出变量的类型,如 var person3 的类型为 (double, int) 元组。

1.2 引用类型

除了 1.1 一节中介绍的值类型,C# 中的其他类型均为引用类型。常用的引用类型包括字符串 string,类 class,接口 interface,数组 [],委托 delegate 等。类、接口与委托的概念我们将在后续章节中介绍,这里我们先介绍字符串和数组。

1.2.1 字符串

C# 中的字符串 string 与 Python 中的字符串类似,是一个不可变的字符序列。例如:

1
2
3
4
5
6
7
string name1 = new string("Alice");
// name1 是一个引用,即一个 string 对象的别名,该对象的内容为 "Alice"
string name2 = "Bob"; // 简化写法
string name3; // 仅定义了一个 string 引用,并未指向任何 string 对象
name3 = "Charlie";
// 现在 name3 指向了一个新的 string 对象,其内容为 "Charlie"
string name4 = name3; // 复制的是引用,而非 "Charlie" 本身

1.2.2 数组

C# 中的数组是一个固定大小的元素集合,元素类型可以是值类型或引用类型,例如由 int 类型组成的数组 int[],由 string 类型组成的数组 string[] 等。例如:

1
2
3
4
5
6
7
int[] arr1 = new int[5];  // 定义一个长度为 5 的 int 数组
arr1[0] = 1; // 访问数组元素
int[] arr2 = new int[3] { 2, 3, 4 }; // 定义并初始化一个 int 数组
int[] arr3 = { 2, 3, 4 }; // 简化写法
var arr4 = new[] { "Alice", "Bob", "Charlie" };
// 数组类型和数组长度都可以由编译器自动推断
int length4 = arr4.Length; // 获取数组长度

C# 中也可以定义多维数组,但与 C 语言不同,C# 的多维数组并非通过嵌套数组实现,而是直接定义为一个多维数组类型。例如:

1
2
3
4
5
6
int[,] matrix1 = new int[3, 3];  // 定义一个 3x3 的二维数组
matrix1[0, 0] = 1; // 访问二维数组元素
var matrix2 = new[,] { { 1, 2, 3 }, { 4, 5, 6 }, { 7, 8, 9 } };
int size2 = matrix2.Length; // 获取二维数组的元素总数
int row2 = matrix2.GetLength(0); // 获取二维数组的行数
int col2 = matrix2.GetLength(1); // 获取二维数组的列数

我们可以使用 foreach 语句遍历数组中的元素,但需要注意的是,通过 foreach 无法修改数组中的元素,只能访问其值。例如:

1
2
3
4
foreach (var item in arr4)
{
Console.WriteLine(item);
}

1.3 .NET 数据结构

C# 还提供了许多常用的数据结构,如列表 List<T>、字典 Dictionary<TKey, TValue>、集合 HashSet<T>、队列 Queue<T>、栈 Stack<T> 等,这些数据结构都位于 System.Collections.Generic 命名空间中,本质为泛型类(与 C++ 中的模板类相似),可以存储任意类型的数据,用法与 C++ STL 中的容器以及 Python 的标准数据类型类似。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
using System.Collections.Generic;

List<int> numbers = new List<int> { 1, 2, 3, 4, 5 }; // 定义一个整数列表
numbers.Add(6); // 添加元素
foreach (var number in numbers)
{
Console.WriteLine(number); // 遍历列表元素
}

Dictionary<string, int> ages = new Dictionary<string, int>
{
{ "Alice", 30 },
{ "Bob", 25 },
{ "Charlie", 35 }
}; // 定义一个字符串到整数的字典
ages["Alice"] = 31; // 修改字典中的值
foreach (var kvp in ages)
{
Console.WriteLine($"{kvp.Key}: {kvp.Value}"); // 遍历字典键值对
}

HashSet<string> names = new HashSet<string> { "Alice", "Bob", "Charlie" }; // 定义一个字符串集合
names.Add("Alice"); // 添加重复元素不会报错,但不会添加重复的元素
foreach (var name in names)
{
Console.WriteLine(name); // 遍历集合元素
}

Queue<string> queue = new Queue<string>(); // 定义一个字符串队列
queue.Enqueue("Alice"); // 入队
queue.Enqueue("Bob"); // 入队
Console.WriteLine(queue.Dequeue()); // 出队,输出 "Alice"

Stack<string> stack = new Stack<string>(); // 定义一个字符串栈
stack.Push("Alice"); // 压栈
stack.Push("Bob"); // 压栈
Console.WriteLine(stack.Pop()); // 弹栈,输出 "Bob"

此外,C# 还提供 ArrayList 和 Hashtable 等非泛型集合类型,其元素类型为 object(所有类型的基类,即可以存储任意类型的对象),但不推荐使用,因为它们不提供类型安全检查,容易导致运行时错误,此处不再赘述。

2. 输入与输出

在介绍 C# 的输入输出前,我们先介绍一下 C# 的格式化字符串。

2.1 格式化字符串

C# 提供了多种格式化字符串的方式,包括转义字符、逐字文本、字符串内插、原始字符串等。

2.1.1 转义字符

使用 \ 转义特殊字符,如 \n 表示换行,\\ 表示反斜杠,与 C 语言类似。例如:

1
string greeting = "Hello, World!\nThis is a new line.";

2.1.2 逐字文本

使用 @ 前缀定义逐字文本字符串,逐字文本字符串中的转义字符不会被处理。例如:

1
string path = @"C:\Users\Alice\Documents";

2.1.3 字符串内插

使用 $ 前缀定义字符串内插,可以在字符串中嵌入变量,与 Python 类似。例如:

1
2
3
string name = "Alice";
int age = 30;
string greeting = $"Hello, my name is {name} and I am {age} years old.";

2.1.4 原始字符串

以 """ 开始并以 """ 结束,允许多行字符串,若为多行字符串则以单独的一行 """ 结尾,且字符串的缩进以结尾的 """ 的起始位置为基准。原始字符串文本不进行任何转义操作,但允许字符串内插(开头的 $ 数量代表内插所需要的花括号数)。例如:

1
2
3
4
5
6
string name = "Alice";
int age = 30;
string rawStringWithInterpolation = $$"""
This is a raw string with interpolation.
My name is {{name}} and I am {{age}} years old.
""";

2.2 控制台读写

2.2.1 控制台输入

C# 提供了两种控制台输入:System.Console.Read 和 System.Console.ReadLine。其中 System.Console.Read 读取一个字符返回(返回值为 int);而 System.Console.ReadLine 可以读入一行字符串。若要读入一个整数或浮点数,需要手动进行转换。例如:

1
2
3
string line = System.Console.ReadLine();
int num1 = int.Parse(line); // 将字符串转换为整数
int num2 = Convert.ToInt32(System.Console.ReadLine());

2.2.2 控制台输出

控制台输出则常用 System.Console.Write 和 System.Console.WriteLine 实现。其中 System.Console.Write 输出内容后不换行,而 System.Console.WriteLine 输出内容后会自动换行。此外,可以进行格式输出,只需要在字符串中用 {} 括住参数的序号即可,但更常用的是字符串内插。例如:

1
2
3
4
5
System.Console.Write("Hello, World!");  // 输出不换行
System.Console.WriteLine("Hello, World!"); // 输出并换行
int num = 42;
System.Console.WriteLine("The answer is {0}.", num); // 格式化
System.Console.WriteLine($"The answer is {num}."); // 字符串内插

2.3 .NET 流与文件读写

C# 也支持文件的输入输出操作,可以使用 System.IO 命名空间中的类来实现。常用的文件操作类包括 File、StreamReader 和 StreamWriter。

2.3.1 File 类

File 类常用于一次性读写文件操作,相较于流操作更为简单。例如:

1
2
3
4
using System.IO;
// 使用 File 类进行文件操作
File.WriteAllText("output.txt", "Hello, World!"); // 写入文件
string content = File.ReadAllText("output.txt"); // 读取文件

2.3.2 StreamReader 和 StreamWriter 类

StreamReader 和 StreamWriter 类则用于逐行读写文件,适合处理大文件或需要逐行处理的场景。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
using (StreamWriter writer = new StreamWriter("output.txt"))  // 逐行写入
{
writer.WriteLine("Hello, World!");
writer.WriteLine("This is a new line.");
}
using (StreamReader reader = new StreamReader("output.txt")) // 逐行读取
{
string line;
while ((line = reader.ReadLine()) != null)
{
Console.WriteLine(line); // 逐行输出
}
}

上面的 using 语句用于确保在使用完流后自动释放资源,避免资源泄漏。

3. 运算与控制

3.1 运算符

C# 除了支持常见的算术运算符、关系运算符和逻辑运算符外,还带有一些特殊的运算符。

3.1.1 空合并运算符

用 ?? 表示,如果左侧为 null,则返回右侧的值。例如:

1
2
int? a = null;  // int? 表示可空的整数类型,即可以为 null
int b = a ?? 0; // 如果 a 为 null,则 b 为 0

3.1.2 空条件运算符

用 ?. 表示,如果左侧为 null,则返回 null,否则返回左侧的值。例如:

1
2
3
4
string? str = null;
// 虽然 string 本身就可以为 null,但使用 string? 可让编译器不再警告
string result = str?.ToUpper() ?? "Default";
// 如果 str 为 null,则 result 为 "Default"

3.1.3 空合并赋值运算符

用 ??= 表示,如果左侧为 null,则将右侧的值赋给左侧。例如:

1
2
int? a = null;
a ??= 10; // 如果 a 为 null,则将 10 赋值给 a

3.1.4 空包容运算符

用 ! 表示,用于指示编译器左侧的值不会为 null,通常用于取消编译器的 null 检查。例如:

1
2
3
string? nullString = null;
string result = nullString!.ToUpper();
// 运行时会抛出 NullReferenceException

注意,使用 ! 运算符时要格外小心,它只是告诉编译器不要警告,但如果值确实是 null,运行时仍会出错。

3.2 模式匹配

模式匹配是一种测试表达式是否具有特定特征的方法,在 C# 中可以通过 switch 语句或 is 关键字实现,与 Python 的 match…case 类似。模式匹配可以用于以下场景:

3.2.1 Null 检查

使用 is null 或 is not null 进行空值判断。例如:

1
2
3
4
5
6
7
8
if (obj is null)
{
Console.WriteLine("Object is null");
}
if (obj is not null)
{
Console.WriteLine("Object is not null");
}

3.2.2 类型匹配

在 switch 语句中匹配类型。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
object obj = "Hello, World!";
switch (obj)
{
case string s:
Console.WriteLine($"String: {s}");
break; // C# 中每个 case 必须以 break 结束
case int i when i > 0: // 使用 when 子句添加额外条件
Console.WriteLine($"Positive Integer: {i}");
break;
default:
Console.WriteLine("Unknown type");
break;
}

3.2.3 值匹配与比较

在 switch 语句中匹配或比较特定的值。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
int number = 42;
switch (number)
{
case 42:
Console.WriteLine("The answer to life, the universe, and everything");
break;
case > 0 and < 100: // and 用于同模式叠加,when 用于额外条件
Console.WriteLine("Positive number less than 100");
break;
case < 0:
Console.WriteLine("Negative number");
break;
}

3.2.4 属性匹配

在 switch 语句中匹配对象的属性。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Person
{
public string Name;
public int Age;
public Person(string name, int age)
{
Name = name;
Age = age;
}
}
Person person = new Person("Alice", 30);
switch (person)
{
case { Name: "Alice", Age: 30 }:
Console.WriteLine("Person is Alice, 30 years old");
break;
case { Name: "Bob" }:
Console.WriteLine("Person is Bob");
break;
case { Age: < 18 }:
Console.WriteLine("Person is a minor");
break;
default:
Console.WriteLine("Unknown person");
break;
}

3.2.5 元组匹配

在 switch 语句中匹配元组。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
(int x, int y) point = (3, 4);
switch (point)
{
case (0, 0):
Console.WriteLine("Origin");
break;
case (var x, 0): // 捕获元组中的变量
Console.WriteLine($"On X-axis at {x}");
break;
case (_, _): // 弃元模式 _ 用于匹配任何元素
Console.WriteLine($"Other point");
break;
}

3.3 异常处理

在 C# 中,System.Exception 类是一切异常类的基类,Message 是该类及其派生类共有的属性,用于储存异常信息。C# 中的异常处理使用 try-catch-finally 语句块来捕获和处理异常,与 C++ 类似。

3.3.1 内置异常类

C# 提供了许多内置异常类,如 ArgumentNullException(参数为空)、IndexOutOfRangeException(数组越界)等,程序运行过程中若遇到这些异常,系统会自动抛出相应的异常对象,其 Message 属性会自动包含异常信息。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
try
{
// 可能触发内置异常的代码
int[] arr = new int[5];
arr[10] = 1; // 将自动抛出带有 Message 属性的 IndexOutOfRangeException 异常
}
catch (IndexOutOfRangeException ex) // 捕获特定类型的异常
{
// 处理异常
Console.WriteLine($"Caught an IndexOutOfRangeException: {ex.Message}");
}
finally
{
// 无论是否发生异常都会执行的代码,常用于进行恢复或清理工作
}

3.3.2 调用异常类

上述内置的异常类在其他地方也可以调用并抛出,但需要自定义其 Message 属性来提供更具体的错误信息。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
try
{
// 调用内置异常类
if (arr.Length == 0) // 本来这个操作并不会抛出空参数异常,但此处我们故意调用并抛出
{
// 自主调用的内置异常需要自定义 Message 属性并手动抛出
throw new ArgumentNullException("Array cannot be empty");
}
}
catch (Exception ex) // 捕获所有异常,若无需处理异常也可简略写成 catch
{
Console.WriteLine($"Caught an exception: {ex.Message}");
throw; // 重新抛出异常
}

3.3.3 自定义异常类

此外,在 C# 中也可以自定义异常类。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class CustomException : Exception  // 继承自 Exception 类
{
public CustomException(string message) : base(message)
{
// 可以在此处添加自定义的异常处理逻辑
}
}

try
{
// 调用自定义异常类
if (arr.Length < 5)
{
// 手动抛出自定义异常
throw new CustomException("Array length must be at least 5");
}
}
catch (CustomException ex)
{
Console.WriteLine($"Caught a CustomException: {ex.Message}");
}

4. 类

4.1 类的定义

类属于引用类型,由 class 关键词定义。一个类可以包含字段、方法等,甚至可以包含类(称为嵌套类)。类还可以分成很多块来定义,甚至放在多个文件里,只需要将每个部分的定义都加上 partial 关键字即可。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Person
{
public readonly string name; // 只读公有字段,只能在构造方法中赋值
public int age = 18; // 带有默认值的公有字段
private string IDNumber; // 私有字段
// 未指定默认值时,值类型默认为 0,引用类型默认为 null

public Person(string name, int age) // 构造方法
{
this.name = name; // this 表示指向本对象自身的引用,用于区分同名的参数和字段
this.age = age;
}
}

// 调用
Person person1 = new Person("Alice", 30); // 创建 Person 类的实例
person1.name = "Bob"; // 修改公有字段

上面所使用的构造方法与 C++ 中的构造函数类似,不具有返回值,且方法名与类名相同,是在一个对象被构造的时候调用的方法,由 new 表达式传递参数。一个类可以有多个构造方法(称为重载),但每个构造方法的参数列表必须不同。

4.2 Static 关键字

4.2.1 静态字段与方法

类中的字段和方法可以是静态的,使用 static 关键词定义。静态字段和方法属于类本身,而不是类的实例。静态字段在所有实例中共享,静态方法可以直接通过类名调用,与 Python 中的类变量和类方法相似。例如:

1
2
3
4
5
6
7
8
9
10
11
12
public class MathUtils
{
public static const double Pi = 3.14159; // 静态常量字段,定义后不可修改
public static double Add(double a, double b) // 静态方法
{
return a + b;
}
}

// 使用静态字段和方法
Console.WriteLine(MathUtils.Pi); // 输出:3.14159
Console.WriteLine(MathUtils.Add(2, 3)); // 输出:5

4.2.2 运算符重载

运算符重载是一种特殊的静态方法,在 C# 中其语法与 C++ 类似,使用 operator 关键字定义。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Point
{
public int X;
public int Y;
public Point(int x, int y)
{
X = x;
Y = y;
}
public static Point operator +(Point p1, Point p2) // 重载 + 运算符
{
return new Point(p1.X + p2.X, p1.Y + p2.Y);
}
}

// 调用
Point p1 = new Point(1, 2);
Point p2 = new Point(3, 4);
Point p3 = p1 + p2; // 使用重载的 + 运算符

4.3 类的方法

4.3.1 参数传递

与 C++ 类似,C# 中方法的参数默认采用值传递,即将实参复制一份给形参。对于值类型来说,复制的是值类型的所有数据,对于引用类型来说,复制的是一个引用。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Person
{
public int age;
}
class Utility
{
static public void Swap(Person x, Person y)
{
Person tmp = x;
x = y;
y = tmp;
}
static public void SwapAge(Person x, Person y)
{
int tmp = x.age;
x.age = y.age;
y.age = tmp;
}
}

// 调用
Person p = new Person(), q = new Person();
p.age = 555; q.age = 666;
Utility.Swap(p, q);
Console.WriteLine($"{p.age} {q.age}"); // 输出:555 666
Utility.SwapAge(p, q);
Console.WriteLine($"{p.age} {q.age}"); // 输出:666 555

若要实现引用传递,可以使用 ref 关键字。加上关键字 ref 后,值类型的形参和实参指代的是同一个值类型对象,而引用类型的实参和形参指代的是同一个引用,例如上述代码改成:

1
2
3
4
5
6
7
8
9
10
11
12
static public void Swap(ref Person x, ref Person y)
{
Person tmp = x;
x = y;
y = tmp;
}

// 调用
Person p = new Person(), q = new Person();
p.age = 555; q.age = 666;
Utility.Swap(ref p, ref q); // 调用时也必须加 ref 关键字!
Console.WriteLine($"{p.age} {q.age}"); // 输出:666 555

4.3.2 参数缺省

C# 中可以为方法的参数设置缺省值,这样在调用方法时可以省略某些参数。调用时,默认会把末尾未赋实参的参数赋以缺省值,但也可以自行指定。例如:

1
2
3
4
5
6
7
8
9
10
11
12
class MathTool
{
static public int Div(int x = 1, int y = 1)
{
return x / y;
}
}

// 调用
MathTool.Div(5); // x = 5, y = 1
MathTool.Div(y: 9); // x = 1, y = 9
MathTool.Div(y: 5, x: 4); // x = 4, y = 5

4.3.3 Lambda 表达式

如果方法体只有一行代码,可以使用 Lambda 表达式来简化书写。Lambda 表达式可以看成一个匿名方法,其语法为 (参数列表) => 表达式,与 Python 中的推导式类似。例如:

1
2
3
(x, y) => x + y;
o => { o = o + 1; return o; }; // 参数只有一个时可省略括号,且可以使用语句块
() => Console.WriteLine("Hello, World!"); // 无参数时括号不可省略

4.4 继承与多态

4.4.1 类的继承

C# 继承的语法与 C++ 类似,但 C# 不支持多继承。在类里可以通过 base 关键字代表它的基类,同样构造方法也需要通过 base 关键字来为它的基类提供构造方法的参数。例如:

1
2
3
4
5
6
7
8
9
10
11
12
class Animal
{
private string name;
public Animal(string name)
{
this.name = name;
}
}
class Dog : Animal
{
public Dog(string name) : base(name) {}
}

4.4.2 方法重写

在 C# 中,只有虚方法(使用 virtual 关键字定义,必须定义方法体)和抽象方法(使用 abstract 关键字定义,不能定义方法体)可以被重写,且重写方法时必须使用 override 关键字。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Animal
{
public virtual void Speak() // 虚方法
{
Console.WriteLine("Animal speaks");
}
}
class Dog : Animal
{
public override void Speak() // 重写虚方法
{
Console.WriteLine("Dog barks");
}
}

4.4.3 抽象类

抽象类使用 abstract 关键字定义,其不能被实例化,但可以被继承(不允许继承的类使用 sealed 关键字定义)。但与 C++ 不同,C# 中的抽象类可以包含非抽象方法(即有方法体的方法)。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
abstract class Shape  // 抽象类
{
public abstract double Area(); // 抽象方法,没有方法体
public void Display() // 非抽象方法,有方法体
{
Console.WriteLine("Displaying shape");
}
}
class Circle : Shape // 继承抽象类
{
private double radius;
public Circle(double radius)
{
this.radius = radius;
}
public override double Area() // 实现抽象方法
{
return Math.PI * radius * radius;
}
}

4.4.4 接口

接口(使用 interface 关键字定义,属于引用类型)比抽象类更为严格,其只能包含没有方法体的方法(即抽象方法,但不需要使用 abstract 关键字),且不能包含字段。但一个类可以实现(即继承)多个接口,实现接口中的方法时也无需使用 override 关键字。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
interface IDrawable  // 接口
{
void Draw(); // 抽象方法,没有方法体
}
interface IShape // 另一个接口
{
double Area(); // 抽象方法,没有方法体
}
class Rectangle : IDrawable, IShape // 实现多个接口
{
private double width;
private double height;
public Rectangle(double width, double height)
{
this.width = width;
this.height = height;
}
public void Draw() // 实现 IDrawable 接口的方法
{
Console.WriteLine("Drawing rectangle");
}
public double Area() // 实现 IShape 接口的方法
{
return width * height;
}
}

5. C# 特性

5.1 泛型

C# 中的泛型与 C++ 的模板类似,允许在类、方法等定义中使用类型参数,只需要在类或方法名后使用尖括号 <> 括住泛型的名称即可。此外,若希望泛型类型参数满足某些条件,可以使用 where 关键字指定类型约束。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Point<T>
where T : struct
{
public T X { get; private set; }
public T Y { get; private set; }
public Point(T x, T y)
{
this.X = x;
this.Y = y;
}
}

// 调用
Point<int> pt1 = new Point(0, 0);

其中,where T : struct 表示 T 必须是一个不可为 null 的值类型,否则会报错。此外,struct 还可换成:

  • class:不可为 null 的引用类型
  • class?:引用类型
  • new():具有无参构造方法
  • 一个类名或接口名:T 必须从该类继承或实现了该接口
  • ……(具体约束根据需求而定)

5.2 委托

委托是一种引用类型,作用与 C++ 中的函数指针类似。委托类型的定义格式与方法类似,只是在返回值类型前加上 delegate 关键字。例如:

1
delegate int BinaryFunctor(int x, int y);

该段代码定义了一个委托类型,名字叫 BinaryFunctor。该委托可以接收参数为 (int, int),返回类型为 int 的方法。与其他引用类型一样,我们需要用 new 关键字创建一个委托,并将一个方法赋给这个委托。例如:

1
2
3
4
5
int Add(int a, int b) => a + b;  // Add 方法的定义
BinaryFunctor bf = new BinaryFunctor(Add);

// 调用
Console.WriteLine(bf(3, 5)); // 输出 8

但多数情况下,我们并不需要自定义委托类型,因为 .NET 中已经定义好了一些内置的委托类型:

  • Action:返回值为 void 类型的委托,泛型参数列表内为参数列表,例如 Action 为无参且返回值为 void 的委托、Action<int> 为参数是 int 且返回值为 void 的委托。
  • Func:既有参数又有返回值的委托。泛型参数列表中最后一个为返回值类型。例如 Func<int, double> 为参数是 int、返回值是 double 的委托。

此外,一个委托不仅可以绑定一个方法,还可以绑定多个方法,即多播委托。多播委托可以通过 += 运算符添加方法,通过 -= 运算符移除方法。例如:

1
2
3
4
5
6
7
8
static public void Call1() => Console.WriteLine("Call1");
static public void Call2() => Console.WriteLine("Call2");
static public void Call3() => Console.WriteLine("Call3");

var caller = new Action(Call1);
caller += Call2;
caller = caller + Call3;
caller.Invoke(); // 等价于 caller();

注意,移除方法后多播委托可能存在不绑定任何一个方法的情况,在这种情况下调用委托是非法的。我们可以使用 caller?.Invoke() 先判断委托是否绑定了方法,如果是再调用 Invoke 方法。

6. C# 进阶(选读)

由于篇幅的限制和课时的影响,很多有趣且非常有用的内容我们没有做过多展开,例如:

感兴趣的同学可以自行阅读。此外,我们将在下一节学习使用 C# 进行多线程程序与异步程序的编写。接下来请进一步学习“多线程与异步”单元。


肆叁小灶第六讲 .NET 开发与 C# 基础
https://sqzr2319.github.io/43Class/43Class-6/
作者
sqzr2319
发布于
2025年8月3日
许可协议