[深入解析C#] 第2章:C#2
第 2 章:C#2
❗️2.1 泛型
类型安全:
类型安全是计算机编程中的一个概念。 在编程语言里,数据有不同的类型,像整数、浮点数、字符串等。类型安全主要是确保程序在运行过程中,对数据的操作是符合数据类型要求的。 比如,在一个类型安全的程序中,你不能把一个字符串当作数字来进行数学运算。如果编程语言是类型安全的,它会在编译阶段或者运行阶段检查出这种错误操作,避免程序出现不可预期的行为,像程序崩溃或者产生错误的计算结果。 强类型语言(如Java、C#)通常有比较严格的类型安全检查机制。以Java为例,如果你定义一个变量是整数类型,当你试图把一个文本内容赋值给这个变量时,编译器就会报错,提示类型不匹配。这有助于开发者尽早发现和纠正错误,提高程序的稳定性和可靠性。
泛型常用应用场景:
- 集合
- 委托(尤其是LINQ中的应用)
- 异步代码(Task
表示该方法将返回一个类型为T的值) - 可空值类型
- 等等等等
2.1.1 示例:泛型诞生前的集合(泛型为什么诞生,解决了什么问题)
.NET 1 的三大类集合
- 数组:语言和运行时直接支持数组。数组的大小在初始化时就已经确定
- 普通对象集合:API中的值(或者键)由 System.Object 描述(例如 ArrayList 和 Hashtable)
ArrayList 可以理解为List<System.Object>
同样,Hashtable的键值的类型都是object(System.Object的别名)
- 专用类型集合:API 中描述的值具有特定类型,集合只能用于该类型。例如 StringCollection 是保存字符串的集合,只能接收 String 类型的元素,不能接收 Object 类型的
数组协变机制:



可以 AI 了解一下数组协变与数组逆变
一开始是默认的数组形式创建集合,创建时需要指定数组大小,并且遇到更多数据的情况,需要重新创建数组来分配
以下没有用集合表达式简化数组添加元素,是为了模拟程序其他部分动态添加元素的场景
namespace LearnCSharp;
class Program
{
static void Main(string[] args)
{
Console.WriteLine("Hello, World!");
}
static string[] GenerateNames()
{
string[] names = new string[4]; // 创建数组时需要指定长度
names[0] = "Gamma";
names[1] = "Beta";
names[2] = "Alpha";
names[3] = "Delta";
return names;
}
static void PrintNames(string[] names)
{
foreach (string name in names)
Console.WriteLine(name);
}
}
问题:重复性操作有追踪当前集合大小、重新分配数组等
解决:使用ArrayList
using System.Collections;
namespace LearnCSharp;
class Program
{
static void Main(string[] args)
{
Console.WriteLine("Hello, World!");
}
static ArrayList GenerateNames()
{
ArrayList names = new ArrayList();
names.Add("Alice");
names.Add("Bob");
names.Add("Charlie");
return names;
}
static void PrintNames(ArrayList names)
{
// 提问:如果 ArrayList 包含一个非 string 元素会怎么样?
foreach (string name in names)
Console.WriteLine(name);
}
}
ArrayList实现了变长,但ArrayList.Add方法参数类型为Object
但foreach里string可能会发生隐式转换,对于无法转为string的对象会报错
为了解决这个类型问题,就有了StringCollection:
using System.Collections;
using System.Collections.Specialized;
namespace LearnCSharp;
class Program
{
static void Main(string[] args)
{
Console.WriteLine("Hello, World!");
}
static StringCollection GenerateNames()
{
StringCollection names = new StringCollection();
names.Add("Alice");
names.Add("Bob");
names.Add("Charlie");
return names;
}
static void PrintNames(StringCollection names)
{
foreach (string name in names)
Console.WriteLine(name);
}
}
但是对于其他广如烟海的类型们来说,为各自维护一个Collection的成本巨大,泛型因此而生
2.1.2 泛型降临
对于上面的例子,写法是List<T>
using System.Collections;
using System.Collections.Specialized;
namespace LearnCSharp;
class Program
{
static void Main(string[] args)
{
Console.WriteLine("Hello, World!");
}
static List<string> GenerateNames()
{
List<string> names = new List<string>();
names.Add("Alice");
names.Add("Bob");
names.Add("Charlie");
return names;
}
static void PrintNames(List<string> names)
{
foreach (string name in names)
{
Console.WriteLine(name);
}
}
}
- 变长:无须在创建时知道长度
- 类型安全:foreach对于非集合内的类型,编译器会报错
- 兼容类型:兼容所有类型,成本大幅降低
1. 类型形参与类型实参


2. 泛型类型和泛型方法的度
泛型类型和泛型方法可以声明多个类型形参,例如:
public class Dictionary<TKey, TValue>
泛型度是泛型声明中类型形参的数量

需要注意下面这种写法会报错

但是可以写成这样:
public void Method<T1, T2>() {}
// 其中T1,T2的类型实参可以相同
Dictionary<string, string>
2.1.3 泛型的适用范围
枚举型不能声明为泛型,而类、结构体、接口以及委托这些可以声明为泛型类型。
对于类型成员:
判断原则:看它是否引入了新的类型形参
方法和类型可以是泛型,但以下类型成员不能是泛型:
- 字段
- 属性
- 索引器
- 构造器
- 事件
- 终结器(析构函数)
2.1.4 方法类型实参的类型判断
对于编译器生成的 IL 代码来说,以下两种写法相同
首先有这样一个函数
public static List<T> CopyAtMost<T>(List<T> input, int maxElements)
两种写法:
List<int> numbers = new List<int>();
...
List<int> firstTwo = CopyAtMost<int>(numbers, 2);
List<int> numbers = new List<int>();
...
List<int> firstTwo = CopyAtMost(numbers, 2); // 编译器可以自行推断
类型推断只能用于方法,但可以简化泛型类实例的创建
补充:C# 元组
用于简单的数据集合,或者函数返回多个值(通常比out好用)

2.1.5 类型约束
在泛型类型或泛型方法中声明类型形参时,可以使用类型约束来限定哪些类型可以用作类型实参。(比如经典的unity单例模板类MonoSingleton,限制只能继承自MonoBehaviour)
示例:使用泛型约束打印items
using System.Collections;
using System.Collections.Specialized;
using System.Globalization;
namespace LearnCSharp;
class Program
{
static void Main(string[] args)
{
Console.WriteLine("Hello, World!");
}
static void PrintItems<T>(List<T> items) where T : IFormattable // 类型约束
{
// 包含本地化相关信息(日期格式,货币,计数等)
CultureInfo culture = CultureInfo.InvariantCulture; // 使用不变文化,确保输出格式一致(常用于数据存储确保一致性)
foreach (T item in items)
{
Console.WriteLine(item.ToString(null, culture));
}
}
}
类型约束除了接口外,还可约束以下类型
- 引用类型约束——
where T:class。类型实参必须是一个引用类型。(class这个关键字容易引起误解,它表示任何引用类型,包括所有接口和委托。) - 值类型约束——
where T:struct。类型实参必须是非可空值类型(结构体类型或枚举类型)。可空值类型不适用于本约束。 - 构造器约束——
where T:new()。类型实参必须是公共的无参构造器。该约束保证了可以通过new T(创建一个T类型的实例。 - 转换约束—
where T:SomeType。这里的SomeType可以是类、接口或者其他类型形参:
where T: Control
where T:IFormattable
where T1 : T2
类型约束还可组合使用,也可将类型形参作为类型约束本身
2.1.6 default运算符和typeof运算符
default运算符是一元运算符,操作数是类型名/类型形参,返回值是该类型的默认值(引用类型→null,非可空值类型→该类型的“0”值(0、0.0、0.0m、false、UTF16编码的0等),可空值类型→该类型的null)
示例:
default(T)
default(int)
default(string)
default(List<T>)
default(List<List<string>>)
default常与泛型一起使用
using System.Collections;
using System.Collections.Specialized;
using System.Globalization;
namespace LearnCSharp;
class Program
{
static void Main(string[] args)
{
Console.WriteLine("Hello, World!");
}
public T LastOrDefault<T>(IEnumerable<T> source)
{
T ret = default(T); // 声明一个局部变量,并将其初始化为类型参数 T 的默认值。
// 获取最后一个元素的值
foreach (T item in source)
{
ret = item;
}
return ret;
}
}
typeof:

补充:typeof常和反射结合使用,在编译阶段就能确定结果,因此typeof本身操作很快,但反射很慢
typeof返回Type类型的值,需要注意区分typeof和GetType方法
| 特性 | typeof(T) | obj.GetType() |
|---|---|---|
| 性质 | 运算符(Operator) | 方法(Method) |
| 操作对象 | 类型名 (如 int) |
对象实例 (如 x) |
| 发生时间 | 编译时 | 运行时 (动态) |
| 多态性 | 获取声明时的静态类型 | 获取对象实际的派生类型 |
using System.Collections;
using System.Collections.Specialized;
using System.Globalization;
namespace LearnCSharp;
class Program
{
static void Main(string[] args)
{
PrintType<string>(); // string作为类型实参
PrintType<int>(); // int作为类型实参
}
static void PrintType<T>()
{
Console.WriteLine($"typeof(T) = {typeof(T)}");
Console.WriteLine($"typeof(List<T>) = {typeof(List<T>)}");
}
}
运行结果
typeof(T) = System.String
typeof(List<T>) = System.Collections.Generic.List`1[System.String]
typeof(T) = System.Int32
typeof(List<T>) = System.Collections.Generic.List`1[System.Int32]
其中有使用反射时泛型类型的命名格式,List1表示一个List`的泛型类型,泛型度为 1(只有一个类型形参)
关于typeof(List<>):
只有typeof有,GetType无,指向泛型类型的定义,如typeof(Dictionary<,>)
2.1.7 泛型类型初始化与状态
List<int>和List<string>由同一个泛型定义构造,但使用时会被看作不同的类型
namespace Test;
class GenericCounter<T>
{
private static int value; // 每个封闭的、已构造类型对应一个字段
static GenericCounter()
{
Console.WriteLine("Initializing counter for {0}", typeof(T));
}
public static void Increment()
{
value++;
}
public static void Display()
{
Console.WriteLine("Counter for {0}: {1}", typeof(T), value);
}
}
class GenericCounterDemo
{
static void Main()
{
GenericCounter<string>.Increment(); // 触发 GenericCounter<string> 的初始化
GenericCounter<string>.Increment();
GenericCounter<string>.Display(); // 输出 Counter for System.String: 2
GenericCounter<int>.Display();
GenericCounter<int>.Increment(); // 触发 GenericCounter<int> 的初始化
GenericCounter<int>.Display(); // 输出 Counter for System.Int32: 1
}
}
输出:
Initializing counter for System.String
Counter for System.String: 2
Initializing counter for System.Int32
Counter for System.Int32: 0
Counter for System.Int32: 1
- GenericCounter
和GenericCounter 的值相互独立 - 静态构造器被执行了两次
2.2 可空值类型
2.2.1 目标:表达信息的缺失
和接口隔离原则(SOLID中的I)思想有相似之处,可空值就是为了解决:有时我们需要一种变量保存某种信息,但不是所有信息都需要时刻“在场”的情景
e.g.
- 客户订单中包含公司信息,但不是所有人都是以公司名义提交订单
- 个人信息中,不是所有人都有卒年信息
在C#1还没有引入表示null值方法时,用的是下面两种蹩脚的方法实现类似功能:
- 数据缺失时,采用预设值,比如最高价格用decimal.MaxValue
- 单独维护一个布尔值表示其他字段是否为实际值/默认值
可空值类型封装了第二种方式
2.2.2 CLR 和 framework 的支持:Nullable<T>结构体
Nullable<T>的早期版本:
public struct Nullable<T> where T : struct // 泛型结构体,其类型约束为非空值类型
{
private readonly T value;
private readonly bool hasValue;
public Nullable(T value) // 提供了值的构造器
{
this.value = value;
this.hasValue = true;
}
public bool HasValue // 用于检查是否有值的属性
{
get { return hasValue; }
}
// 访问值,如果值不存在则抛出异常
public T Value
{
get
{
if (!hasValue)
{
throw new InvalidOperationException();
}
return value;
}
}
}
在构造器中设置hasValue为true,另外还利用了结构体隐含的无参构造器(hasValue的默认值为false)
需要注意T约束为结构体类型
Nullable<T>结构体提供的额外方法和运算符:
GetValueOrDefault():获取结构体的值,如果hasValue为false则返回默认值GetValueOrDefault(T defaultValue):相比上一个,可以指定由实参返回默认值T到Nullable<T>的隐式转换Nullable<T>到T的显式转换
装箱行为
简介:
装箱:值类型 →object/该值类型所实现的任何接口类型。值被复制到堆上。
拆箱:引用类型 → 值类型,可能引发InvalidCastException。
Nullable<T>的装箱行为是根据HasValue的值确定的
- 为false:结果是 null 引用
- 为true:结果是“装箱T”对象的引用
2.2.3 语言层面支持
1. ?后缀
用于可空值类型命名的简化,以下几种写法完全等价
Nullable<int> x;
Nullable<Int32> x;
int? x;
Int32? x;
2. null字面量
用于简化值的表达,C#2中,null的含义表示:一个null引用(C#1中有的)或一个HasValue为false的可空类型的值
当null用于可空值类型时,它表示
HasValue为false的可空类型的值,而不是null引用
所以下面的写法是等价的
int? x = new int?();
int? x = null;
对于以下两种写法则可以自由选择,UnityEngine.Object对==进行了重载,和null比较会导致性能开销
if (x != null)
if (x.HasValue)
3. 转换
除了T和Nullable<T>间的转换,还支持链式转换
如果存在从s到т的类型转换(例如从int转换到decimal),那么以下类型转换都是合法的:
Nullable<S> → Nullable<T>S → Nullable<T>Nullable<S> → T
这种通过填充null值扩展已有操作的过程叫作提升
4. 提升运算符
重载非可空类型T的运算符时,Nullable<T>也会重载相同的运算符,但操作数类型和返回值类型和非可空类型T有所区别,这被称为提升运算符
true和false运算符不能被提升,但二者很少用,因此影响不大- 只有操作数是非可空值类型的运算符才能被提升
- 对于一元运算符和二元运算符(等价运算符和关系运算符除外),原运算符的返回类型必须是非可空的值类型
- 对于等价运算符和关系运算符,原运算符的返回类型必须是bool类型;
- 作用于
Nullable<bool>的&和|运算符具有单独定义的行为

5. 可空逻辑
条件逻辑运算符&&和||不适用于Nullable<bool>类型

如何理解?
可以将null当成一个变量,如果结果取决于该变量的值,则结果一定是null

6. as 运算符与可空值类型
as运算符的返回值为一个可空类型的值:当原始引用的类型为null或与目标类型不匹配时,返回null值,或者返回一个有意义的值,示例如下:
static void PrintValueAsInt32(object o)
{
int? nullable = o as int?;
Console.WriteLine(nullable.HasValue ? nullable.Value.ToString() : "null");
}
调用方
PrintValueAsInt32(5); // 打印结果5
PrintValueAsInt32("some string"); // 打印结果null
对可空类型使用
as运算符,性能较差,特别是与采用is运算符相比。
在C#7及以后,对大部分可空值类型采用模式匹配
7. 空合并运算符??
C#2引入了空合并运算符??,为变量提供一个默认值,是一个二元运算符
int? a = 5;
int b = 10;
int c = a ?? b;
还可以组合使用,如x ?? y ?? z,如果x为空,计算y,y还为空,继续计算z
C#6引入了空值条件运算符?.
2.3 简化委托的创建
2.3.1 方法组转换
方法组就是一个或多个同名方法(可能有不同重载),简而言之,当你只写方法名而不加括号和参数时,这个名称指代的就是一个“方法组”。
例如:
public delegate void EventHandler(object sender, EventArgs e)
private void HandleButtonClick(object sender, EventArgs e)
// 创建委托
EventHandler handler = new EventHandler(HandleButtonClick);
C# 2利用方法组简化了创建
EventHandler handler = HandleButtonClick;
button.Click += HandleButtonClick;
2.3.2 匿名方法
此部分简要介绍,因为后续C# 3的lambda更nb
void AddClickLogger(Control control, string message)
{
control.Click += delegate
{
Console.WriteLine("Control clicked: {0}", message);
}
}
匿名方法的强大之处在于闭包时,例如委托执行时这里的message已经不可访问,但编译器已经完成了相关代码的生成
2.3.3 委托的兼容性
C#1没有但C#2支持的例子:
public delegate void Printer(string message);
public void PrintAnything(object obj)
{
Console.WriteLine(obj);
}
public void ExeTest()
{
Printer p1 = new Printer(PrintAnything);
Printer p2 = PrintAnything; // 满足一致性转换
}
此外还可以用委托来创建另一个委托,但签名要兼容
2.4 迭代器
枚举接口代表了元素的序列。对序列的常规操作,无外乎生产和消费序列。无论是泛型接口还是非泛型接口,如果选择手动实现,无疑既繁复又易出错,因此C#2引入了一个新特性迭代器——来简化该过程。
在C#里迭代器和生成器是一个东西,但是如果硬要区分,迭代器偏“遍历”序列,生成器偏“生产”序列
2.4.1 迭代器简介
迭代器是包含迭代器块的方法或者属性。迭代器块可包含yield return或yield break,只能返回:
- IEnumerable
- IEnumerable
- IEnumerator
- IEnumerator
C# 迭代器的核心就是
yield return(以及yield break)。但“生成类型”需要分层次解释。
1. 迭代器方法返回的“表面类型”
迭代器方法必须返回以下类型之一:
IEnumerable<T>IEnumerator<T>IEnumerable(非泛型)IEnumerator(非泛型)例如:
public IEnumerable<int> GetNumbers() // 表面返回类型是 IEnumerable<int> { yield return 1; yield return 2; }2. 编译器生成的“真实类型”
当你在方法中使用
yield return后,编译器不会让这个方法按普通逻辑执行。它会自动生成一个隐藏的嵌套类(一个状态机),该类实现了:
- 你声明的返回接口(如
IEnumerable<int>、IEnumerator<int>)- 必要的成员:
Current、MoveNext()、Reset()、Dispose()等这个隐藏类才是迭代器真正的“生成类型”,它在你的源代码中不可见(可通过反编译工具查看)。
生成类型的特点
- 是一个 值类型(
struct)或 引用类型(取决于编译器实现,早期为 class,现在多为 struct 以降低开销)- 包含一个状态机(用
1、0、1、2…… 表示执行位置)- 保存 局部变量作为字段(因为方法会被多次暂停/恢复)
- 实现
IEnumerator<T>和IEnumerable<T>(如果是泛型)示例(伪内部结构):
private sealed class GetNumbers_d__0 : IEnumerable<int>, IEnumerator<int> { private int state; // 状态机当前状态 private int current; // 当前 yield 返回值 private int initialThreadId; public bool MoveNext() { /* 根据 state 跳转到不同代码块 */ } public int Current => current; // ... }
3. 与普通返回集合的区别
- 普通方法:
return new List<int> { 1, 2 };→ 直接返回一个具体的集合对象。- 迭代器方法:
yield return 1; yield return 2;→ 返回编译器生成的 迭代器类型,它实现了延迟执行(按需逐个生成元素)。
总结回答你的问题
C#迭代器的生成类型是什么,是指yield return吗?
yield return是语法标记,告诉编译器这个方法是一个迭代器。- 生成类型 是编译器自动创建的一个隐藏类(实现了
IEnumerable/IEnumerator及其泛型版本)。- 你作为程序员只需要关心方法声明的返回类型(如
IEnumerable<int>),背后复杂的迭代器状态机由编译器自动生成。所以,“迭代器的生成类型” 可以理解成:“编译器生成的那个具有状态机的中间类型”,而
yield return是启动这个生成过程的语法手段。
如果返回类型是非泛型接口,生成类型是object
如果是泛型接口,生成类型则是该泛型接口实参的类型(如IEnumerator<string>的生成类型是string)
2.4.2 延迟执行
延迟执行(延迟计算)的基本思想:只在需要获取计算结果的时候执行代码
namespace Test;
static class Program
{
static IEnumerable<int> CreateSimpleIterator()
{
yield return 10;
for (int i = 0; i < 3; i++)
{
yield return i;
}
yield return 20;
}
static void Main()
{
IEnumerable<int> enumerable = CreateSimpleIterator();
using (IEnumerator<int> enumerator = enumerable.GetEnumerator())
{
while (enumerator.MoveNext())
{
int value = enumerator.Current;
Console.WriteLine(value);
}
}
}
}
IEnumerable和IEnumerator的区别:前者是可枚举集合的接口,后者是枚举器;一个是数据集合的抽象,一个是遍历过程的抽象。
有点像书和书签,一个书签的移动不会影响书和其他书签的状态。unity常用的协程就是使用的IEnumerator
2.4.3 执行yield语句
在2.4.2的示例代码中,只有执行到MoveNext方法后,CreateSimpleIterator中的代码才会被真正执行
那什么时候终止呢?就是以下情况啦
- 抛出异常
- 方法执行完毕
- 遇到
yield break语句 - 执行到
yield return语句,迭代器准备返回值
需要注意迭代器中直接使用return是不被允许的
2.4.4 延迟执行的重要性
一个简单的例子是无限长的斐波那契数列,调用方可根据需要决定迭代次数
2.4.5 处理finally块
实际编码用using多一些,但using语句是基于finally块实现的,可以了解一下
static IEnumerable<string> Iterator()
{
try
{
Console.WriteLine("第一次迭代前");
yield return "第一次迭代";
Console.WriteLine("第二次迭代前");
yield return "第二次迭代";
Console.WriteLine("第二次迭代后");
}
finally
{
Console.WriteLine("在finally块中");
}
}
在try块中,执行到yield return会暂停执行,并不会每次都执行一次finally中的内容
static void Main()
{
foreach (string value in Iterator())
{
Console.WriteLine(value);
// 此处加break,迭代器的finally仍会执行一次,因为foreach隐含了一条using语句
}
}
2.4.6 处理finally的重要性
主要意义:资源释放、相同目的的迭代器可以链接使用

2.4.7 迭代器实现机制概览
一个迭代器示例
public static IEnumerable<int> GenerateIntegers(int count)
{
try
{
for (int i = 0; i < count; i++)
{
Console.WriteLine("Yielding {0}", i);
yield return i;
int doubled = i * 2;
Console.WriteLine("Yielding doubled value {0}", doubled);
yield return doubled;
}
}
finally
{
Console.WriteLine("In finally block");
}
}
包含了五个精心设计:
- 一个参数
- 一个需要在
yield return语句之间保留的局部变量 - 一个不需要在
yield return语句之间保留的局部变量 - 两条
yield return语句 - 一个
finally块
编译器为迭代器生成的代码结构(主体,省略了实现细节,进行了调整)
// 原方法声明签名的桩方法
public static IEnumerable<int> GenerateIntegers(int count)
{
GeneratedClass ret = new GeneratedClass(-2);
ret.count = count;
return ret;
}
// 状态机的生成类
private class GeneratedClass : IEnumerable<int>, IEnumerator<int>
{
public int count;
private int state;
private int current;
private int initialThreadId;
private int i;
public GeneratedClass(int state)
{
this.state = state;
initialThreadId = Environment.CurrentManagedThreadId;
}
public bool MoveNext() { /**/ } // 状态机的主体代码
public IEnumerator<int> GetEnumerator() { /**/ } // 根据需要创建新的状态机
public void Reset()
{
throw new NotSupportedException(); // 生成的迭代器不支持此操作
}
public void Dispose() { /**/ } // 根据需要执行finally块
public int Current
{
get { return current; }
}
private void Finally1(){ /**/}
IEnumerator Enumerable().GetEnumerator()
{
return GetEnumerator();
}
object IEnumerator.Current { get { return Current; }}
}
⚙️ 核心转换:从“代码块”到“状态机”
编译器会把你写的迭代器代码,彻底转变为一个实现了
IEnumerator<T>接口的“状态机”。
- 生成嵌套类:创建一个私有嵌套类(如
<GetNumbers>d__0),它同时实现了IEnumerable<T>和IEnumerator<T>,这意味着它既是可枚举的,又是枚举器本身。- 方法变工厂:你定义的迭代器方法本身,会被编译器改造成一个简单的“工厂方法”,只负责创建并返回这个状态机类的实例。
这个
MoveNext()方法是整个状态机的心脏,它控制着迭代的全部流程。
你编写的yield return方法会被编译成类似以下的逻辑:private sealed class <GenerateIntegers>d__0 : IEnumerable<int>, IEnumerator<int> { // 状态机内部使用的字段 private int state; // -1: 未开始, 0: 运行中, 1: 第一次暂停... private int current; // 存储当前 yield return 的值 private int count; // 捕获的方法参数 private int i; // 局部变量被提升为字段 public int Current { get { return current; } } public bool MoveNext() { switch (state) { case 0: state = -1; // 进入方法体 for (i = 0; i < count; i++) { // yield return i; current = i; state = 1; // 标记在第一次 yield 后暂停 return true; resume_1: // yield return i * 2; (续约此处) current = i * 2; state = 2; return true; } state = 3; return false; // ... 其他状态的跳转逻辑 } } // ... GetEnumerator() 等方法 }🧐 状态机如何一步步工作?
通过
state字段,这个状态机精确地恢复了你代码的执行逻辑。
- 状态字段 (
<state>) : 像一个指针,1(未开始) →0(运行) →1(第一次yield return后),每次yield return后都会记录当前进度。- 数据字段 : 机制将所有局部变量和参数都提升为类的字段。
MoveNext(): 函数入口通过switch语句派发到一个代码label,实现从上次离开的位置“恢复执行”。- ‘Current’ 属性 :
yield return的值被暂存在current字段中,供下次遍历时读取。💎 总结
理解
yield作为语法糖的本质尤为重要:
- 延迟执行:迭代器代码本身并不会立即运行。只有在第一次调用
MoveNext()或执行foreach循环时,状态机才会启动,真正实现“按需生成”。finally的必要性:机制确保try/finally能被正确处理。因为迭代可能在任何yield return后暂停,C# 编译器生成的代码会非常小心地确保finally块(常用于释放资源)在所有路径上都被执行。- 行为与性能:机制虽然不是传统函数调用,但其状态切换和跳转带来了微小的性能开销。在高性能场景下需权衡使用。同时,机制保证了每次
foreach循环都是全新迭代,状态相互隔离,线程安全无须担心。
2.5 一些小的特性
2.5.1 局部类型
涉及常见的partial关键字,将单个类/结构体/接口分开在多个源文件中声明;通常与代码生成器结合使用(例如qframework uikit中将design.cs与主体分离)
此外,如果声明的是泛型类型,那么其各部分声明的类型名和类型形参都必须相同。如果存在类型约束,那么这些类型约束也必须相同。如果声明的类型实现了多个接口,那么这些局部类型可以负责各自的接口实现,而且实现部分和相应接口可以不在同一声明中。
局部方法(C# 3)
局部方法可以将声明与实现分离
// File1.cs
partial class Generator
{
partial void OnBeforeYield(int value); // 声明
}
// File2.cs
partial class Generator
{
partial void OnBeforeYield(int value) // 实现
{
Console.WriteLine($"About to yield {value}");
}
}
可以由代码生成器生成可选的“钩子方法”,之后就可以用手动为其添加额外行为
1️⃣ “由代码生成器生成可选的‘钩子方法’”
代码生成器(比如 Visual Studio 自带的 T4 模板、源生成器,或者实体框架的 EDMX 生成器)会自动生成一部分骨架代码。在生成代码时,它会在某些特定位置预先写好一个分部方法的调用,并且把该方法声明为
partial。因为这个方法只有声明,没有实现(生成器只负责调用它,而不提供具体逻辑),所以对于使用者来说,这个钩子是“可选”的:你想用就自己实现,不想用就完全忽略。2️⃣ “之后就可以用手动为其添加额外行为”
如果程序员希望在这个钩子位置执行自定义操作(比如记录日志、验证数据、修改返回值等),他只需要在项目的另一个分部类文件(通常是手写的代码文件)中,为同一个分部方法提供方法体(实现)。这样,生成器代码中原本空荡荡的钩子就有了实际行为。
如果程序员没有手动提供实现,编译器在编译时就会彻底删除对该钩子的所有调用(不产生任何 IL 代码和运行时开销)。这正是分部方法“可选”特性的核心优势。
3️⃣ 为什么叫做“钩子方法”?
就像在老式电话总机上把线路“钩住”以插入自己的线路一样,钩子方法让程序员可以在生成器代码的固定流程中“挂上”自己的逻辑,而不需要修改生成器代码本身。这符合开闭原则(对扩展开放,对修改关闭)。
✅ 举个完整的例子
生成器生成的代码文件
AutoGen.cs:partial class DataProcessor { // 声明一个可选的钩子方法 partial void OnBeforeProcess(int value); public IEnumerable<string> ProcessItems(int[] items) { foreach (int v in items) { OnBeforeProcess(v); // 钩子调用点 yield return $"Item: {v}"; } } }程序员手动添加的
UserCode.cs:partial class DataProcessor { // 提供实现,钩子生效 partial void OnBeforeProcess(int value) { if (value < 0) throw new ArgumentException("Value cannot be negative"); Console.WriteLine($"Processing {value}"); } }如果程序员删掉
UserCode.cs,或者注释掉实现,那么OnBeforeProcess(v);这一行在编译后不复存在,就好像从来没写过一样。💎 总结
“可由代码生成器生成可选的钩子方法,然后用手动添加额外行为” = 生成器负责声明和调用,你负责决定是否实现。这种设计让自动生成的代码既保持纯净、高效,又留下了灵活定制的入口,是大型框架和代码生成工具的常用技巧。
2.5.2 静态类
静态类不能声明实例方法、属性、事件或构造器,但可以声明普通的嵌套类;常用于只包含静态方法的工具类等
扩展方法只能在非嵌套、非泛型的静态类中声明
2.5.3 属性的 getter/setter 访问分离
C# 1中只有单一的属性访问修饰符,也就是说public/private同时作用于getter和setter,想要为两者设置不同的访问权限是不支持的。C# 2就解决了这个问题。
常用的是public getter搭配private setter
2.5.4 命名空间别名
可以用来避免为保证命名唯一而导致的冗长类型名,在C# 1中就支持命名空间别名
using System;
// 引入空间别名
using WinForms = System.Windows.Forms;
using WebForms = System.Web.UI.WebControls;
class Test
{
static void Main()
{
// 通过别名进行区分
Console.WriteLine(typeof(WinForms.Button)); // 输出:System.Windows.Forms.Button
Console.WriteLine(typeof(WebForms.Button)); // 输出:System.Web.UI.WebControls.Button
}
}
C# 2进行了如下扩展
1. 命名空间别名限定符语法
相比上面的点写法,C#2使用一对冒号来表示冒号前的标识符是命名空间别名而非类型名,避免歧义
static void Main()
{
// 通过别名进行区分
Console.WriteLine(typeof(WinForms::Button)); // 输出:System.Windows.Forms.Button
Console.WriteLine(typeof(WebForms::Button)); // 输出:System.Web.UI.WebControls.Button
}
2. 全局命名空间别名
C# 2引入了global作为全局命名空间的一个别名,有两种用法:
- 指示全局命名空间中的类型
using System;
namespace MyApp
{
// 定义了一个同名的 Console 类
class Console
{
public static void Write(string msg) { }
}
class Program
{
static void Main()
{
// 若不使用 global::,编译器会认为你指的是当前命名空间里的 Console
Console.Write("Hello"); // 调用的是 MyApp.Console.Write
// 使用 global:: 则强制指向全局命名空间中的 System.Console
global::System.Console.WriteLine("Hello"); // 正确调用真正的 Console
}
}
}
- 用于类型完全限定名的一个“根”命名空间。像根目录
/和C:\一样,编写代码生成器(自动生成代码)时非常有用
// 假设有一个顶级类型定义在全局空间中(无任何命名空间)
public class MyGlobalClass { }
namespace Outer.Inner
{
class Test
{
static void Main()
{
// 引用全局命名空间中定义的类型
global::MyGlobalClass obj = new global::MyGlobalClass();
// 同样适用于 .NET 内置类型
global::System.Int32 number = 42;
}
}
}
3. 外部别名
使用较少,通常看作权宜之计,而且在一开始就有更好的命名冲突解决方案。例如设计更具体而非通用/扁平的命名(作为库作者),修改配置文件(作为使用者)…
问题:跨程序集的同命名空间下的同类型名该如何处理?
C# 2引入了外部别名处理:
首先需要在.csproj中设置别名
<ItemGroup>
<Reference Include="LibV1">
<Aliases>FirstAlias</Aliases>
</Reference>
<Reference Include="LibV2">
<Aliases>SecondAlias</Aliases>
</Reference>
</ItemGroup>
然后代码顶部需要这两个语句
extern alias FirstAlias;
extern alias SecondAlias;
完整示例:
namespace MyLib {
public class Greeter {
public void Say() => Console.WriteLine("Version 1");
}
}
namespace MyLib {
public class Greeter {
public void Say() => Console.WriteLine("Version 2");
}
}
extern alias FirstAlias;
extern alias SecondAlias;
using System;
class Program
{
static void Main()
{
var v1 = new FirstAlias::MyLib.Greeter();
var v2 = new SecondAlias::MyLib.Greeter();
v1.Say(); // 输出: Version 1
v2.Say(); // 输出: Version 2
}
}
2.5.5 编译指令
和C++的预处理语法相比,剥离了文本替换,更简洁可控
#pragma warning disable CS0219
private int variable = CallSomeMethod();
#pragma warning restore CS0219
2.5.6 固定大小的缓冲区
产品代码中很少使用
固定大小的缓冲区只能用于非安全的代码且是结构体内部,负责在结构体内部分配一块固定大小的内存,使用fixed修饰符
unsafe struct VersionedData
{
public int Major;
public int Minor;
public fixed byte Data[16];
}
unsafe static void Main()
{
VersionedData versioned = new VersionedData();
versioned.Major = 2;
versioned.Minor = 1;
versioned.Data[10] = 20;
}
以上结构体可能是24字节(两个int共8字节,加上16个byte)/32字节(运行时以8字节为单位对齐数据)
C# 7.3关于访问大小固定缓冲区字段的改进
假设versioned变量不是局部变量,而是一个类的字段,在C#7.3之前,需要通过fixed语句来创建一个指针才能访问versioned.Data;到了C#7.3以后,就可以通过字段直接访问该缓冲区了,不过仍限于非安全的上下文中。
2.5.7 InternalsVisibleTo
我去终于不用写反射来测私有方法惹
C#规范没有该特性,属于framework和运行时的范畴。这是一个attribute,包含一个参数,用于指定另一个程序集。由该参数指定的程序集,可以访问当前程序集中包含该attribute的内部成员。
[assembly:InternalsVisibleTo("MyProduct.Test")]
有时还需要指定公钥
[assembly:InternalsVisibleTo("NodaTime.Test,PublicKey=0024...4669")]
适用场景:
- 允许测试程序集访问内部成员,以此简化测试工作(需要和测试界的准则:只测试公共API权衡)
- 允许私有工具类访问内部成员,以此避免代码复制
// 文件:Runtime/MyService.cs
namespace MyGame
{
public class MyService
{
// 原本是 private,改为 internal 以便测试
internal int Add(int a, int b)
{
return a + b;
}
}
}
// 文件:Runtime/AssemblyInfo.cs
using System.Runtime.CompilerServices;
// 核心:将 internal 成员的访问权限授予指定的测试程序集
[assembly: InternalsVisibleTo("MyGame.Tests")]
// 文件:Tests/MyServiceTests.cs
using NUnit.Framework;
using MyGame; // 引入被测试的命名空间
public class MyServiceTests
{
[Test]
public void Add_WhenCalled_ReturnsSum()
{
// Arrange
var service = new MyService();
// Act
int result = service.Add(2, 3); // ✅ 此时可以正常访问 internal 方法
// Assert
Assert.AreEqual(5, result);
}
}

浙公网安备 33010602011771号