[深入解析C#] 第 13 章:引用传递提升执行效率
💡 性能的提升通常会导致代码可读性变差,本篇内容很多特性都是为了性能提升而用的
❗️13.1 回顾:ref 关键字的基本原理
-
核心概念:
ref参数允许方法直接使用调用方提供的变量(存储位置),形参和实参是同一存储位置的别名,方法内对形参的修改会直接影响实参。 -
关键点:
-
变量模型:变量可理解为一个包含名称、编译时类型和当前值的容器(如“一张纸”)。

-
值传递 vs. 引用传递:
- 值参数:将实参的值复制到一个新的存储位置(形参),修改互不影响。
ref参数:形参和实参共享同一存储位置,就像给同一个变量起了多个别名,修改任何别名都会直接影响该存储位置的值。
-
引用类型与
ref的区别:- 对象引用是变量中存储的值(地址),通过值传递引用时,复制的是地址,两个变量指向同一对象。
ref参数是传递变量本身的别名,即使传递的是引用类型变量,修改的是变量中存储的引用值,而不是对象。
-
多个
ref指向同一变量:- 可以同时将同一个变量作为多个
ref参数传递(如IncrementAndDouble(ref x, ref x))。 - 所有形参和原始变量都是同一存储位置的别名,修改互相可见。
- 示例:
x初始为 5,p1++变为 6,p2 *= 2变为 12,最终x为 12。
- 可以同时将同一个变量作为多个
-
思维模型:将
ref参数视为目标变量的别名,而非副本,这是理解后续 C# 7ref局部变量和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 }

- 面试准备建议:
- 重要性:⭐⭐⭐(核心基础,必须清晰理解)
- 面试回答要点:
-
能清晰区分值传递、传递对象引用、引用传递(ref)三者:
- 值传递:复制值(栈上的全部数据)

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

-
能举例说明
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(新对象字段默认值)

- 面试准备建议:
- 重要性:⭐⭐(理解原理,用于高性能场景)
- 面试回答要点:
ref局部变量是 C# 7 引入的性能优化工具,为已存在的变量提供别名,避免对大型值类型的复制。- 使用时必须初始化,且不能逃逸当前方法的作用域。
- 适合操作数组中的大型结构体、修改字段时无需复制整个值。
- 区分
ref局部变量和普通引用类型变量:前者是对存储位置的别名,后者是对象引用的副本。 - Unity 中应用:遍历并修改大量
Vector3、Matrix4x4等结构体数组时,使用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 中,当需要修改
NativeArray、Vector3[]等大型数据集合中的元素时,利用ref return或ref索引器可避免频繁的值拷贝,提升性能。 - 能对比
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局部变量功能的补充提及,体现对语言细节的了解。
- C# 7.2 允许
📦13.2.4 ref readonly(C# 7.2)
-
核心概念:
ref readonly允许创建只读引用别名,用于安全高效地访问只读字段、数组元素等,避免值拷贝的同时防止意外修改。 -
关键点:
- 解决的问题:
- 对只读字段取别名以避免复制。
- 通过
ref提供只读访问,防止数据被篡改。
- 语法与使用:
- 可用于
ref局部变量和ref return:ref 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破坏只读性。
- IL 中
- 注意事项:
- 使用
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 中形参是附加了
IsReadOnlyAttribute的ref参数。 - 该 attribute 可能在程序集中自动生成,不依赖特定 .NET 版本。
- 不使用
modreq,旧版编译器会将其视为普通ref参数,但会因缺少in关键字而编译失败。
- IL 中形参是附加了
- 使用建议:
- 主要用于传递大型只读结构体(如
Matrix4x4、Vector3),避免昂贵的值拷贝。 - 普通小尺寸类型(如
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 引入的,用于按只读引用传递参数,避免大型结构体拷贝。- 对比
ref:ref可读可写,调用方必须显式;in只读,调用方可省略,编译器可能创建临时副本。 - 实际应用:在高性能计算、游戏开发中,用于传递
Matrix4x4、Vector3等大型值类型。 - 注意事项:小型类型(
int、float)使用in可能降低性能;隐式传递表达式会导致临时变量复制,可能抵消避免拷贝的收益。 - Unity 中若使用 Jobs/Burst 或频繁调用数学方法,传递大型
struct时合理使用in可减少内存拷贝,提升性能。
📦13.3.1 兼容性考量
- 核心概念:将值参数改为
in参数是源码兼容(无需修改调用代码即可编译),但不是二进制兼容(已编译程序集调用该方法会失败);ref改为in是二进制兼容,但源码不兼容。 - 关键点:
- 值参数 →
in参数:- 源码兼容:调用方代码可不加
in修饰符重新编译通过。 - 二进制不兼容:旧程序集(编译时方法接收值)无法调用新方法(签名变为
ref+ 只读特性),运行时失败。 - 若所有调用方可重新编译(如内部方法、项目内部代码),问题不大;公开发布的库需视为破坏性变更。
- 源码兼容:调用方代码可不加
ref参数 →in参数(仅用于避免拷贝而不修改参数):- 二进制兼容:IL 层面仍为
ref,但添加了只读特性。 - 源码不兼容:调用方代码原来必须带
ref,新签名要求in(或省略),旧调用代码会编译失败。
- 二进制兼容:IL 层面仍为
- 前提条件:改动不能破坏方法原有的语义;若方法内依赖
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。
- 若面试官问到
in与ref的区别,可补充这一点,展示对内存模型和安全性的深入理解。
📦13.3.3 使用 in 参数进行方法重载
- 核心概念:方法可以基于值参数和
in参数进行重载,但无法仅通过ref、out、in三者本身互相区分。调用时,不带in的调用优先选择值参数版本,显式带in的调用才选择in参数版本。 - 关键点:
-
CLR 层面不可区分
ref/out/in:in参数在 IL 层面就是带只读特性的ref参数,因此无法只靠ref、out、in的不同来形成合法的重载。
-
值参数与
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可能没有收益,甚至因传递地址而变慢。
- 必须通过实际性能评估(如 BenchmarkDotNet)确认存在瓶颈,且
- 安全隔离策略:
- 公共 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 struct和in参数,可获得安全且高性能的数据传递。 - 在 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 struct 与 IXmlSerializable 的冲突
-
核心概念:实现
IXmlSerializable接口的结构体,其ReadXml方法中通常需要对this赋值,这与readonly struct对this只读的要求冲突,导致此类结构体无法声明为readonly。 -
关键点:
- 冲突原因:
readonly struct中this被视为in参数(只读),不可赋值;但ReadXml方法通常需要this = parsedValue;来反序列化。 - 作者的三种选择:
- 接受性能损失:不使用
readonly,允许in参数/ref readonly的防御性复制。 - 移除 XML 序列化支持(可能作为主版本变更)。
- 使用非安全代码(如
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 允许扩展方法的第一个参数使用
ref或in修饰符,从而在保持扩展方法调用语法的同时,避免大型结构体的复制,或实现对原始值的原地修改。 -
关键点:
- 解决的问题:
- 普通扩展方法(值参数)每次调用都会复制结构体,大型结构体性能差。
- 传统的静态工具方法(接收
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 中,可为
Vector3、Color等内置结构体编写in/ref扩展方法,实现高性能的向量计算或颜色调整,避免频繁的值拷贝。
- C# 7.2 的
📦13.5.2 ref 和 in 扩展方法的使用限制
-
核心概念:
ref/in扩展方法只能扩展值类型。ref扩展方法可通过结构体约束的泛型类型参数实现,in扩展方法则不能以类型形参作为被扩展类型。这些限制源于避免引用类型的空引用风险以及性能考量。 -
关键点:
- 只能扩展值类型:
ref和in扩展方法的第一个参数(被扩展类型)必须是值类型(结构体或内置值类型)。- 引用类型(如
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不为null,ref扩展方法如果作用于引用类型,则可能违背这一假定。
- 性能:值类型拷贝开销大,
- 只能扩展值类型:
-
代码示例(合法与非法声明):
// 合法声明 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 中,为
Vector3、Quaternion等内置结构体编写in/ref扩展方法以优化性能时,自然会满足这些限制,实际影响不大。
13.6 类 ref 结构体(C# 7.2)
📦13.6.1 类 ref 结构体的规则
-
核心概念:
ref struct是一种只能存在于栈内存上的值类型,编译器通过一系列严格规则保证其不会被提升到堆上,从而为高性能、安全的内存操作(如Span<T>)提供基础。 -
关键点:
- 声明方式:
public ref struct RefLikeStruct { ... } - 核心目标:确保
ref struct实例永远只存在于栈内存,不能被移动到堆。 - 编译器强制的限制:
- 不可作为类/普通结构体的字段(除非另一个
ref struct的实例字段)。静态字段也不行(因为静态字段在堆上)。 - 不可装箱:任何装箱操作(如转换为
object、接口)都被禁止。 - 不可用作类型实参:不能用于泛型方法或泛型类型(因为泛型可能将其实例存储在堆上)。
- 不可创建数组:
ref struct类型的数组不能创建,也不能用于typeof。 - 不能被捕获到堆上的上下文中:
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 struct是Span<T>的底层机制,展示对现代 C# 内存管理的理解。
- 重要性:⭐(了解即可,理解其在
13.6.2 Span<T>和栈内存分配
-
核心概念:
Span<T>是一个ref struct,它提供了对连续内存的安全、高性能视图,自身不拥有内存,可以在栈上分配,消除不必要的堆内存分配和复制。配合stackalloc、string.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 struct或Span<T>的底层实现,能提到这种特性标记方式会是不错的亮点,但不属于常见考点。

浙公网安备 33010602011771号