肆叁小灶第六讲 .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 |
|
如果安装成功,你将看到 .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 |
|
- 进入新创建的目录:
1 |
|
- 打开
Program.cs
文件,你会看到以下代码:
1 |
|
- 运行程序:
1 |
|
你应该会看到输出 Hello, World!
。
0.4.2 使用 Visual Studio
- 打开 Visual Studio,选择 “创建新项目”。
- 在语言栏中选择 “C#”,然后选择 “控制台应用”,点击 “下一步”。
- 输入项目名称(如
HelloWorld
),选择保存位置,点击 “下一步” - 勾选”不启用顶级语句”,然后点击 “创建”。
- 在
Program.cs
文件中,你会看到以下代码:
1 |
|
- 点击工具栏上的 “运行” 按钮(或按 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 |
|
上面是 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 |
|
C# 编译器会自动将这个顶级语句转换为一个隐式的 Main
方法,并输出 Hello, World!
。值得一提的是,上面的代码并没有引入 using System
语句,但仍然可以使用 Console
类,这是因为 C# 编译器会自动引入一些常用的命名空间。
0.6 C# 项目结构
一个包含多文件的 C# 项目的目录结构一般如下:
1 |
|
其中,Program.cs
是主程序文件,包含程序的入口点;ModuleName1
和 ModuleName2
是两个模块,分别包含各自的脚本文件;Resources
文件夹用于存放静态资源,如图片和数据文件;bin
和 obj
文件夹是编译过程中自动生成的输出目录和中间文件目录;README.md
是项目说明文档;.gitignore
是 Git 忽略文件配置。
如需在一个代码文件中引用其他代码文件中的类或方法,可以使用 using
语句引入命名空间。例如,在 Program.cs
中引用 ModuleName1/ScriptName1.cs
中定义的命名空间 Namespace1
下的类 Class1
,可以使用以下方式:
1 |
|
可见,在 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 |
|
注意,C# 默认可以将低精度类型隐式转换到高精度类型,而将高精度类型转换到低精度类型需要强制类型转换。例如:
1 |
|
1.1.2 枚举类型
枚举类型则用于定义一个新的数据类型,该类型的所有可能值就是其枚举成员。为了后续便于比较和赋值,enum
为每个可能的值分配了一个整数值。例如:
1 |
|
1.1.3 结构体类型
C# 中的结构体类型也与 C 语言中的结构体相似,可以包含多个字段。例如:
1 |
|
1.1.4 元组类型
此外,C# 还支持元组类型,提供了简洁的语法来将多个数据元素分组成一个轻型数据结构,与 Python 中的元组和 C++ 中的 std::tuple
类似,可看作一个轻量级的结构体。元组最常用的场景是作为方法的返回值,将多个返回值打包成一个元组返回。例如:
1 |
|
上面使用的 var
关键字是 C# 的类型推断功能,编译器会根据右侧的值自动推断出变量的类型,如 var person3
的类型为 (double, int)
元组。
1.2 引用类型
除了 1.1 一节中介绍的值类型,C# 中的其他类型均为引用类型。常用的引用类型包括字符串 string
,类 class
,接口 interface
,数组 []
,委托 delegate
等。类、接口与委托的概念我们将在后续章节中介绍,这里我们先介绍字符串和数组。
1.2.1 字符串
C# 中的字符串 string
与 Python 中的字符串类似,是一个不可变的字符序列。例如:
1 |
|
1.2.2 数组
C# 中的数组是一个固定大小的元素集合,元素类型可以是值类型或引用类型,例如由 int
类型组成的数组 int[]
,由 string
类型组成的数组 string[]
等。例如:
1 |
|
C# 中也可以定义多维数组,但与 C 语言不同,C# 的多维数组并非通过嵌套数组实现,而是直接定义为一个多维数组类型。例如:
1 |
|
我们可以使用 foreach
语句遍历数组中的元素,但需要注意的是,通过 foreach
无法修改数组中的元素,只能访问其值。例如:
1 |
|
1.3 .NET 数据结构
C# 还提供了许多常用的数据结构,如列表 List<T>
、字典 Dictionary<TKey, TValue>
、集合 HashSet<T>
、队列 Queue<T>
、栈 Stack<T>
等,这些数据结构都位于 System.Collections.Generic
命名空间中,本质为泛型类(与 C++ 中的模板类相似),可以存储任意类型的数据,用法与 C++ STL 中的容器以及 Python 的标准数据类型类似。例如:
1 |
|
此外,C# 还提供 ArrayList
和 Hashtable
等非泛型集合类型,其元素类型为 object
(所有类型的基类,即可以存储任意类型的对象),但不推荐使用,因为它们不提供类型安全检查,容易导致运行时错误,此处不再赘述。
2. 输入与输出
在介绍 C# 的输入输出前,我们先介绍一下 C# 的格式化字符串。
2.1 格式化字符串
C# 提供了多种格式化字符串的方式,包括转义字符、逐字文本、字符串内插、原始字符串等。
2.1.1 转义字符
使用 \
转义特殊字符,如 \n
表示换行,\\
表示反斜杠,与 C 语言类似。例如:
1 |
|
2.1.2 逐字文本
使用 @
前缀定义逐字文本字符串,逐字文本字符串中的转义字符不会被处理。例如:
1 |
|
2.1.3 字符串内插
使用 $
前缀定义字符串内插,可以在字符串中嵌入变量,与 Python 类似。例如:
1 |
|
2.1.4 原始字符串
以 """
开始并以 """
结束,允许多行字符串,若为多行字符串则以单独的一行 """
结尾,且字符串的缩进以结尾的 """
的起始位置为基准。原始字符串文本不进行任何转义操作,但允许字符串内插(开头的 $
数量代表内插所需要的花括号数)。例如:
1 |
|
2.2 控制台读写
2.2.1 控制台输入
C# 提供了两种控制台输入:System.Console.Read
和 System.Console.ReadLine
。其中 System.Console.Read
读取一个字符返回(返回值为 int
);而 System.Console.ReadLine
可以读入一行字符串。若要读入一个整数或浮点数,需要手动进行转换。例如:
1 |
|
2.2.2 控制台输出
控制台输出则常用 System.Console.Write
和 System.Console.WriteLine
实现。其中 System.Console.Write
输出内容后不换行,而 System.Console.WriteLine
输出内容后会自动换行。此外,可以进行格式输出,只需要在字符串中用 {} 括住参数的序号即可,但更常用的是字符串内插。例如:
1 |
|
2.3 .NET 流与文件读写
C# 也支持文件的输入输出操作,可以使用 System.IO
命名空间中的类来实现。常用的文件操作类包括 File
、StreamReader
和 StreamWriter
。
2.3.1 File 类
File 类常用于一次性读写文件操作,相较于流操作更为简单。例如:
1 |
|
2.3.2 StreamReader 和 StreamWriter 类
StreamReader
和 StreamWriter
类则用于逐行读写文件,适合处理大文件或需要逐行处理的场景。例如:
1 |
|
上面的 using
语句用于确保在使用完流后自动释放资源,避免资源泄漏。
3. 运算与控制
3.1 运算符
C# 除了支持常见的算术运算符、关系运算符和逻辑运算符外,还带有一些特殊的运算符。
3.1.1 空合并运算符
用 ??
表示,如果左侧为 null,则返回右侧的值。例如:
1 |
|
3.1.2 空条件运算符
用 ?.
表示,如果左侧为 null,则返回 null,否则返回左侧的值。例如:
1 |
|
3.1.3 空合并赋值运算符
用 ??=
表示,如果左侧为 null,则将右侧的值赋给左侧。例如:
1 |
|
3.1.4 空包容运算符
用 !
表示,用于指示编译器左侧的值不会为 null,通常用于取消编译器的 null 检查。例如:
1 |
|
注意,使用 !
运算符时要格外小心,它只是告诉编译器不要警告,但如果值确实是 null
,运行时仍会出错。
3.2 模式匹配
模式匹配是一种测试表达式是否具有特定特征的方法,在 C# 中可以通过 switch
语句或 is
关键字实现,与 Python 的 match…case
类似。模式匹配可以用于以下场景:
3.2.1 Null 检查
使用 is null
或 is not null
进行空值判断。例如:
1 |
|
3.2.2 类型匹配
在 switch
语句中匹配类型。例如:
1 |
|
3.2.3 值匹配与比较
在 switch
语句中匹配或比较特定的值。例如:
1 |
|
3.2.4 属性匹配
在 switch
语句中匹配对象的属性。例如:
1 |
|
3.2.5 元组匹配
在 switch
语句中匹配元组。例如:
1 |
|
3.3 异常处理
在 C# 中,System.Exception
类是一切异常类的基类,Message
是该类及其派生类共有的属性,用于储存异常信息。C# 中的异常处理使用 try-catch-finally
语句块来捕获和处理异常,与 C++ 类似。
3.3.1 内置异常类
C# 提供了许多内置异常类,如 ArgumentNullException
(参数为空)、IndexOutOfRangeException
(数组越界)等,程序运行过程中若遇到这些异常,系统会自动抛出相应的异常对象,其 Message
属性会自动包含异常信息。例如:
1 |
|
3.3.2 调用异常类
上述内置的异常类在其他地方也可以调用并抛出,但需要自定义其 Message
属性来提供更具体的错误信息。例如:
1 |
|
3.3.3 自定义异常类
此外,在 C# 中也可以自定义异常类。例如:
1 |
|
4. 类
4.1 类的定义
类属于引用类型,由 class
关键词定义。一个类可以包含字段、方法等,甚至可以包含类(称为嵌套类)。类还可以分成很多块来定义,甚至放在多个文件里,只需要将每个部分的定义都加上 partial
关键字即可。例如:
1 |
|
上面所使用的构造方法与 C++ 中的构造函数类似,不具有返回值,且方法名与类名相同,是在一个对象被构造的时候调用的方法,由 new
表达式传递参数。一个类可以有多个构造方法(称为重载),但每个构造方法的参数列表必须不同。
4.2 Static 关键字
4.2.1 静态字段与方法
类中的字段和方法可以是静态的,使用 static
关键词定义。静态字段和方法属于类本身,而不是类的实例。静态字段在所有实例中共享,静态方法可以直接通过类名调用,与 Python 中的类变量和类方法相似。例如:
1 |
|
4.2.2 运算符重载
运算符重载是一种特殊的静态方法,在 C# 中其语法与 C++ 类似,使用 operator
关键字定义。例如:
1 |
|
4.3 类的方法
4.3.1 参数传递
与 C++ 类似,C# 中方法的参数默认采用值传递,即将实参复制一份给形参。对于值类型来说,复制的是值类型的所有数据,对于引用类型来说,复制的是一个引用。例如:
1 |
|
若要实现引用传递,可以使用 ref
关键字。加上关键字 ref
后,值类型的形参和实参指代的是同一个值类型对象,而引用类型的实参和形参指代的是同一个引用,例如上述代码改成:
1 |
|
4.3.2 参数缺省
C# 中可以为方法的参数设置缺省值,这样在调用方法时可以省略某些参数。调用时,默认会把末尾未赋实参的参数赋以缺省值,但也可以自行指定。例如:
1 |
|
4.3.3 Lambda 表达式
如果方法体只有一行代码,可以使用 Lambda 表达式来简化书写。Lambda 表达式可以看成一个匿名方法,其语法为 (参数列表) => 表达式
,与 Python 中的推导式类似。例如:
1 |
|
4.4 继承与多态
4.4.1 类的继承
C# 继承的语法与 C++ 类似,但 C# 不支持多继承。在类里可以通过 base 关键字代表它的基类,同样构造方法也需要通过 base 关键字来为它的基类提供构造方法的参数。例如:
1 |
|
4.4.2 方法重写
在 C# 中,只有虚方法(使用 virtual
关键字定义,必须定义方法体)和抽象方法(使用 abstract
关键字定义,不能定义方法体)可以被重写,且重写方法时必须使用 override
关键字。例如:
1 |
|
4.4.3 抽象类
抽象类使用 abstract
关键字定义,其不能被实例化,但可以被继承(不允许继承的类使用 sealed
关键字定义)。但与 C++ 不同,C# 中的抽象类可以包含非抽象方法(即有方法体的方法)。例如:
1 |
|
4.4.4 接口
接口(使用 interface
关键字定义,属于引用类型)比抽象类更为严格,其只能包含没有方法体的方法(即抽象方法,但不需要使用 abstract
关键字),且不能包含字段。但一个类可以实现(即继承)多个接口,实现接口中的方法时也无需使用 override
关键字。例如:
1 |
|
5. C# 特性
5.1 泛型
C# 中的泛型与 C++ 的模板类似,允许在类、方法等定义中使用类型参数,只需要在类或方法名后使用尖括号 <>
括住泛型的名称即可。此外,若希望泛型类型参数满足某些条件,可以使用 where
关键字指定类型约束。例如:
1 |
|
其中,where T : struct
表示 T
必须是一个不可为 null
的值类型,否则会报错。此外,struct
还可换成:
class
:不可为null
的引用类型class?
:引用类型new()
:具有无参构造方法- 一个类名或接口名:
T
必须从该类继承或实现了该接口 - ……(具体约束根据需求而定)
5.2 委托
委托是一种引用类型,作用与 C++ 中的函数指针类似。委托类型的定义格式与方法类似,只是在返回值类型前加上 delegate
关键字。例如:
1 |
|
该段代码定义了一个委托类型,名字叫 BinaryFunctor
。该委托可以接收参数为 (int, int)
,返回类型为 int
的方法。与其他引用类型一样,我们需要用 new
关键字创建一个委托,并将一个方法赋给这个委托。例如:
1 |
|
但多数情况下,我们并不需要自定义委托类型,因为 .NET 中已经定义好了一些内置的委托类型:
Action
:返回值为void
类型的委托,泛型参数列表内为参数列表,例如Action
为无参且返回值为void
的委托、Action<int>
为参数是int
且返回值为void
的委托。Func
:既有参数又有返回值的委托。泛型参数列表中最后一个为返回值类型。例如Func<int, double>
为参数是int
、返回值是double
的委托。
此外,一个委托不仅可以绑定一个方法,还可以绑定多个方法,即多播委托。多播委托可以通过 +=
运算符添加方法,通过 -=
运算符移除方法。例如:
1 |
|
注意,移除方法后多播委托可能存在不绑定任何一个方法的情况,在这种情况下调用委托是非法的。我们可以使用 caller?.Invoke()
先判断委托是否绑定了方法,如果是再调用 Invoke
方法。
6. C# 进阶(选读)
由于篇幅的限制和课时的影响,很多有趣且非常有用的内容我们没有做过多展开,例如:
感兴趣的同学可以自行阅读。此外,我们将在下一节学习使用 C# 进行多线程程序与异步程序的编写。接下来请进一步学习“多线程与异步”单元。