肆叁小灶第六讲 .NET 开发与 C# 基础

肆叁小灶第六讲,介绍了 .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 类库所在的命名空间。包含该命名空间,可以使用该命名空间的内容。其中,ConsoleSystem 命名空间的一个类,因此该语句应为 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 是主程序文件,包含程序的入口点;ModuleName1ModuleName2 是两个模块,分别包含各自的脚本文件;Resources 文件夹用于存放静态资源,如图片和数据文件;binobj 文件夹是编译过程中自动生成的输出目录和中间文件目录;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# 还提供 ArrayListHashtable 等非泛型集合类型,其元素类型为 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.ReadSystem.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.WriteSystem.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 命名空间中的类来实现。常用的文件操作类包括 FileStreamReaderStreamWriter

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 类

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

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 nullis 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-6/
作者
sqzr2319
发布于
2025年8月3日
许可协议