[深入解析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 类型的

数组协变机制:

img
img
img

可以 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

foreachstring可能会发生隐式转换,对于无法转为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. 类型形参与类型实参

img

img

2. 泛型类型和泛型方法的度

泛型类型和泛型方法可以声明多个类型形参,例如:

public class Dictionary<TKey, TValue>

泛型是泛型声明中类型形参的数量

img

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

img

但是可以写成这样:

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好用)

img

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:

img

补充:typeof常和反射结合使用,在编译阶段就能确定结果,因此typeof本身操作很快,但反射很慢

typeof返回Type类型的值,需要注意区分typeofGetType方法

特性 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;
        }
    }
}

在构造器中设置hasValuetrue,另外还利用了结构体隐含的无参构造器(hasValue的默认值为false

需要注意T约束为结构体类型

Nullable<T>结构体提供的额外方法和运算符:

  • GetValueOrDefault():获取结构体的值,如果hasValuefalse则返回默认值
  • GetValueOrDefault(T defaultValue):相比上一个,可以指定由实参返回默认值
  • TNullable<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中有的)或一个HasValuefalse的可空类型的值

当null用于可空值类型时,它表示HasValuefalse的可空类型的值,而不是null引用

所以下面的写法是等价的

int? x = new int?();
int? x = null;

对于以下两种写法则可以自由选择,UnityEngine.Object==进行了重载,和null比较会导致性能开销

if (x != null)
if (x.HasValue)

3. 转换

除了TNullable<T>间的转换,还支持链式转换

如果存在从sт的类型转换(例如从int转换到decimal),那么以下类型转换都是合法的:

  • Nullable<S> → Nullable<T>
  • S → Nullable<T>
  • Nullable<S> → T

这种通过填充null值扩展已有操作的过程叫作提升

4. 提升运算符

重载非可空类型T的运算符时,Nullable<T>也会重载相同的运算符,但操作数类型和返回值类型和非可空类型T有所区别,这被称为提升运算符

  • truefalse运算符不能被提升,但二者很少用,因此影响不大
  • 只有操作数是非可空值类型的运算符才能被提升
  • 对于一元运算符和二元运算符(等价运算符和关系运算符除外),原运算符的返回类型必须是非可空的值类型
  • 对于等价运算符和关系运算符,原运算符的返回类型必须是bool类型;
  • 作用于Nullable<bool>&|运算符具有单独定义的行为

img

5. 可空逻辑

条件逻辑运算符&&||不适用于Nullable<bool>类型

img

如何理解?

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

img

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 returnyield 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>
  • 必要的成员:CurrentMoveNext()Reset()Dispose()

这个隐藏类才是迭代器真正的“生成类型”,它在你的源代码中不可见(可通过反编译工具查看)。

生成类型的特点

  • 是一个 值类型struct)或 引用类型(取决于编译器实现,早期为 class,现在多为 struct 以降低开销)
  • 包含一个状态机(用 1012 …… 表示执行位置)
  • 保存 局部变量作为字段(因为方法会被多次暂停/恢复)
  • 实现 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);
            }
        }
    }
}

IEnumerableIEnumerator的区别:前者是可枚举集合的接口,后者是枚举器;一个是数据集合的抽象,一个是遍历过程的抽象。

有点像书和书签,一个书签的移动不会影响书和其他书签的状态。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的重要性

主要意义:资源释放、相同目的的迭代器可以链接使用

img

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 字段,这个状态机精确地恢复了你代码的执行逻辑。

  1. 状态字段 (<state>) : 像一个指针,1(未开始) → 0(运行) → 1(第一次yield return后),每次 yield return 后都会记录当前进度。
  2. 数据字段 : 机制将所有局部变量和参数都提升为类的字段。
  3. MoveNext() : 函数入口通过 switch 语句派发到一个代码 label,实现从上次离开的位置“恢复执行”。
  4. ‘Current’ 属性 : yield return 的值被暂存在 current 字段中,供下次遍历时读取。

💎 总结

理解 yield 作为语法糖的本质尤为重要:

  1. 延迟执行:迭代器代码本身并不会立即运行。只有在第一次调用 MoveNext() 或执行 foreach 循环时,状态机才会启动,真正实现“按需生成”。
  2. finally的必要性:机制确保 try/finally 能被正确处理。因为迭代可能在任何 yield return 后暂停,C# 编译器生成的代码会非常小心地确保 finally 块(常用于释放资源)在所有路径上都被执行。
  3. 行为与性能:机制虽然不是传统函数调用,但其状态切换和跳转带来了微小的性能开销。在高性能场景下需权衡使用。同时,机制保证了每次 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);
    }
}
posted @ 2026-07-28 18:31  绘星tsuki  阅读(1)  评论(0)    收藏  举报