AIGC标识 [深入解析C#] 第 13 章:引用传递提升执行效率

💡 性能的提升通常会导致代码可读性变差,本篇内容很多特性都是为了性能提升而用的

❗️13.1 回顾:ref 关键字的基本原理

  • 核心概念ref 参数允许方法直接使用调用方提供的变量(存储位置),形参和实参是同一存储位置的别名,方法内对形参的修改会直接影响实参。

  • 关键点

    • 变量模型:变量可理解为一个包含名称、编译时类型和当前值的容器(如“一张纸”)。

      img

    • 值传递 vs. 引用传递

      • 值参数:将实参的复制到一个新的存储位置(形参),修改互不影响。
      • ref 参数:形参和实参共享同一存储位置,就像给同一个变量起了多个别名,修改任何别名都会直接影响该存储位置的值。
    • 引用类型与 ref 的区别

      • 对象引用是变量中存储的值(地址),通过值传递引用时,复制的是地址,两个变量指向同一对象。
      • ref 参数是传递变量本身的别名,即使传递的是引用类型变量,修改的是变量中存储的引用值,而不是对象。
    • 多个 ref 指向同一变量

      • 可以同时将同一个变量作为多个 ref 参数传递(如 IncrementAndDouble(ref x, ref x))。
      • 所有形参和原始变量都是同一存储位置的别名,修改互相可见。
      • 示例:x 初始为 5,p1++ 变为 6,p2 *= 2 变为 12,最终 x 为 12。
    • 思维模型:将 ref 参数视为目标变量的别名,而非副本,这是理解后续 C# 7 ref 局部变量和 ref return 的基础。

  • 代码示例

    static void IncrementAndDouble(ref int p1, ref int p2)
    {
        p1++;
        p2 *= 2;
    }
    
    static void Main()
    {
        int x = 5;
        IncrementAndDouble(ref x, ref x); // x、p1、p2 是同一个存储位置的别名
        Console.WriteLine(x);             // 输出 12
    }
    

img

  • 面试准备建议
    • 重要性:⭐⭐⭐(核心基础,必须清晰理解)
    • 面试回答要点
      • 能清晰区分值传递、传递对象引用、引用传递(ref)三者:

        • 值传递:复制值(栈上的全部数据)

        img

        • 传递对象引用:值传递 + 引用类型(传递对象引用的副本)。修改对象属性/字段外部同步更改,修改指向的对象外部不会同步更改。
        • ref 传递:传递变量本身,形参是实参的别名。修改对象的值/所指向的对象,外部都会同步更改。

        img

      • 能举例说明 ref 参数如何直接修改调用方变量(如交换两个值:void Swap(ref int a, ref int b))。

      • 知道多个 ref 参数可以指向同一变量,这是别名概念的体现。

      • 理解这是 C# 7 引入 ref 局部变量和 ref return 的前置基础,是高性能场景中减少值拷贝的关键机制。

      • 在 Unity 中,ref 可用于通过方法直接修改结构体(如 Vector3)或值类型变量的值,避免装箱和复制。


13.2 ref 局部变量和 ref return

13.2.1 ref 局部变量

  • 核心概念ref 局部变量为现有变量创建别名,两者共享同一存储位置,修改任一都会直接影响另一者。它通过避免值拷贝来提升对大型结构体或数组元素的操作效率。

  • 关键点

    • 声明语法ref int alias = ref originalVariable;(声明和初始化都必须使用 ref 关键字)。
    • 共享存储位置
      • ref 局部变量和它引用的变量是同一个存储位置的别名,不是值的副本。
      • 修改任一者,另一者立即可见。
    • 可引用的目标
      • 局部变量(已确定赋值)、字段、数组元素、通过 ref return 返回的变量。
      • 不能引用只读字段(除非在构造函数内),因这会破坏只读约束。
    • 限制
      • 必须在声明时立即初始化,不能先声明后赋值(C# 7.3 允许后续重新指向新变量,但首次仍需初始化)。
      • 初始化时被引用的变量必须已确定赋值
      • 不能存在 ref 字段,ref 局部变量的生命周期不得超出当前方法(不能被 async 方法、匿名函数、局部函数捕获)。
      • 类型必须完全一致或存在一致性转换(identity conversion),不支持隐式转换(如不能将 int 转换为 long 的别名,也不能将 T? 可空类型直接作为别名,除非类型一致)。
    • 阻止垃圾回收ref 局部变量指向对象的字段或数组元素时,会阻止该对象/数组被垃圾回收,直到 ref 变量不再使用。
    • 性能考量:主要用于操作大型值类型(如 struct、数组元素)时避免昂贵的拷贝,游戏、实时系统等高性能场景尤其实用。

    💡 使用ref变量指向对象字段或者数组元素,会让垃圾回收器的工作变得更加复杂。垃圾回收器需要辨别该变量对应的对象,然后保留该对象。一般的对象引用比较简单,因为它们能直接判断出所引用的对象。对于对象而言,每增加一个指向其字段的ref变量,垃圾回收器所维护的数据结构就会增加一个内部指针。如果同时出现很多这种变量,代价就会随之高涨。好在ref变量只会出现在栈内存中,不大可能造成性能问题。

  • 代码示例

    // 基本别名示例
    int x = 10;
    ref int y = ref x;  // y 是 x 的别名
    y++;
    Console.WriteLine(x); // 11
    
    // 修改数组元素无拷贝
    var array = new (int x, int y)[10];
    for (int i = 0; i < array.Length; i++)
    {
        ref var element = ref array[i]; // 直接引用数组元素
        element.x++;
        element.y *= 2;
    }
    
    // 指向对象的字段(注意生命周期)
    var obj = new RefLocalField();
    ref int tmp = ref obj.value;
    tmp = 10;
    obj = new RefLocalField(); // tmp 仍指向旧对象的字段
    Console.WriteLine(tmp);    // 10(旧对象字段值)
    Console.WriteLine(obj.value); // 0(新对象字段默认值)
    

img

  • 面试准备建议
    • 重要性:⭐⭐(理解原理,用于高性能场景)
    • 面试回答要点
      • ref 局部变量是 C# 7 引入的性能优化工具,为已存在的变量提供别名,避免对大型值类型的复制。
      • 使用时必须初始化,且不能逃逸当前方法的作用域。
      • 适合操作数组中的大型结构体、修改字段时无需复制整个值。
      • 区分 ref 局部变量和普通引用类型变量:前者是对存储位置的别名,后者是对象引用的副本。
      • Unity 中应用:遍历并修改大量 Vector3Matrix4x4 等结构体数组时,使用 ref 局部变量可避免频繁复制,提升性能。

13.2.2 ref return

  • 核心概念:方法可以通过 ref 返回一个变量的引用(别名),而不是返回值的副本。调用方可选择用 ref 局部变量接收或直接操作该引用,从而直接修改原始数据,避免拷贝。

  • 关键点

    • 语法:返回类型前、return 语句后都需要 ref 关键字。例如:static ref int Method(ref int p) => ref p;
    • 别名语义ref 返回的是变量的存储位置,返回值与原始变量共享同一内存位置,修改任一者都反映到同一数据。
    • 安全规则(编译器强制):返回的引用必须在方法返回后仍然有效。因此:
      • ✅ 可以返回:ref 参数、out 参数、引用类型的字段、结构体的字段(当结构体变量是 ref/out 参数时)、数组元素。
      • ❌ 不可以返回:方法内部声明的局部变量(包括值类型参数)、方法内声明的结构体的字段。
    • 限制
      • async 方法和迭代器块(yield return)中不可使用 ref return
      • 不能将 ref 用于类型实参(如 Func<ref int> 非法),但可以用于委托声明:delegate ref int RefFuncInt32(); 合法。
    • 使用方式
      • 接收时通常用 ref int y = ref Method(...);,但也可以不声明 ref 局部变量,直接对返回的引用进行操作:Method(ref x)++;
      • 可链式调用:RefReturn(ref RefReturn(ref x))++;
    • 索引器中的 ref return:索引器可定义为返回 ref,从而直接引用并修改集合内部的元素,如 public ref int this[int index] => ref array[index];
  • 代码示例

    // 基本 ref return 与 ref 局部变量
    static ref int RefReturn(ref int p) => ref p;
    static void Main()
    {
        int x = 10;
        ref int y = ref RefReturn(ref x);
        y++;
        Console.WriteLine(x); // 11
    }
    
    // 直接对 ref return 结果操作
    int z = 10;
    RefReturn(ref z)++;       // z 变为 11
    
    // 索引器返回引用
    class ArrayHolder
    {
        private readonly int[] array = new int[10];
        public ref int this[int index] => ref array[index];
    }
    var holder = new ArrayHolder();
    ref int item = ref holder[0];
    item = 42; // 直接修改数组元素
    
  • 面试准备建议

    • 重要性:⭐⭐(中等,理解概念可用于高性能场景说明)
    • 面试回答要点
      • ref return 允许方法返回变量的引用,避免大型结构体的拷贝,常见于数组元素、字段的直接操作。
      • 编译器会保证返回的引用在方法结束后仍有效,不能返回局部变量。
      • ref 局部变量配合,可高效修改数据集合(如数组、自定义容器的元素)。
      • 在 Unity 中,当需要修改 NativeArrayVector3[] 等大型数据集合中的元素时,利用 ref returnref 索引器可避免频繁的值拷贝,提升性能。
      • 能对比 ref return 和普通返回值:前者返回存储位置别名,后者返回值的副本。

📦13.2.3 条件运算符 ?: 与 ref 值(C# 7.2)

  • 核心概念:C# 7.2 起,条件运算符 ?: 的第二和第三操作数均可为 ref 变量,整个表达式结果也可作为 ref 引用使用,从而根据条件直接引用不同的现有变量。

  • 关键点

    • ref 条件表达式
      • 语法:ref int counter = ref (condition) ? ref variableA : ref variableB;
      • 结果是一个引用,直接指向被选中的变量,而非其值的副本。
    • 典型应用:根据条件对两个不同变量之一进行原地修改,避免额外的 if-else 分支和重复代码。
    • 一致性增强:该特性弥补了 C# 7.0 中 ref 局部变量无法通过条件直接选择不同变量的缺口,使 ref 的运用更统一流畅。
    • 适用场景:需要根据条件选择并修改多个可变值类型变量中的一个(如元组字段、数组元素、结构体字段)。
  • 代码示例

    static (int even, int odd) CountEvenAndOdd(IEnumerable<int> values)
    {
        var result = (even: 0, odd: 0);
        foreach (var value in values)
        {
            // 根据 value 奇偶选择引用 result.even 或 result.odd
            ref int counter = ref (value & 1) == 0
                ? ref result.even : ref result.odd;
            counter++; // 直接修改被引用的字段
        }
        return result;
    }
    
  • 面试准备建议

    • 重要性:⭐(了解即可,非核心考点)
    • 面试回答要点
      • C# 7.2 允许 ?: 运算符直接返回 ref 引用,可基于条件选择两个现有变量之一的别名。
      • 主要用于需要根据条件选择并直接修改不同值类型变量的场景,简化代码。
      • 若面试中提到 ref 特性的演进,可将其作为 ref 局部变量功能的补充提及,体现对语言细节的了解。

📦13.2.4 ref readonly(C# 7.2)

  • 核心概念ref readonly 允许创建只读引用别名,用于安全高效地访问只读字段、数组元素等,避免值拷贝的同时防止意外修改。

  • 关键点

    • 解决的问题
      • 对只读字段取别名以避免复制。
      • 通过 ref 提供只读访问,防止数据被篡改。
    • 语法与使用
      • 可用于 ref 局部变量和 ref returnref readonly int alias = ref GetFieldAlias();
      • 接收 ref readonly 返回值的局部变量必须也声明为 ref readonly
      • 一旦定义为 ref readonly,就不能给变量赋新值;对于结构体,不能修改其字段或调用属性的 setter。
    • 只读视图模式
      • 可将内部数组或其他可变容器通过 ref readonly 索引器暴露,外部只能读取元素的只读引用,不可修改容器内数据。
      • 类似于 ReadOnlyCollection,但无额外分配和复制,对大型值类型优势明显。
    • 实现原理
      • IL 中 ref readonly 返回的方法被编译为普通 ref 返回,但附加了 InAttribute 特性,并通过 modreq 要求调用方编译器识别该特性。
      • 如果编译器不识别 InAttribute(如 C# 7.0),就不能调用该方法,防止通过可写 ref 破坏只读性。
    • 注意事项
      • 使用 ref readonly 不一定总能带来性能提升,有时可能适得其反(13.4 节将讨论),产品代码中需谨慎评估。
  • 代码示例

    // 1. 返回只读字段的别名
    static readonly int field = DateTime.UtcNow.Second;
    static ref readonly int GetFieldAlias() => ref field;
    
    static void Main()
    {
        ref readonly int local = ref GetFieldAlias();
        Console.WriteLine(local); // 只读访问,不可修改 local
    }
    
    // 2. 数组的只读视图(避免复制,不可修改)
    class ReadOnlyArrayView<T>
    {
        private readonly T[] values;
        public ReadOnlyArrayView(T[] values) => this.values = values;
        public ref readonly T this[int index] => ref values[index];
    }
    
    var array = new int[] { 10, 20, 30 };
    var view = new ReadOnlyArrayView<int>(array);
    ref readonly int element = ref view[0];  // 只读引用
    Console.WriteLine(element);              // 10
    array[0] = 100;                          // 原数组修改
    Console.WriteLine(element);              // 100,引用反映了修改
    
  • 面试准备建议

    • 重要性:⭐(了解即可,性能敏感场景才涉及)
    • 面试回答要点
      • ref readonly 是 C# 7.2 引入的特性,提供对变量的只读引用,避免拷贝,但禁止修改。
      • 常用于公开内部数据(如数组)的只读视图,或高效传递大型只读结构体。
      • 如果被问及与 ref 的区别,答 ref 可读可写,ref readonly 只能读;后者更安全,适合不希望调用者修改的场景。
      • 在 Unity 中,可以用于访问大型只读数据(如共享网格数据),既保持性能又防止误修改。
      • 注意提及编译器通过 InAttribute 确保跨程序集的安全性,了解这一实现细节会是加分项。

13.3 in参数(C# 7.2)

  • 核心概念in 修饰符使参数按引用只读传递,避免复制大型值类型,同时保证方法内不可修改该参数,调用方可选择是否显式标记 in

  • 关键点

    • in 参数的语义
      • 形参在方法内表现为 ref readonly,不能赋值或修改其成员(如果是结构体)。
      • 传递方式是按引用(别名),避免值拷贝,特别适合大型只读结构体。
    • 调用方式
      • 隐式引用传递:直接传变量或表达式,编译器可能创建隐藏的临时变量保存值,然后将该临时变量的引用传入。
      • 显式 in 传递PrintDateTime(in start); 要求实参必须是可直接按引用传递的变量(如局部变量、字段、数组元素);不能传递属性、方法返回值等。
      • 若实参不能直接引用(如 start.AddMinutes(1) 的返回值),使用 in 会导致编译错误,但隐式传递会创建临时副本并通过引用传递。
    • 编译器实现
      • IL 中形参是附加了 IsReadOnlyAttributeref 参数。
      • 该 attribute 可能在程序集中自动生成,不依赖特定 .NET 版本。
      • 不使用 modreq,旧版编译器会将其视为普通 ref 参数,但会因缺少 in 关键字而编译失败。
    • 使用建议
      • 主要用于传递大型只读结构体(如 Matrix4x4Vector3),避免昂贵的值拷贝。
      • 普通小尺寸类型(如 int)使用 in 可能适得其反(引用传递有额外开销)。
      • 隐式传递表达式时会产生隐藏临时变量,仍有一次拷贝。
  • 代码示例

    static void PrintDateTime(in DateTime value)
    {
        string text = value.ToString("yyyy-MM-dd'T'HH:mm:ss", CultureInfo.InvariantCulture);
        Console.WriteLine(text);
        // value = DateTime.Now; // 错误:不能修改 in 参数
    }
    
    static void Main()
    {
        DateTime start = DateTime.UtcNow;
        PrintDateTime(start);                   // 隐式引用传递(或复制后传引用)
        PrintDateTime(in start);                // 显式引用传递,start 必须是变量
        PrintDateTime(start.AddMinutes(1));     // 隐式,创建临时副本
        // PrintDateTime(in start.AddMinutes(1)); // 编译错误:返回值不能按引用传递
    }
    
  • 面试准备建议

    • 重要性:⭐⭐(中等,高性能场景重要)
    • 面试回答要点
      • in 参数是 C# 7.2 引入的,用于按只读引用传递参数,避免大型结构体拷贝。
      • 对比 refref 可读可写,调用方必须显式;in 只读,调用方可省略,编译器可能创建临时副本。
      • 实际应用:在高性能计算、游戏开发中,用于传递 Matrix4x4Vector3 等大型值类型。
      • 注意事项:小型类型(intfloat)使用 in 可能降低性能;隐式传递表达式会导致临时变量复制,可能抵消避免拷贝的收益。
      • Unity 中若使用 Jobs/Burst 或频繁调用数学方法,传递大型 struct 时合理使用 in 可减少内存拷贝,提升性能。

📦13.3.1 兼容性考量

  • 核心概念:将值参数改为 in 参数是源码兼容(无需修改调用代码即可编译),但不是二进制兼容(已编译程序集调用该方法会失败);ref 改为 in二进制兼容,但源码不兼容
  • 关键点
    • 值参数 → in 参数
      • 源码兼容:调用方代码可不加 in 修饰符重新编译通过。
      • 二进制不兼容:旧程序集(编译时方法接收值)无法调用新方法(签名变为 ref + 只读特性),运行时失败。
      • 若所有调用方可重新编译(如内部方法、项目内部代码),问题不大;公开发布的库需视为破坏性变更。
    • ref 参数 → in 参数(仅用于避免拷贝而不修改参数):
      • 二进制兼容:IL 层面仍为 ref,但添加了只读特性。
      • 源码不兼容:调用方代码原来必须带 ref,新签名要求 in(或省略),旧调用代码会编译失败。
    • 前提条件:改动不能破坏方法原有的语义;若方法内依赖 in 的只读保证来避免意外修改,将此前提与兼容性一并考虑。
    • 实践建议
      • 内部方法或源代码可控的项目:放心将值参数改为 in 以优化性能。
      • 公开发布的库(如 NuGet 包):将值参数改为 in 是破坏性二进制变更,需谨慎,可能需要版本迁移。
  • 面试准备建议
    • 重要性:⭐(了解即可,有助于理解库设计决策)
    • 面试回答要点
      • 能区分源码兼容、二进制兼容,并举例说明 in 参数改动的影响。
      • 理解为什么公开发布的库中修改参数传递方式需要特别小心。
      • 若被问到 C# 中向后兼容性问题,可以此为例展示对语言演进和二进制兼容的理解。
      • 在 Unity 中,若封装共享框架 DLL,修改公共 API 参数为 in 会导致已编译插件调用失败,需注意版本协调。

📦13.3.2 in 参数的不可变性陷阱:外部修改

  • 核心概念in 参数只是只读别名,编译器阻止方法内部修改,但无法阻止外部代码(如通过委托、其他线程)修改同一存储位置,导致参数值在方法执行中发生变化。

  • 关键点

    • 别名副作用in 参数与传入变量共享同一内存位置。若外部代码(如回调、其他线程)修改该变量,方法内读取 in 参数时能观察到变化。
    • 与值参数的对比
      • 值参数:创建实参的副本,方法内任何外部修改都不会影响该副本。
      • in 参数:仅是原变量的只读视图,原变量改变时 in 参数随之改变。
    • 不可仅凭“只读”假设安全:即使方法本身不修改,也不能保证参数在方法执行期间不变化,这在涉及字段、共享状态、多线程时尤其危险。
    • 设计警示ref 显式提醒别名风险,而 in 看似“只读”易让人误以为值不变,应特别警惕。
  • 代码示例

    static void InParameter(in int p, Action action)
    {
        Console.WriteLine($"p (before) = {p}");
        action();   // 可能修改原始变量
        Console.WriteLine($"p (after) = {p}");
    }
    
    static void Main()
    {
        int x = 10;
        InParameter(x, () => x++);  // 输出 p=10 然后 p=11
    
        int y = 10;
        ValueParameter(y, () => y++); // 输出 p=10 然后 p=10
    }
    
  • 面试准备建议

    • 重要性:⭐(了解即可,避免错误依赖)
    • 面试回答要点
      • in 参数并不保证值不变,它只是阻止方法自身修改,外部修改仍然可见。
      • 这与值参数有本质区别,值参数是快照,in 是只读别名。
      • 多线程或回调场景中若忽略这一点,可能引入难以发现的 bug。
      • 若面试官问到 inref 的区别,可补充这一点,展示对内存模型和安全性的深入理解。

📦13.3.3 使用 in 参数进行方法重载

  • 核心概念:方法可以基于值参数和 in 参数进行重载,但无法仅通过 refoutin 三者本身互相区分。调用时,不带 in 的调用优先选择值参数版本,显式带 in 的调用才选择 in 参数版本。
  • 关键点
    • CLR 层面不可区分 ref/out/in

      • in 参数在 IL 层面就是带只读特性的 ref 参数,因此无法只靠 refoutin 的不同来形成合法的重载。
    • 值参数与 in 参数可重载

      • void Method(int x) { }
      • void Method(in int x) { }(合法)
    • 重载决议规则

      • 无修饰符的调用:优先级:值参数 > in 参数

        int x = 5;
        Method(5);      // 调用值参数版本
        Method(x);      // 调用值参数版本
        Method(in x);   // 调用 in 参数版本(必须显式带 in)
        
    • 设计意图:为现有值参数方法添加 in 参数的重载时,不会破坏原有的无修饰符调用代码,保障了向后兼容。

  • 面试准备建议
    • 重要性:⭐(了解即可,重载决议细节)
    • 面试回答要点
      • 若被问到“能否用 in 和普通参数重载”,答可以,并说明 CLR 限制(ref/out/in 三者不能互重载)。
      • 指出不带 in 的调用优先匹配值参数版本,只有显式添加 in 才走 in 参数版本。
      • 这体现了 C# 重载决议规则对调用方兼容性的保护。

13.3.4 in 参数的使用指导

  • 核心概念in 参数主要为了提升大型值类型传递的性能,但必须谨慎使用,优先保证代码正确性与可读性,并在测量确认性能瓶颈后再引入。

  • 关键点

    • 性能优化的前提
      • 必须通过实际性能评估(如 BenchmarkDotNet)确认存在瓶颈,且 in 能带来可观的提升。
      • 对小型结构体或引用类型使用 in 可能没有收益,甚至因传递地址而变慢。
    • 安全隔离策略
      • 公共 API 应使用普通值参数,以防止因外部修改导致的不可预期行为。
      • 内部私有方法可以使用 in 参数,此时调用方可控,能确保参数在方法执行期间不被意外修改。
    • 显式 in 修饰符
      • 建议在调用 in 参数的方法时,显式加上 in 修饰符(除非有意利用编译器的临时变量机制),以提高代码可读性和意图明确性。
    • 代码审查辅助:未来可能出现 Roslyn 分析器帮助检查和强制执行这些 in 参数的使用规范。
    • 避免误解:牢记 in 参数只是只读别名,其值仍可能因其他代码而改变,不能将其等同于不变的快照。
  • 代码示例(安全使用模式):

    // 公共接口:值参数,隔离外部修改
    public static double PublicMethod(LargeStruct first, LargeStruct second)
    {
        double firstResult = PrivateMethod(in first);
        double secondResult = PrivateMethod(in second);
        return firstResult + secondResult;
    }
    
    // 私有实现:in 参数避免拷贝
    private static double PrivateMethod(in LargeStruct input)
    {
        double scale = GetScale(in input);
        return (input.X + input.Y + input.Z) * scale;
    }
    
    private static double GetScale(in LargeStruct input) =>
        input.Weight * input.Score;
    
  • 面试准备建议

    • 重要性:⭐⭐(理解最佳实践,体现代码优化素养)
    • 面试回答要点
      • in 参数适用于大型只读结构体的高性能传递,但需要先用性能分析工具确认收益,避免盲目优化。
      • 在公共 API 中使用 in 有风险,因为无法保证调用期间变量不被外部修改,建议在可控的私有方法内使用。
      • 调用时尽量显式加上 in,除非需要编译器创建临时副本。
      • 能解释 in 参数并非保证值完全不变,而是限制方法本身不能修改,体现对语言特性的深入理解。

13.4 将结构体声明为只读(C# 7.2)

13.4.1 只读结构体(readonly struct,C# 7.2)

  • 核心概念:将结构体声明为 readonly,编译器会保证其所有实例成员都不会修改 this,从而在对只读变量(如 in 参数、readonly 字段)进行成员访问时,消除隐式的防御性复制,真正实现性能提升。

  • 关键点

    • 背景问题——隐式防御性复制
      • 当访问只读变量(readonly 字段、in 参数、ref readonly 局部变量)的成员时,编译器为了保护只读变量的值不被意外修改,会先将其整体复制到一个临时变量,再对临时变量调用成员。
      • 对于 in 参数,这会导致本来想避免拷贝的优化适得其反,在方法内部反而产生多次拷贝。
    • readonly struct 的解决方案
      • 在结构体声明前加上 readonly 修饰符:public readonly struct YearMonthDay { ... }
      • 编译器强制要求:
        • 所有实例字段必须是只读的。
        • 所有实例属性和方法必须保证不修改 this(编译器会自动检查;需要修改字段的方法必须显式标记为非只读,但 C# 7.2 主要通过自动属性等实现)。
      • 编译器确认该结构体的任何实例成员都不会修改其值后,就不再对只读变量的成员访问进行防御性复制,直接访问原地址,避免拷贝。
    • 性能收益:与 in 参数、ref readonly 配合,形成一套完整的高性能只读值类型传递方案,大幅减少大型结构体的拷贝。
    • 适用范围:仅影响值类型(结构体);引用类型的只读字段仅保证引用不变,不防御其内部数据修改,因此没有此问题。
  • 代码示例(避免防御性复制前后对比):

    // 普通结构体(可能产生防御性复制)
    public struct YearMonthDay { ... }
    
    // 只读结构体(消除防御性复制)
    public readonly struct YearMonthDay
    {
        public int Year { get; }
        public int Month { get; }
        public int Day { get; }
        public YearMonthDay(int year, int month, int day) =>
            (Year, Month, Day) = (year, month, day);
    }
    
    // 使用 in 参数传递只读结构体
    void Print(in YearMonthDay input)
    {
        // 编译器不会复制 input,直接访问原值
        Console.WriteLine($"{input.Year}-{input.Month}-{input.Day}");
    }
    
  • 面试准备建议

    • 重要性:⭐⭐(高性能场景重要,体现对C#内存模型的理解)
    • 面试回答要点
      • 能解释防御性复制问题:编译器为何在访问只读变量的结构体成员时需要先复制,以及这如何导致性能损失。
      • readonly struct 通过编译器保证所有成员只读,消除了防御性复制,使 in 参数等优化真正生效。
      • 典型应用:定义大型不可变值类型(如数学向量、颜色、日期)时,同时使用 readonly structin 参数,可获得安全且高性能的数据传递。
      • 在 Unity 中,自定义的大尺寸结构体(如封装复杂计算的参数)可声明为 readonly struct,在 Job 系统或 Burst 编译器优化中避免多余拷贝。

13.4.2 结构体的 readonly 修饰符(C# 7.2)

  • 核心概念:在结构体声明前添加 readonly 修饰符,编译器将保证整个结构体不可变,从而消除对只读变量(in 参数、readonly 字段等)的防御性复制,实现高效的只读引用传递。

  • 关键点

    • 解决的问题:普通结构体的只读变量在访问其成员时,编译器会进行防御性整体复制(防止潜在的修改)。readonly 修饰符告诉编译器该结构体是不可变的,因此可以安全地直接通过引用访问成员,无需复制。
    • 声明方式public readonly struct YearMonthDay { ... }
    • 约束条件
      • 所有实例字段和自动实现的实例属性必须是只读的(静态成员无此限制)。
      • 只能在构造器中为 this 赋值。在普通结构体成员中 this 视为 ref 参数,在只读结构体中 this 视为 in 参数。
    • 兼容性
      • 向现有结构体添加 readonly:不影响源码和二进制兼容性。
      • 从结构体移除 readonly:属于破坏性修改,因为之前依赖只读行为的已编译代码可能因后续修改而出错。
    • 收益:与 in 参数、ref readonly 局部变量完美配合,构建一套无需拷贝的安全高性能数据传递机制。
    • 限制:要求结构体本身必须是不可变的(所有实例字段只读)。如果结构体内部某些字段需要变化,则无法应用 readonly,此时可能需要重构设计。
  • 代码示例

    public readonly struct YearMonthDay
    {
        public int Year { get; }
        public int Month { get; }
        public int Day { get; }
    
        public YearMonthDay(int year, int month, int day) =>
            (Year, Month, Day) = (year, month, day);
    }
    
    // 使用 in 参数传递只读结构体,无防御性复制
    void Print(in YearMonthDay input)
    {
        // 直接读取成员,不会复制 input
        Console.WriteLine($"{input.Year}-{input.Month}-{input.Day}");
    }
    
  • 面试准备建议

    • 重要性:⭐⭐(理解原理用于高性能优化)
    • 面试回答要点
      • readonly struct 是 C# 7.2 引入的,用于创建完全不可变的值类型。
      • 能解释为什么需要它:消除 in 参数、readonly 字段等场景下编译器为保护只读值而产生的防御性拷贝。
      • 添加 readonly 是安全的(向后兼容),移除则是破坏性的。
      • 在 Unity 中,配合 in 参数和 ref readonly,可以高效传递大型不可变数据(如自定义数学类型、配置数据包),避免频繁的内存拷贝,提升性能。

📦13.4.3 readonly structIXmlSerializable 的冲突

  • 核心概念:实现 IXmlSerializable 接口的结构体,其 ReadXml 方法中通常需要对 this 赋值,这与 readonly structthis 只读的要求冲突,导致此类结构体无法声明为 readonly

  • 关键点

    • 冲突原因readonly structthis 被视为 in 参数(只读),不可赋值;但 ReadXml 方法通常需要 this = parsedValue; 来反序列化。
    • 作者的三种选择
      1. 接受性能损失:不使用 readonly,允许 in 参数/ref readonly 的防御性复制。
      2. 移除 XML 序列化支持(可能作为主版本变更)。
      3. 使用非安全代码(如 Unsafe.AsRef)强行突破只读限制。
    • 作者的最终决定:接受实现 IXmlSerializable 的结构体不能是 readonly 的事实。
    • 一般建议
      • 尽量将不可变结构体声明为 readonly,但需确认没有成员需要给 this 赋值。
      • 添加 readonly 是向后兼容的,但移除是破坏性修改,需谨慎评估。
    • 扩展问题:不只是 XML 序列化,任何需要在实例方法中整体替换 this 的接口(如某些反序列化或重置方法)都会与 readonly struct 冲突。
  • 代码示例(冲突代码):

    // 普通结构体实现 IXmlSerializable 常见模式
    void IXmlSerializable.ReadXml(XmlReader reader)
    {
        // ... 解析文本得到 result ...
        this = result; // 需要给 this 赋值
    }
    
    // 若结构体声明为 readonly struct,上述代码无法编译
    public readonly struct MyStruct : IXmlSerializable
    {
        void IXmlSerializable.ReadXml(XmlReader reader)
        {
            // this = ...; // 编译错误:无法给只读结构体的 this 赋值
        }
    }
    
  • 面试准备建议

    • 重要性:⭐(了解局限即可,非核心考点)
    • 面试回答要点
      • readonly struct 保证不可变,但限制了需要在实例方法中整体替换值的场景(如反序列化)。
      • 如果面试问到 readonly struct 的限制,可举 IXmlSerializable 或其他需要给 this 赋值的情况为例,展示对语言约束的理解。
      • 在 Unity 中,若自定义结构体需要实现与序列化相关的接口(如 ISerializationCallbackReceiver 或编辑器中的自定义序列化),需注意其是否会导致与 readonly 的冲突。

13.5 使用 ref 参数或者 in 参数的扩展方法(C# 7.2)

13.5.1 在扩展方法中使用 ref/in 参数来规避复制

  • 核心概念:C# 7.2 允许扩展方法的第一个参数使用 refin 修饰符,从而在保持扩展方法调用语法的同时,避免大型结构体的复制,或实现对原始值的原地修改。

  • 关键点

    • 解决的问题
      • 普通扩展方法(值参数)每次调用都会复制结构体,大型结构体性能差。
      • 传统的静态工具方法(接收 in 参数)虽避免复制,但调用语法不优雅。
      • ref/in 扩展方法结合了二者优点:既保持 vector.Magnitude() 的流畅语法,又避免拷贝。
    • in 扩展方法
      • 第一个参数声明为 in this Vector3D vec,表示按只读引用传递。
      • 用于计算新值,不修改原结构体。
      • 可在读写变量上调用,也可在只读变量上调用(与普通 in 参数一致)。
    • ref 扩展方法
      • 第一个参数声明为 ref this Vector3D orig,表示按可写引用传递。
      • 用于原地修改原结构体(如偏移、缩放、重置)。
      • 调用时不需要显式写 ref 关键字(与普通 ref 参数不同),编译器自动处理。
      • 不能在只读变量(如 ref readonly 局部变量)上调用,保护只读语义。
    • 限制ref/in 扩展方法的目标类型(第一个参数的类型)存在一定限制(后续会详述)。
  • 代码示例

    public readonly struct Vector3D
    {
        public double X { get; }
        public double Y { get; }
        public double Z { get; }
        public Vector3D(double x, double y, double z) => (X, Y, Z) = (x, y, z);
    }
    
    // in 扩展方法:只读引用,计算大小
    public static double Magnitude(in this Vector3D vec) =>
        Math.Sqrt(vec.X * vec.X + vec.Y * vec.Y + vec.Z * vec.Z);
    
    // ref 扩展方法:可写引用,原地偏移
    public static void OffsetBy(ref this Vector3D orig, in Vector3D off) =>
        orig = new Vector3D(orig.X + off.X, orig.Y + off.Y, orig.Z + off.Z);
    
    // 使用
    var vector = new Vector3D(1.5, 2.0, 3.0);
    var offset = new Vector3D(5.0, 2.5, -1.0);
    
    vector.OffsetBy(offset);               // 原地修改,无需 ref 关键字
    Console.WriteLine(vector.Magnitude());  // 调用 in 扩展方法
    
  • 面试准备建议

    • 重要性:⭐⭐(高性能场景实用,体现语言演进理解)
    • 面试回答要点
      • C# 7.2 的 in/ref 扩展方法在保持流畅调用语法的同时,避免大型结构体拷贝或实现原地修改。
      • 对比普通扩展方法(值拷贝)和静态工具方法(语法不优雅),新语法提供了更佳的性能和可读性平衡。
      • ref 扩展方法调用时不需要加 ref,但编译器保证其只能用于可写变量;in 扩展方法可用于可写和只读变量。
      • 在 Unity 中,可为 Vector3Color 等内置结构体编写 in/ref 扩展方法,实现高性能的向量计算或颜色调整,避免频繁的值拷贝。

📦13.5.2 refin 扩展方法的使用限制

  • 核心概念ref/in 扩展方法只能扩展值类型。ref 扩展方法可通过结构体约束的泛型类型参数实现,in 扩展方法则不能以类型形参作为被扩展类型。这些限制源于避免引用类型的空引用风险以及性能考量。

  • 关键点

    • 只能扩展值类型
      • refin 扩展方法的第一个参数(被扩展类型)必须是值类型(结构体或内置值类型)。
      • 引用类型(如 string、接口)不可用,因为引用类型传递引用(地址)本身就有,不存在值拷贝开销,且引用类型可能为 null,会破坏扩展方法调用时实例非空的约定。
    • 泛型限制
      • ref 扩展方法:允许泛型类型参数,但必须施加 struct 约束(如 where T : struct)。
      • in 扩展方法:被扩展类型本身不能是类型形参(即不能 in this T target),即使有 struct 约束也不合法。但方法本身可以是泛型,即可以接受额外的类型参数(如 Method<T>(this in Guid target, T other))。
      • 原因:目前没有只读结构体的类型约束(where T : readonly struct),in 要求被扩展类型必须是具体的值类型,以避免只读语义上的潜在问题。
    • 设计动机
      • 性能:值类型拷贝开销大,ref/in 可消除拷贝,引用类型无此问题。
      • 安全性:obj.Method() 的调用形式隐含 obj 不为 nullref 扩展方法如果作用于引用类型,则可能违背这一假定。
  • 代码示例(合法与非法声明):

    // 合法声明
    static void Method(this ref int target)                           // ref 扩展值类型
    static void Method<T>(this ref T target) where T : struct         // ref 扩展带 struct 约束的泛型
    static void Method<T>(this ref T target) where T : struct, IComparable<T>
    static void Method(this in int target)                            // in 扩展值类型
    static void Method(this in Guid target)
    static void Method<T>(this in Guid target, T other)               // in 扩展方法可以是泛型,但被扩展类型不能是类型形参
    
    // 非法声明
    // static void Method(this ref string target)                     // 引用类型
    // static void Method<T>(this ref T target) where T : IComparable<T> // 没有 struct 约束
    // static void Method(this in string target)                      // 引用类型
    // static void Method<T>(this in T target) where T : struct       // 被扩展类型不能是类型形参
    
  • 面试准备建议

    • 重要性:⭐(了解限制即可,非高频考点)
    • 面试回答要点
      • ref/in 扩展方法只能用于值类型,这是为了性能(避免拷贝)和语义安全(避免 null 调用)。
      • ref 扩展方法可通过泛型 + struct 约束实现通用;in 扩展方法不能以类型形参作为被扩展类型,因为缺少只读结构体的约束。
      • 能举例区分合法与非法声明,展示对 C# 语言细节的掌握。
      • 在 Unity 中,为 Vector3Quaternion 等内置结构体编写 in/ref 扩展方法以优化性能时,自然会满足这些限制,实际影响不大。

13.6 类 ref 结构体(C# 7.2)

📦13.6.1 类 ref 结构体的规则

  • 核心概念ref struct 是一种只能存在于栈内存上的值类型,编译器通过一系列严格规则保证其不会被提升到堆上,从而为高性能、安全的内存操作(如 Span<T>)提供基础。

  • 关键点

    • 声明方式public ref struct RefLikeStruct { ... }
    • 核心目标:确保 ref struct 实例永远只存在于栈内存,不能被移动到堆。
    • 编译器强制的限制
      1. 不可作为类/普通结构体的字段(除非另一个 ref struct 的实例字段)。静态字段也不行(因为静态字段在堆上)。
      2. 不可装箱:任何装箱操作(如转换为 object、接口)都被禁止。
      3. 不可用作类型实参:不能用于泛型方法或泛型类型(因为泛型可能将其实例存储在堆上)。
      4. 不可创建数组ref struct 类型的数组不能创建,也不能用于 typeof
      5. 不能被捕获到堆上的上下文中
        • async 方法中不能在 await 前后跨作用域使用。
        • 迭代器块(yield)中不能在 yield 前后跨作用域使用。
        • 不能被 lambda 表达式、匿名方法、局部函数、LINQ 查询捕获。
    • 设计意图:通过将数据严格限制在栈上,避免垃圾回收开销,并安全地引用堆外内存(如栈上分配的缓冲区、非托管内存),是 Span<T> 等高性能类型的基础。
    • 使用场景:普通开发人员很少需要自行声明 ref struct,但会频繁使用框架中内建的 ref struct(如 Span<T>ReadOnlySpan<T>)。
  • 代码示例(声明及限制示意):

    public ref struct RefLikeStruct
    {
        public int Value;
        public RefLikeStruct(int value) => Value = value;
    }
    
    // 限制示例(编译错误)
    // var list = new List<RefLikeStruct>();        // 不能作为类型实参
    // var array = new RefLikeStruct[10];           // 不能创建数组
    // object boxed = new RefLikeStruct(5);        // 不能装箱
    
  • 面试准备建议

    • 重要性:⭐(了解即可,理解其在 Span 中的作用)
    • 面试回答要点
      • ref struct 是只能存在于栈的值类型,用于安全实现高性能、低分配的内存操作。
      • 编译器禁止其装箱、作为泛型参数、被捕获到堆等,是为了保证其栈内存安全性。
      • 知道 Span<T>ReadOnlySpan<T> 就是 ref struct,它们的引入让 C# 能高效处理连续内存块,对游戏开发、网络处理、序列化等场景有重要意义。
      • 在 Unity 面试中,若涉及高性能优化或 Span<T> 相关讨论,能指出 ref structSpan<T> 的底层机制,展示对现代 C# 内存管理的理解。

13.6.2 Span<T>和栈内存分配

  • 核心概念Span<T> 是一个 ref struct,它提供了对连续内存的安全、高性能视图,自身不拥有内存,可以在栈上分配,消除不必要的堆内存分配和复制。配合 stackallocstring.Create 等,可实现接近零分配的数据处理。

  • 关键点

    • Span<T> 的设计目标:替代“数组 + offset + length”的碎片化内存操作,提供统一、高效的内存访问抽象。
    • 类型特性
      • ref struct,只能存在于栈上,不可装箱、不可作为类字段、不可用于异步上下文中。
      • 支持索引器、切分(Slice),可指向栈内存、数组、非托管内存等。
    • 栈内存分配 stackalloc
      • 可在栈上分配内存,无需 unsafe 代码,返回 Span<T>
      • C# 7.3 起支持初始化器:Span<int> span = stackalloc int[] { 1, 2, 3 };
    • string.Create 与零分配字符串构建
      • public static string Create<TState>(int length, TState state, SpanAction<char, TState> action)
      • 直接在字符串内存上通过 Span<char> 写入,避免临时缓冲区及复制。
      • 利用元组值类型传递状态,避免 lambda 捕获导致的堆分配。
    • 性能收益
      • 减少堆内存分配和垃圾回收压力。
      • 避免不必要的复制(尤其大型数据)。
      • JIT 可对 Span<T> 进行高度优化。
    • 扩展:基于模式的 fixed 语句(C# 7.3):
      • 只要类型提供 GetPinnableReference() 方法返回 ref T,即可使用 fixed 获取指针,便于与需要指针的互操作代码集成。
  • 代码示例

    // 1. 使用 stackalloc 和 Span<char> 生成随机字符串(无堆分配临时缓冲区)
    static string Generate(string alphabet, Random random, int length)
    {
        Span<char> chars = stackalloc char[length];
        for (int i = 0; i < length; i++)
            chars[i] = alphabet[random.Next(alphabet.Length)];
        return new string(chars);
    }
    
    // 2. 使用 string.Create 实现零额外分配随机字符串
    static string Generate(string alphabet, Random random, int length) =>
        string.Create(length, (alphabet, random), (span, state) =>
        {
            var (alpha, rnd) = state;
            for (int i = 0; i < span.Length; i++)
                span[i] = alpha[rnd.Next(alpha.Length)];
        });
    
    // 3. C# 7.3: stackalloc 初始化器
    Span<int> numbers = stackalloc int[] { 1, 5, 2, 6, 3 };
    
  • 面试准备建议

    • 重要性:⭐⭐(理解原理,可用于性能优化讨论)
    • 面试回答要点
      • Span<T>ref struct,提供了安全、高效的内存视图,不拥有内存,可指向栈、堆或非托管内存。
      • 它解决了传统数组和 ArraySegment<T> 的不足,能够在无需复制的情况下对任意连续内存进行切片和操作。
      • stackalloc 配合 Span<T> 可在栈上分配临时内存,完全避免堆分配。
      • string.Create 允许直接向字符串内存写入,消除构造字符串时的额外复制。
      • 在 Unity 中,虽然 Span<T> 尚未广泛集成(取决于 .NET 版本和平台支持),但理解它有助于编写高性能序列化、网络解析、数据压缩等底层代码,展示对现代 C# 性能特性的掌握。

📦13.6.3 类 ref 结构体的 IL 表示

  • 核心概念ref struct 在编译后由 IsRefLikeAttribute 特性标记,并借助特定的 ObsoleteAttribute 消息来让旧版编译器拒绝对其使用,从而保证内存安全规则不被破坏。
  • 关键点
    • IsRefLikeAttribute 标记
      • 编译器为 ref struct 添加 System.Runtime.CompilerServices.IsRefLikeAttribute 特性。
      • 如果目标框架未提供该特性,编译器会自动在程序集中生成。
    • 兼容性机制——ObsoleteAttribute 的妙用
      • 不使用 modreq(与 in 参数的 IsReadOnlyAttribute 不同),而是添加一个带有固定消息的 ObsoleteAttribute
      • 任何能识别 IsRefLikeAttribute 的编译器会忽略这个 ObsoleteAttribute,正常处理 ref struct
      • 不能识别 IsRefLikeAttribute 的旧版编译器(如早期 C# 版本)则会看到 Obsolete 类型,产生编译错误,从而阻止不安全的用法。
    • 设计意图:在不引入 modreq 强制升级工具链的前提下,通过过时特性巧妙地实现向后兼容与安全隔离。如果结构体确实被声明为过时,编译器仍会正常报告其过时状态。
  • 面试准备建议
    • 重要性:⭐(仅作为底层原理了解)
    • 面试回答要点
      • ref struct 在 IL 层面由 IsRefLikeAttribute 标记,让运行时不直接依赖该特性,而是由编译器保证其栈内存安全。
      • 使用 ObsoleteAttribute 做旧编译器兼容,体现了 C# 语言设计中对向后兼容的精细考量。
      • 面试中如果被问到 ref structSpan<T> 的底层实现,能提到这种特性标记方式会是不错的亮点,但不属于常见考点。
posted @ 2026-07-29 13:40  绘星tsuki  阅读(1)  评论(0)    收藏  举报