1. C# 入门

本章从「C#、.NET 与程序运行过程」「一个 C# 程序的基本结构」和「注释与代码区域」等方面说明 C# 入门。

1.1 C#、.NET 与程序运行过程

C# 是一门强类型、面向对象的编程语言。.NET 提供运行时、基础类库和开发工具。通常情况下,C# 源代码先被编译为中间语言,再由 .NET 运行时执行。
可以先简单理解为:
在 Unity 中同样使用 C# 编写脚本,但 Unity 所使用的运行环境、支持的语言特性和普通 .NET 项目可能存在差异。实际开发时应以当前 Unity 版本和项目配置为准。

1.2 一个 C# 程序的基本结构

这段代码包含四个重要部分。
1.2.1 using
using 用于导入命名空间,使代码可以直接使用其中的类型。
导入 System 后,可以直接写:
否则需要写完整名称:
注意:using 只是让类型名称更简洁,并不会把某个类复制到当前文件中。
1.2.2 namespace
命名空间用于组织代码并减少命名冲突。
不同命名空间中可以存在同名类型:
1.2.3 class
类是组织数据和行为的一种方式。现阶段可以先把它理解为一个“模板”,后面会在面向对象部分详细说明。
1.2.4 Main
Main 是控制台程序的入口方法,程序从这里开始执行。
static 表示调用该方法时不需要先创建 Program 对象。
注意:C# 区分大小写,Mainmain 是两个不同的方法。

1.3 注释与代码区域

本节从「单行注释」「多行注释」和「XML 文档注释」等方面说明注释与代码区域。
1.3.1 单行注释
1.3.2 多行注释
1.3.3 XML 文档注释
1.3.4 #region
#region 可以折叠一段代码,但不会改变程序逻辑。
项目中不应为了折叠而嵌套大量 #region。当一个文件已经需要很多区域才能看清时,通常意味着类的职责过多,应考虑拆分。

1.4 控制台输入与输出

本节从「输出」「读取一行文本」和「读取按键」等方面说明控制台输入与输出。
1.4.1 输出
1.4.2 读取一行文本
Console.ReadLine() 的返回类型是 string。即使用户输入的是数字,读取到的仍然是字符串,需要再进行类型转换。
1.4.3 读取按键
ReadKey 常用于暂停程序或制作简单的控制台交互。

1.5 变量、常量与命名

变量用于保存程序运行过程中可能变化的数据。
常量使用 const 声明,定义后不能修改:
1.5.1 常见命名方式
  • 局部变量和参数:小驼峰,例如 playerNamemoveSpeed
  • 类、方法和属性:帕斯卡命名,例如 PlayerControllerTakeDamage
  • 私有字段:常见写法是 _currentHp,具体以项目规范为准。
  • 常量:C# 项目中也通常使用帕斯卡命名,例如 MaxCount
命名必须满足:
  • 不能以数字开头;
  • 不能直接使用关键字;
  • 区分大小写;
  • 同一作用域内不能重复声明同名变量;
  • 尽量表达实际含义,不使用无意义的 a1temp2

1.6 常用数据类型

本节从「整数类型」「浮点类型」和「其他常用类型」等方面说明常用数据类型。
1.6.1 整数类型
  • sbyte:1 字节,范围为 -128 到 127。
  • byte:1 字节,范围为 0 到 255。
  • short:2 字节,范围为 -32768 到 32767。
  • ushort:2 字节,范围为 0 到 65535。
  • int:4 字节,最常用的整数类型。
  • uint:4 字节,只保存非负整数。
  • long:8 字节,适合更大的整数。
  • ulong:8 字节,只保存非负整数。
游戏开发中普通计数、索引、等级通常使用 int。只有确定范围或协议格式时,才需要刻意选择更小的整数类型。
1.6.2 浮点类型
  • float:4 字节,约 6~9 位有效数字,字面量通常需要加 f
  • double:8 字节,约 15~17 位有效数字,是小数字面量的默认类型。
  • decimal:16 字节,十进制精度较高,常用于金额等场景,字面量需要加 m
注意:浮点数存在精度误差,不应直接依赖 a == b 判断经过多次计算的小数是否完全相等。
1.6.3 其他常用类型
  • bool:表示 truefalse
  • char:表示一个 UTF-16 代码单元,使用单引号。
  • string:表示字符串,使用双引号;它是引用类型。

1.7 字符串与转义字符

常见转义字符:
逐字字符串在前面加 @
字符串插值使用 $
$@ 可以组合使用:

1.8 类型转换

notion image
notion image
1.8.1 隐式转换
从范围较小的数值类型转为范围较大的兼容类型时,通常可以隐式转换:
1.8.2 显式转换
显式转换使用强制类型转换语法,可能丢失精度或溢出:
这里不是四舍五入,而是直接舍弃小数部分。
1.8.3 Parse
输入无法转换时,Parse 会抛出异常。
1.8.4 TryParse
处理用户输入时更推荐 TryParse
1.8.5 Convert
ConvertParse 和强制转换的行为并不完全相同,不能只因为都叫“转换”就随意替换。

1.9 运算符

本节从「算术运算符」「自增和自减」和「赋值运算符」等方面说明运算符。
1.9.1 算术运算符
两个整数相除,结果仍然是整数:
1.9.2 自增和自减
前置和后置在单独一行时结果相同,但参与表达式时求值顺序不同。实际项目中不建议把多个自增操作塞进复杂表达式。
1.9.3 赋值运算符
1.9.4 比较运算符
1.9.5 逻辑运算符
&&|| 具有短路行为:当前面的条件已经能决定结果时,后面的表达式不会执行。
1.9.6 位运算符
位运算符包括 &|^~<<>>。它们常用于权限、状态掩码和底层数据处理。
刚接触时应先掌握普通布尔逻辑,位标记等实际需要时再深入。
1.9.7 三元运算符
适合简单的二选一,复杂逻辑仍应使用 if

1.10 异常捕获

  • try:放置可能发生异常的代码。
  • catch:处理指定异常。
  • finally:无论是否出现异常,通常都会执行。
注意:异常适合处理异常情况,不应代替普通的分支判断。读取用户数字时优先使用 TryParse

1.11 条件分支

本节从「if」和「switch」两方面说明条件分支。
1.11.1 if
1.11.2 switch
switch 适合对一个明确值进行多分支匹配。

1.12 循环语句

本节从「while」「do while」和「for」等方面说明循环语句。
1.12.1 while
1.12.2 do while
do while 至少执行一次循环体。
1.12.3 for
1.12.4 foreach
1.12.5 breakcontinue
  • break:立即结束当前循环。
  • continue:跳过本次循环剩余内容,进入下一次循环。
易错点:循环条件、变量更新和边界不正确,容易导致死循环或数组越界。

1.13 枚举

notion image
枚举用于表示一组有名称的整数常量,适合描述有限状态和类型。
使用:
枚举底层默认使用 int,第一个成员默认值为 0,后续依次增加,也可以手动指定:
枚举和整数可以显式转换:
注意:把任意整数强制转换为枚举不会自动检查该值是否真的定义过。需要验证时可以使用 Enum.IsDefined

1.14 随机数

Next(min, max) 包含 min,但不包含 max
不要在很短时间内反复创建多个 Random 对象。通常应复用一个实例。

1.15 断点调试

调试时应重点观察:
  • 当前执行到哪一行;
  • 局部变量的实际值;
  • 分支是否进入预期路径;
  • 循环执行了多少次;
  • 异常在哪一行抛出;
  • 调用栈来自哪个方法。
遇到问题时,比起不断增加 Console.WriteLine,断点、单步执行和变量监视通常更高效。

1.16 入门综合练习:角色创建

下面把输入、枚举、分支和类型转换结合起来。

2. C# 基础

本章从「数组」「二维数组」和「交错数组」等方面说明 C# 基础。

2.1 数组

数组用于保存一组 类型相同、长度固定 的数据。数组本身是引用类型,数组中的元素可以是值类型,也可以是引用类型。
2.1.1 一维数组
常见声明方式:
数组创建后长度固定:
访问元素时使用从 0 开始的索引:
易错点:长度为 3 的数组,有效索引是 012。访问 numbers[3] 会发生越界异常。
2.1.2 遍历数组
使用 for 可以同时获得索引:
只需要读取元素时,可以使用 foreach
foreach 中的迭代变量不能直接用于修改原数组元素。需要按索引修改时,应使用 for
2.1.3 查找元素
手动查找:
也可以使用数组提供的方法:
找不到时返回 -1
2.1.4 数组的“增加”和“删除”
数组长度不能原地改变。所谓增加或删除元素,本质上通常是:
  1. 创建新数组;
  1. 复制需要保留的数据;
  1. 让变量引用新数组。
也可以使用:
Array.Resize 内部仍然会创建新数组,并不是把原数组的内存直接拉长。需要频繁增删元素时,后面学习的 List<T> 更合适。
2.1.5 清空和复制
值类型元素会恢复为默认值,引用类型元素会变为 null
复制数组:
如果数组元素是引用类型,这种复制只会复制元素中保存的引用,不会自动创建全新的对象。

2.2 二维数组

二维数组可以理解为固定行列的表格。
访问元素:
获取行数和列数:
遍历:
二维数组适合:
  • 棋盘;
  • 网格地图;
  • 固定行列的数据表;
  • 关卡格子信息。

2.3 交错数组

交错数组是“数组中的每个元素仍然是数组”,每一行长度可以不同。
访问元素:
遍历:
二维数组和交错数组的区别:
  • int[,]:规则矩形,所有行列统一;
  • int[][]:每一行可以拥有不同长度。

2.4 函数(方法)

方法用于封装一段可重复调用的逻辑。
示例:
调用:
2.4.1 无参数、无返回值
2.4.2 有参数、无返回值
2.4.3 无参数、有返回值
2.4.4 有参数、有返回值
注意:方法声明中的参数叫形参,调用时传入的具体数据叫实参。

2.5 作用域与局部变量

在一对大括号中声明的变量,通常只在该作用域内有效。
方法内部不能直接访问另一个方法的局部变量。需要使用参数传递、返回值或对象成员保存数据。

2.6 返回值

return 有两个作用:
  1. 返回结果;
  1. 立即结束当前方法。
一个方法只能声明一个返回类型,但可以通过元组、结构体、类或 out 返回多个结果。
使用元组:

2.7 refout

普通参数按值传递。对值类型来说,方法获得的是值的副本。
2.7.1 ref
ref 允许方法直接修改调用方变量。调用前变量必须已经初始化。
2.7.2 out
out 常用于让方法额外输出结果。调用前变量不必初始化,但方法返回前必须赋值。
调用:
2.7.3 如何选择
  • 只需要输入:普通参数;
  • 需要读取并修改原变量:ref
  • 主要用于输出附加结果:out
  • 结果关系复杂:优先考虑返回对象、结构体或元组,提高可读性。

2.8 params 变长参数

params 允许调用方传入数量不固定的同类型参数。
调用:
规则:
  • 一个方法只能有一个 params 参数;
  • params 必须放在参数列表最后;
  • 本质上仍然是数组参数。

2.9 可选参数与命名参数

可选参数必须提供默认值:
调用:
必填参数应放在可选参数前面。命名参数可以提高多个同类型参数调用时的可读性。

2.10 函数重载

同一个类型中,方法名相同但参数列表不同,称为重载。
重载依据包括:
  • 参数数量;
  • 参数类型;
  • 参数顺序。
不能只通过返回值类型区分重载:

2.11 递归函数

递归是方法直接或间接调用自身。
递归必须包含:
  • 明确的结束条件;
  • 每次调用都逐渐接近结束条件。
否则会不断压入调用栈,最终导致栈溢出。
适合递归的场景包括树结构遍历、文件夹遍历和分治问题。但普通循环更清楚时,不要为了使用递归而递归。

2.12 值类型与引用类型

2.12.1 值类型
常见值类型:
  • 整数和浮点数;
  • boolchar
  • 枚举;
  • 结构体。
赋值时通常复制值:
2.12.2 引用类型
常见引用类型:
  • 类;
  • 数组;
  • 字符串;
  • 委托;
  • 接口类型变量。
类对象赋值时通常复制引用,两个变量可能指向同一个对象:
注意:值类型和引用类型的区别不能简单理解为“一个一定在栈,一个一定在堆”。实际存储位置与变量上下文、运行时优化等有关。学习阶段更重要的是理解复制语义和对象共享。
2.12.3 数组参数为什么会被修改
传参时复制的是数组引用的值,方法内外仍然指向同一个数组对象。
但如果在方法内让参数指向新数组,不会自动改变调用方变量本身:
需要替换调用方的数组变量,可以返回新数组,或使用 ref。通常返回新数组更清楚。

2.13 结构体

结构体使用 struct 声明,是值类型,适合表示较小、数据含义明确的对象。
使用:
结构体的特点:
  • 是值类型;
  • 支持字段、属性、方法和构造逻辑;
  • 可以实现接口;
  • 不支持类之间那样的继承体系;
  • 赋值时通常复制整个值。
适合结构体的场景:
  • 坐标;
  • 颜色;
  • 小型配置值;
  • 不需要独立生命周期的轻量数据。
如果对象数据很多、需要继承、需要共享修改或需要长期保持唯一身份,通常使用类更合适。

2.14 冒泡排序

冒泡排序反复比较相邻元素,把较大的元素逐步移动到后面。
特点:
  • 思路直观;
  • 时间复杂度通常为 O(n²)
  • 适合学习排序思想,不适合大量数据的实际排序。

2.15 选择排序

选择排序每轮从未排序区域中找到最小值,放到当前起始位置。
在实际项目中通常直接使用标准库排序:
手写排序主要用于理解比较、交换、循环边界和时间复杂度。

2.16 基础阶段总结

学完这一部分,应能够:
  • 使用一维、二维和交错数组组织数据;
  • 正确处理数组索引和长度;
  • 编写带参数、返回值和重载的方法;
  • 理解 refoutparams 和可选参数;
  • 区分值类型复制与引用类型共享;
  • 使用结构体表示轻量数据;
  • 看懂并手写冒泡排序和选择排序。
下一部分开始进入 C# 最重要的面向对象内容。

3. C# 核心——面向对象

本章从「什么是面向对象」「类与对象」和「成员变量、成员方法和访问修饰符」等方面说明 C# 核心——面向对象。

3.1 什么是面向对象

面向对象的核心不是“把所有代码都写进类”,而是把现实或业务中的数据与行为组织成相对独立的对象。
例如一个游戏角色通常拥有:
  • 名字、生命值、攻击力等数据;
  • 移动、攻击、受伤、死亡等行为。
如果把这些内容放在一起,就可以形成一个 Player 类。
面向对象常提到三大特性:
  • 封装:隐藏内部细节,通过明确接口使用对象;
  • 继承:在已有类型基础上复用和扩展;
  • 多态:用统一接口处理不同具体类型。
合理使用面向对象可以提高代码复用性、可读性和扩展性,但类拆得过多、继承层级过深也会增加复杂度。

3.2 类与对象

类是模板,对象是根据类创建出的具体实例。
创建对象:
变量可以先保存 null
这表示变量当前没有引用任何 Player 对象。使用前必须确认不为 null

3.3 成员变量、成员方法和访问修饰符

类中常见成员包括:
  • 字段;
  • 属性;
  • 方法;
  • 构造函数;
  • 事件;
  • 索引器;
  • 嵌套类型。
3.3.1 字段
字段用于保存对象状态。一般不建议把所有字段都设为 public,否则外部代码可以随意修改对象内部状态。
3.3.2 方法
3.3.3 常见访问修饰符
  • public:任何可访问该类型的位置都可以使用;
  • private:只能在当前类型内部使用;
  • protected:当前类型和派生类型可以使用;
  • internal:同一程序集内可以使用;
  • protected internal:同一程序集,或其他程序集中的派生类型可以使用;
  • private protected:同一程序集中的当前类型和派生类型可以使用。
学习阶段最常用的是 publicprivateprotected
注意:不给类成员写访问修饰符时,默认值不一定都相同。类成员通常默认是 private,顶级类型通常默认是 internal。不要依赖默认值,重要成员应明确写出访问级别。

3.4 构造函数

构造函数在创建对象时执行,用于初始化对象。
使用:
构造函数特点:
  • 名称与类名相同;
  • 没有返回值类型;
  • 可以重载;
  • 可以使用 this 调用同类型的其他构造函数。

3.5 析构函数与垃圾回收

C# 使用垃圾回收器管理托管对象的内存。对象不再被可达引用使用后,未来某个时间可能被回收。
析构函数语法:
需要注意:
  • 析构函数执行时间不确定;
  • 普通类通常不需要编写析构函数;
  • 文件句柄、网络连接等资源应使用 IDisposableusing 明确释放;
  • 不要依赖垃圾回收器在某个固定时刻执行。
离开 using 作用域后会调用 Dispose

3.6 属性

notion image
属性用于控制字段的读取和修改。
自动属性:
只读属性:
属性看起来像字段,但本质上是带有访问逻辑的成员。它可以:
  • 校验数据;
  • 限制外部写入;
  • 计算结果;
  • 在值改变时执行附加逻辑。
注意:属性访问通常应保持轻量,不应在一个普通 getter 中执行耗时网络请求或复杂副作用。

3.7 索引器

索引器让对象可以使用类似数组的语法访问内部数据。
使用:
索引器适合容器类型,不应为了语法好看而给普通业务对象强行添加。

3.8 静态成员

静态成员属于类型本身,不属于某个具体对象。
调用:
静态方法不能直接访问实例字段,因为它没有当前对象:

3.9 静态类

静态类不能实例化,只能包含静态成员。
适合无状态工具和全局常量。不要把大量全局可变状态都塞进静态类,否则会导致依赖隐藏、测试困难和生命周期混乱。

3.10 运算符重载

自定义类型可以为部分运算符提供自己的行为。
使用:
规则:
  • 运算符重载方法必须是 public static
  • 至少有一个操作数必须是当前自定义类型;
  • 不应让运算符产生违反直觉的行为。

3.11 扩展方法

扩展方法可以在不修改原类型源码的情况下,为类型增加可调用方法语法。
使用:
扩展方法要求:
  • 定义在非泛型静态类中;
  • 方法是 public static
  • 第一个参数使用 this 修饰。
扩展方法不会真正修改原类型,也不能访问原类型的私有成员。如果同名实例方法存在,实例方法优先。

3.12 内部类与分部类

本节从「内部类」和「分部类」两方面说明内部类与分部类。
3.12.1 内部类
类型可以定义在另一个类型内部:
适合只服务于外部类型的实现细节。
3.12.2 分部类
使用 partial 可以把同一个类型拆到多个文件中:
常见于自动生成代码、设计器代码和人工代码需要分离的场景。不能为了避免整理一个过大的类,就无原则地使用分部类;职责过多时仍应拆成多个类型。

3.13 继承

继承允许派生类复用基类的可访问成员。
C# 类只支持单继承,即一个类只能直接继承一个基类,但可以实现多个接口。
继承适合真正的“是一个”关系:
如果只是“拥有一个”,通常使用组合:
游戏项目中通常优先组合,避免继承层级越来越深。

3.14 里氏替换与类型转换

派生类对象可以赋给基类变量:
基类变量只能直接访问基类声明的成员。需要判断具体类型时,可以使用模式匹配:
也可以使用 as
里氏替换原则强调:使用基类的地方,应该能够合理地使用派生类,而不破坏原有行为约定。
如果派生类必须大量推翻基类规则,说明继承关系可能设计错误。

3.15 继承中的构造顺序

创建派生类对象时,先执行基类构造,再执行派生类构造。
base(...) 用于调用基类构造函数。
构造阶段不要调用可能被派生类重写的虚方法,因为派生类自己的字段可能还没有初始化完成。

3.16 object、装箱与拆箱

所有 C# 类型最终都与 object 有关系。引用类型可以隐式转换为 object,值类型转换为 object 时会发生装箱。
拆箱时类型必须匹配:
频繁装箱拆箱会产生额外开销。非泛型集合把元素作为 object 保存,因此存值类型时会出现装箱,后面通常优先使用泛型集合。

3.17 密封类与密封方法

sealed 类不能被继承:
密封重写方法可以阻止后续派生类继续重写:
是否密封应根据设计约束决定,不是所有类都需要可继承。

3.18 多态:virtualoverride

多态允许通过基类变量调用派生类的具体实现。
使用统一类型处理:
调用结果由对象的真实类型决定。
易错点:new 关键字隐藏方法,不等于重写。需要运行时多态时,应使用 virtualoverride

3.19 抽象类与抽象方法

抽象类不能直接实例化,适合表达具有共同状态和基础行为的类型。
派生类必须实现抽象方法:
抽象类可以包含:
  • 字段;
  • 构造函数;
  • 普通方法;
  • 虚方法;
  • 抽象方法;
  • 属性和事件。

3.20 接口

接口描述一组能力或约定。
实现接口:
一个类可以实现多个接口:
接口适合表达:
  • 可受伤;
  • 可交互;
  • 可保存;
  • 可移动;
  • 可序列化等横向能力。
使用接口可以降低调用方对具体类型的依赖:

3.21 显式接口实现

当多个接口成员同名,或不希望成员直接暴露在类实例上时,可以显式实现:
调用时需要通过对应接口变量:

3.22 命名空间与别名

大型项目应按照模块组织命名空间:
同名类型冲突时可以使用完整名称或别名:
命名空间用于逻辑组织,不等同于文件夹,但通常建议目录结构与命名空间保持一致。

3.23 object 常用方法

3.23.1 ToString
3.23.2 Equals
用于判断对象是否相等。默认引用类型通常比较引用身份,自定义值相等语义时应同时考虑 GetHashCode
3.23.3 GetHashCode
哈希集合和字典会使用哈希码。重写 Equals 时通常也需要重写 GetHashCode,确保相等对象得到相同哈希码。
3.23.4 GetType
返回对象运行时类型,是反射功能的入口之一。

3.24 string

字符串是引用类型,但具有不可变性。对字符串的修改会产生新的字符串对象。
这里不是在原字符串后面原地追加,而是生成新的结果并让变量重新引用它。
常用操作:
分割和连接:
空字符串判断:

3.25 StringBuilder

大量重复拼接字符串时,可以使用 StringBuilder,减少中间字符串对象。
常用方法:
  • Append
  • AppendLine
  • Insert
  • Remove
  • Replace
  • Clear
少量字符串拼接直接使用插值通常更清楚,不需要所有地方都换成 StringBuilder

3.26 结构体与类的区别

3.26.1 结构体
  • 值类型;
  • 赋值时通常复制值;
  • 适合较小、相对不可变的数据;
  • 不支持类继承;
  • 可以实现接口。
3.26.2 类
  • 引用类型;
  • 赋值时复制引用;
  • 支持继承和运行时多态;
  • 适合有身份、生命周期和共享状态的对象。
选择时不要只看“结构体性能更好”。大型可变结构体会带来大量复制和难以发现的修改问题。

3.27 抽象类与接口如何选择

notion image
使用抽象类,当多个类型:
  • 具有明确的共同基类关系;
  • 需要共享字段和状态;
  • 需要共同构造过程;
  • 需要提供部分默认实现。
使用接口,当多个类型:
  • 只需要共享某种能力;
  • 可能来自不同继承体系;
  • 一个类型需要同时具备多种能力;
  • 调用方只关心行为契约。
实际项目中二者经常配合:

3.28 实战思路:贪吃蛇项目如何拆分

notion image
控制台贪吃蛇可以按职责拆成:
基础数据可以使用结构体:
蛇是具有身份和可变状态的对象,适合使用类:
输入、绘制、移动和碰撞不要全部堆进 Main。这个项目的重点不是做出复杂画面,而是练习:
  • 类的职责划分;
  • 对象之间如何协作;
  • 结构体和类的选择;
  • 集合保存动态数据;
  • 游戏循环和状态管理。

3.29 核心阶段总结

学完这一部分,应能够:
  • 使用类封装数据和行为;
  • 通过属性控制对象状态;
  • 理解构造、垃圾回收和资源释放;
  • 区分实例成员与静态成员;
  • 使用继承、虚方法和接口实现多态;
  • 理解装箱拆箱及其来源;
  • 正确选择类、结构体、抽象类和接口;
  • 按职责拆分一个简单游戏项目。

4. C# 进阶

4.1 ArrayList

ArrayList 是早期的非泛型动态数组,位于 System.Collections 命名空间。
因为元素以 object 形式保存,所以它可以存放不同类型,但也带来问题:
  • 取出时经常需要强制转换;
  • 值类型会发生装箱和拆箱;
  • 类型错误只能在运行时发现;
  • 可读性和维护性较差。
现代 C# 项目通常优先使用 List<T>。只有维护旧代码或与旧接口交互时,才需要重点关注 ArrayList

4.2 非泛型 Stack

栈遵循 后进先出:最后压入的元素最先取出。
常见用途:
  • 撤销操作;
  • 页面返回记录;
  • 表达式求值;
  • 深度优先搜索。
非泛型 Stack 同样以 object 保存元素,实际项目优先使用 Stack<T>

4.3 非泛型 Queue

队列遵循 先进先出:最早进入的元素最先离开。
常见用途:
  • 消息队列;
  • 技能释放序列;
  • 广度优先搜索;
  • 等待处理的任务。
实际开发通常使用 Queue<T>

4.4 Hashtable

Hashtable 使用键值对保存数据。
键必须唯一。键和值都按 object 处理,缺少编译期类型安全。
现代项目通常使用:
而不是 Hashtable

4.5 泛型

泛型允许在保留类型信息的前提下编写可复用代码。
notion image
4.5.1 泛型类
使用:
T 是类型参数,创建具体对象时再确定真实类型。
4.5.2 泛型方法
调用:
很多情况下编译器可以根据参数推断类型,不需要写 Swap<int>
4.5.3 泛型接口
泛型的主要价值:
  • 编译期类型检查;
  • 减少强制转换;
  • 避免部分装箱拆箱;
  • 一套逻辑适配多种类型。

4.6 泛型约束

约束用于限制类型参数必须具备哪些能力。
4.6.1 引用类型约束
4.6.2 值类型约束
4.6.3 基类约束
4.6.4 接口约束
4.6.5 无参构造约束
多个约束可以组合:
约束不是为了让泛型看起来复杂,而是为了让方法内部能够安全地使用类型能力。

4.7 List<T>

List<T> 是最常用的泛型动态数组。
常用操作:
遍历:
Count 是当前元素数量,Capacity 是当前已经分配的容量。容量不足时,List<T> 会自动扩容。
注意:在 foreach 遍历 List<T> 时直接增删元素,通常会抛出异常。需要删除多个满足条件的元素,可以使用倒序 forRemoveAll

4.8 Dictionary<TKey, TValue>

字典按键快速查找值。
键必须唯一。重复 Add 会抛出异常。
推荐使用 TryGetValue
修改或新增:
遍历:
适合:
  • ID 到配置的映射;
  • 玩家名到玩家数据;
  • 状态名到处理器;
  • 缓存和索引。
易错点:自定义类型作为键时,相等比较和哈希码必须稳定。对象加入字典后,不应修改会影响哈希结果的字段。

4.9 顺序存储与链式存储

4.9.1 顺序存储
数组和 List<T> 的元素在逻辑上按连续索引排列。
特点:
  • 按索引访问快;
  • 尾部追加通常方便;
  • 中间插入删除可能需要移动后续元素;
  • 缓存局部性通常较好。
4.9.2 链式存储
链表通过节点之间的引用连接元素。
特点:
  • 已知节点时插入删除方便;
  • 不支持按索引快速访问;
  • 每个节点需要额外引用;
  • 查找指定位置通常仍需遍历。
不要简单认为“经常删除就一定用链表”。如果为了找到删除位置仍需从头遍历,整体未必更快。应根据真实访问方式选择。

4.10 LinkedList<T>

节点操作:
删除:
LinkedList<T> 没有普通列表那样的索引器。需要频繁按下标访问时,应使用 List<T>

4.11 泛型栈和队列

4.11.1 Stack<T>
4.11.2 Queue<T>
泛型版本类型更明确,也是实际项目中的默认选择。

4.12 委托

委托是一种可以引用方法的类型,可以把方法作为数据保存和传递。
符合签名的方法可以赋给该委托:
4.12.1 多播委托
多个方法会按照委托调用列表依次执行。
删除订阅:
使用匿名 Lambda 订阅后,如果没有保存同一个委托实例,后续很难准确移除,这是常见问题。
4.12.2 Action
表示没有返回值的方法:
4.12.3 Func
最后一个泛型参数是返回值类型:
4.12.4 Predicate<T>
接收一个 T 并返回 bool

4.13 事件

事件是在委托基础上增加发布限制的成员。
外部对象可以订阅和取消订阅:
事件与公开委托字段的关键区别:
  • 外部只能使用 +=-=
  • 外部不能把整个调用列表覆盖掉;
  • 外部不能直接触发事件;
  • 只有声明事件的类型可以调用它。
事件适合发布者不知道具体订阅者的场景,例如:
  • 生命值变化通知 UI;
  • 任务完成通知奖励系统;
  • 场景加载完成通知多个模块;
  • 输入系统通知角色控制器。
注意:长生命周期对象订阅短生命周期对象,或短生命周期对象订阅全局对象后不取消,可能导致对象无法释放。应明确订阅和取消订阅的时机。

4.14 匿名函数与 Lambda 表达式

notion image
notion image
匿名函数可以直接定义一段临时逻辑。
Lambda 是更常见的简写:
多行 Lambda:
4.14.1 闭包
Lambda 可以捕获外部变量:
捕获的是变量,而不是创建 Lambda 那一刻的固定数值。
循环中捕获变量时应明确每次需要保存的值:

4.15 List<T> 排序

4.15.1 基础类型排序
降序:
4.15.2 自定义对象排序
按品质、攻击力、名字依次排序:
4.15.3 IComparable<T>
当类型具有自然排序规则时,可以实现:
4.15.4 IComparer<T>
同一种类型有多套排序规则时,使用独立比较器更合适。

4.16 协变与逆变

协变和逆变用于泛型接口及委托之间的安全类型转换。
4.16.1 协变 out
协变允许把“派生类型序列”看作“基类序列”。
IEnumerable<out T> 只向外提供 T,因此这种转换是安全的。
4.16.2 逆变 in
逆变常见于只接收参数的委托或比较器。
能处理任何 Animal 的方法,当然也能处理 Cat
注意:这里的泛型 inout 与方法参数中的 inout 修饰符不是同一个概念。

4.17 多线程

notion image
notion image
notion image
线程可以让多个执行流程并发运行。
4.17.1 线程安全问题
多个线程同时修改共享数据时,可能产生竞态条件。
lock 保证同一时间只有一个线程进入临界区。
4.17.2 使用线程时的原则
  • 尽量减少共享可变数据;
  • 锁定范围尽可能小;
  • 不要在持有锁时执行长时间阻塞操作;
  • 明确线程如何结束;
  • 捕获并处理工作线程中的异常;
  • 现代应用中很多后台任务更适合使用 Taskasync/await
4.17.3 Unity 中的注意事项
大部分 Unity API 只能在主线程调用,例如:
  • 创建或销毁 GameObject
  • 修改 Transform
  • 操作多数场景对象和 UI。
后台线程可以进行纯数据计算、文件处理或网络数据解析,但结果通常要切回主线程再更新 Unity 对象。

4.18 预处理指令

notion image
notion image
预处理指令在编译阶段控制代码。
4.18.1 条件编译
4.18.2 定义符号
#define 必须放在文件中普通代码之前。大型项目通常通过项目设置或构建配置定义符号,而不是在每个文件中手写。
4.18.3 警告和错误
4.18.4 警告控制
不应长期全局关闭警告。应明确知道为什么忽略,并尽可能缩小范围。

4.19 程序集、元数据与反射

程序集通常是编译后的 .dll.exe,其中包含:
  • 中间代码;
  • 类型信息;
  • 程序集引用;
  • 清单和版本信息;
  • 资源等。
反射允许程序在运行时读取类型信息并操作成员。
4.19.1 获取 Type
4.19.2 查看类型信息
获取成员:
需要导入:
4.19.3 创建对象
如果构造函数需要参数:
4.19.4 调用方法
反射适合:
  • 编辑器工具;
  • 序列化框架;
  • 依赖注入;
  • 插件加载;
  • 自动注册;
  • 测试工具。
反射缺少普通调用的编译期检查,并有额外开销。能直接调用时,不应为了“高级”而使用反射。

4.20 特性(Attribute)

特性用于给程序集、类型、成员或参数附加元数据。
内置示例:
自定义特性:
使用:
通过反射读取:
Unity 中常见的 [SerializeField][Header][Range] 也属于特性。它们提供元数据,由 Unity 编辑器或序列化系统读取。

4.21 迭代器与 yield return

迭代器用于按顺序产生一组元素,而不必一次性构造完整集合。
使用:
yield return 每次返回一个元素,并保留当前执行位置。下一次请求元素时继续执行。
提前结束:
特点:
  • 代码比手写枚举器简单;
  • 支持延迟执行;
  • 适合逐步生成数据;
  • 枚举多次时,方法逻辑通常也会执行多次。

4.22 常用特殊语法

4.22.1 var
var 仍然是静态类型,不等于“任何类型”。编译器必须能够在声明时推断具体类型。
适合类型明显或类型名很长的情况,不应让代码变得难以理解。
4.22.2 匿名类型
匿名类型常用于方法内部的临时组合数据,不能作为普通公开 API 的返回类型直接暴露。
4.22.3 可空值类型
简写:
4.22.4 空条件运算符
任意一层为 null,整个表达式返回 null,不会继续访问后面的成员。
4.22.5 空合并运算符
4.22.6 空合并赋值
只有 _inventorynull 时才创建列表。
4.22.7 字符串插值
格式化数值:
4.22.8 模式匹配
模式匹配可以同时完成类型判断和变量声明,通常比先 is 再强制转换更清楚。

4.23 值类型与引用类型进一步理解

4.23.1 参数默认仍是按值传递
无论值类型还是引用类型,普通参数都是按值传递。
区别在于:
  • 值类型变量保存数据本身,复制的是数据;
  • 引用类型变量保存对象引用,复制的是引用值。
因此方法可以通过复制后的引用修改同一个对象,但不能直接让调用方变量改为引用另一个对象。
4.23.2 浅复制
复制引用类型字段时,可能仍共享内部对象。
即使创建了新的 Team 外壳,如果内部 Members 仍引用同一个列表,修改列表会相互影响。需要独立对象时,应明确创建内部集合的副本。
4.23.3 字符串的特殊表现
字符串是引用类型,但不可变,因此在赋值和修改时经常表现得像值类型:
不是因为字符串变成了值类型,而是因为拼接生成了新字符串。
4.23.4 相等比较
对于自定义引用类型,== 默认通常比较引用身份;字符串重载了相等运算符,比较文本内容。
自定义值相等规则时,应统一考虑:
  • Equals
  • GetHashCode
  • ==!=(确实有需要时)。

4.24 进阶阶段综合示例:事件驱动的战斗对象

使用:
这个示例综合使用了:
  • 接口;
  • 类和属性;
  • 泛型集合;
  • 事件;
  • 委托;
  • Lambda 可替代的订阅逻辑;
  • 迭代器;
  • 订阅生命周期管理。

5. 运行时、内存与工程补充

本章从「C#、CLR 与强类型」「const 与 readonly」和「var、dynamic、is 与 as」等方面说明运行时、内存与工程补充。

5.1 C#、CLR 与强类型

C# 是静态类型语言:编译器会在编译阶段检查大部分类型关系。dynamic 可以推迟某些成员访问和重载解析,但它本身仍是 C# 类型系统中的一种静态类型,不能据此把 C# 称为弱类型语言。
在普通 .NET 程序中,可以把执行过程概括为:
CLR(Common Language Runtime)负责类型加载、异常处理、垃圾回收和代码执行等运行时工作。.NET 是平台与类库体系,CLR 是其中的运行时组成部分,C# 则是可以面向 .NET 编写程序的语言之一。
Unity 项目同样使用 C#,但具体后端可能是 Mono 或 IL2CPP,支持的 API、语言版本和运行时行为要以当前 Unity 版本与项目配置为准,不能把普通 .NET 的所有实现细节直接套到 Unity。

5.2 constreadonly

两者的主要区别:
  • const 是编译期常量,只能在声明时使用常量表达式初始化;
  • readonly 字段可以在声明处或所属类型的构造函数中赋值;
  • const 字段隐含静态语义,不能再加 static
  • readonly 可以保存运行时才确定的值,也可以写成 static readonly
  • readonly 引用类型字段只保证字段不能改为引用另一个对象,不保证该对象内部状态不可变。
公共库中的 public const 值会被编译器传播到使用方代码。若这个值将来可能变化,通常更适合使用 static readonly,否则使用方可能需要重新编译才能看到新值。

5.3 vardynamicisas

本节从「var」「dynamic」和「is 与模式匹配」等方面说明 var、dynamic、is 与 as。
5.3.1 var
var 由编译器根据右侧表达式推断出确定类型,变量之后仍受静态类型检查:
var 不是“任意类型”,也不能在运行过程中随意改变为不兼容类型。
5.3.2 dynamic
dynamic 会把成员检查和部分重载解析推迟到运行时:
如果运行时对象不支持相应操作,错误会在运行时暴露。它适合 COM、动态语言互操作或确实需要动态绑定的边界,不应为了少写类型而在普通业务代码中大量使用。
5.3.3 is 与模式匹配
is 可以检查运行时类型,现代模式匹配还能同时声明变量并增加条件。
5.3.4 as
转换失败时,as 返回 null,而显式强制转换通常抛出异常。as 只适用于引用类型或可空值类型;如果转换失败本来就代表程序错误,应使用模式匹配或显式转换表达意图,而不是一律使用 as

5.4 栈、托管堆与对象生命周期

“值类型一定在栈上、引用类型一定在堆上”只是便于入门的近似说法,不是完整规则。
  • 局部变量可能位于栈、寄存器,或被优化掉;
  • 引用类型实例通常位于托管堆;
  • 值类型作为引用类型字段时,会成为该对象存储的一部分;
  • 值类型被装箱后,会在托管堆上创建对象;
  • 被闭包捕获的局部变量可能被提升到编译器生成的对象中;
  • 具体内存布局还会受到运行时、JIT/AOT 和优化方式影响。
更重要的是区分语义:值类型变量直接表示一个值,普通赋值会复制该值;引用类型变量保存对象引用,普通赋值复制的是引用,因此两个变量可能指向同一个对象。

5.5 集合关系、动态扩容与 IList<T>

数组长度在创建后固定。List<T> 在容量不足时会自动申请更大的内部数组并复制现有元素,因此:
  • 按索引访问通常是 O(1)
  • 尾部追加通常具有均摊 O(1) 复杂度;
  • 中间插入和删除通常需要移动后续元素;
  • 预先知道大致数量时可以设置 Capacity,减少重复扩容和复制。
扩容倍率属于具体实现细节,不应把某个版本的增长策略写成所有 .NET 或 Unity 版本都保证的规则。
IList<T> 是列表能力的接口,List<T> 是具体实现:
参数只需要索引、计数和增删能力时,可以依赖 IList<T>;调用方需要 List<T> 独有成员、明确性能特征或直接实例化时,则使用 List<T>。不要为了“面向接口”而隐藏实际需要的能力。

5.6 字符串拼接与临时分配

string 不可变。反复执行下面的代码通常会创建多个新字符串:
大量循环拼接更适合使用 StringBuilder
少量、一次性的字符串拼接不必机械替换。是否值得优化,应通过分配量、调用频率和性能分析工具判断。

5.7 浮点数精度

floatdouble 使用二进制浮点表示,很多十进制小数不能被精确表示:
不能把上例理解为“计算机算错了”,而是表示方式和舍入造成的结果。常见处理方式:
  • 浮点比较使用与业务尺度相匹配的容差;
  • 金额等十进制精度敏感场景考虑 decimal 或整数最小单位;
  • 游戏坐标、速度等通常仍使用 float,并使用 Unity 提供的近似比较或自定义误差范围;
  • 不要使用一个固定容差解决所有数量级的问题。
decimal 也不是无限精度,只是更适合许多十进制计算;它的性能、范围和与图形 API 的兼容性也需要结合场景判断。

5.8 标准 .NET GC 与 Unity GC

在现代标准 .NET CLR 中,GC 是自动内存管理器,托管堆按第 0、1、2 代组织,以便更频繁地回收短生命周期对象。对象是否可回收取决于它是否还能从 GC 根到达,而不是“变量被设为 null 就一定立刻回收”。
Unity 不能直接套用这段分代描述。当前 Unity 文档说明其使用 Boehm-Demers-Weiser GC,并支持增量回收模式,但不是分代式 GC。因此:
  • 写普通 .NET 知识时,可以讲第 0、1、2 代;
  • 写 Unity 性能时,应按 Unity 对应版本的 GC 文档说明;
  • 两者都应减少没有价值的高频临时分配,但具体诊断要使用各自的分析工具;
  • 不要凭感觉调用 GC.Collect(),普通情况下应由运行时决定回收时机。

5.9 IDisposable 与非托管资源

GC 负责回收托管对象占用的内存,但不了解如何关闭文件句柄、网络连接、窗口句柄或原生内存等资源。
拥有 IDisposable 资源的对象通常也应负责级联释放:
使用方可以写:
Dispose 的含义不是“手动让 GC 回收这个对象”,而是确定性释放其拥有的资源。只有直接持有非托管资源时才通常需要终结器,并优先考虑用 SafeHandle 包装原生句柄。

5.10 单例与工厂模式的使用边界

本节从「单例」和「工厂」两方面说明单例与工厂模式的使用边界。
5.10.1 单例
普通 .NET 中可以使用 Lazy<T> 实现线程安全的延迟初始化:
在 Unity 中,MonoBehaviour 不能通过普通构造函数和 new 创建,单例组件需要结合场景生命周期、重复对象处理和 DontDestroyOnLoad 设计。单例会引入全局状态和隐藏依赖,不应成为所有管理器的默认答案。
5.10.2 工厂
工厂用于集中创建规则,而不是简单把每个 new 包一层:
当创建过程复杂、需要根据配置选择实现,或调用方不应依赖具体类型时,工厂更有价值;类型很少且构造直接时,普通构造函数通常更清楚。
 

6. 总结

学习 C# 时,可以把知识分成三层:语言语法与类型系统、面向对象与常用类库、运行时与工程边界。前两层决定代码能否正确表达业务,后一层决定资源、性能和维护成本是否可控。
实践中应优先写出职责清晰、生命周期明确的代码,再根据实际性能数据选择集合、并发方式与优化手段。

7. 参考资料

  • bilibili 唐老狮 《C# 四部曲》