Lesson 16: C# 12 — ref readonly 参数 + 内联数组 Inline Arrays

补全参数传递的语义三角 + 安全代码里的固定大小缓冲区

前置:已完成 Lesson 14(Primary Constructors)Lesson 15(Collection Expressions);理解 Span/ReadOnlySpan
阅读:ref readonly 修饰符 · 内联数组

第一部分:ref readonly 参数

一、语义三角——ref / ref readonly / in 各管什么

在 C# 12 之前,引用传递只有两个选项:ref(可写引用)和 in(只读引用,可能复制)。中间缺了一格——"我需要原始内存位置,但不会修改它"

ref

可写引用。必须传变量。方法可以修改值。调用方必须写 ref

Swap、TryGet、Interlocked

ref readonly C# 12

只读引用,保证指向原地址。必须传变量。方法不能修改值。调用方必须写 ref

互操作、大 struct 只读检查、IsNullRef

in

只读引用,编译器可能复制到临时变量。可传字面量/表达式。调用方可省略 in

大 struct 传参优化

refref readonly in
必须传变量❌ 值/表达式均可
方法可修改
保证指向原地址❌ 编译器可能复制
调用方写修饰符必须写 ref必须写 ref可省略

二、in 不够用的场景

// 场景 1:需要内存地址的方法——in 不能保证
bool IsNull<T>(ref readonly T value) => Unsafe.IsNullRef(ref value);
// ↑ 如果 value 是 in,编译器可能复制到一个临时变量——IsNullRef 就判断错了

// 场景 2:更新旧 API——ref 改成 ref readonly 不是 Breaking Change
// 旧:void Process(ref LargeStruct data) { /* 只读 */ }
// 新:void Process(ref readonly LargeStruct data) { /* 只读 */ }
// 调用方已经写了 ref,行为不变——但现在编译器会阻止方法内意外修改

// 场景 3:in 能传字面量——但你不需要这个灵活性
M(in 42);  // ✅——但 42 被复制到临时变量,不是原始位置
M(ref readonly 42);  // ❌ 编译错误——字面量没有地址

深入:in 的"可能复制"——防御性拷贝详解 Defensive Copy

根本原因:可变 struct 的方法可能修改自身

C# 的 struct 是值类型,但其实例方法可以修改自身。编译器看到 in 参数时面临一个矛盾:

编译器的保守策略:先复制一份,在副本上操作。这就是防御性拷贝Defensive Copy

什么操作会触发防御性拷贝?

场景struct 类型操作防御性拷贝?
直接字段访问可变p.X(字段)❌ 不复制
调属性 getter(未标 readonly)可变p.X(属性)✅ 复制
调实例方法(未标 readonly)可变p.Offset()✅ 复制
readonly 成员可变p.Distance❌ 不复制
任意操作readonly struct任何成员❌ 不复制
传字面量/表达式任意M(in 42)✅ 复制
把 in 传给另一个 in任意引用透传❌ 不复制

代码演示:何时复制、何时不复制

struct Point { public int X, Y; public int Sum => X + Y; }  // 可变 struct

void Example(in Point p)
{
    var a = p.X;          // ❌ 不复制——直接字段访问,编译器确定安全
    var b = p.Sum;        // ⚡ 复制!Sum 是 getter(属性),编译器不信任
}

// ———————————————————
readonly struct ReadOnlyPoint { public int X { get; } public int Y { get; } }

void Example2(in ReadOnlyPoint p)
{
    var a = p.X;          // ❌ 不复制——readonly struct 不会变,编译器完全信任
}

// ———————————————————
struct Mixed  // 可变 struct,但个别成员标了 readonly
{
    public int X { get => field; set => field = value; }
    public readonly double Length => Math.Sqrt(X * X);  // 承诺不修改
}

void Example3(in Mixed m)
{
    var a = m.X;          // ⚡ 复制!getter 没标 readonly
    var b = m.Length;     // ❌ 不复制——Length 标了 readonly
}

IL 层面:防御性拷贝长什么样?

// C#: void Bad(in Point p) { Console.WriteLine(p.X); }   ← X 是属性 getter
// 生成的 IL:
//   ldarg.1          加载 in 参数的地址
//   ldobj Point      从地址复制整个 struct 到栈 ← 防御性拷贝
//   stloc.0          存入临时变量
//   ldloca.s 0       加载临时变量的地址
//   call get_X()     在临时变量上调用 getter
⚠ 性能陷阱:按值传递只复制一次(调用时),但 in 传递可变 struct 可能每次成员访问都复制一次。循环中每次迭代都防御性拷贝 = 性能反而更差。
消除防御性拷贝的三种方式:
1. 把 struct 声明为 readonly struct(最彻底)
2. 把无副作用成员标上 readonly(C# 8.0+,逐个击破)
3. 只访问字段,不调方法/getter(限制大,不实用)

完整速查表见 in 防御性拷贝参考文档——含决策树、IL 对比、最佳实践。

三、调用方写 ref——不是 ref readonly

这是最反直觉的点:

void Print(ref readonly int x) => Console.WriteLine(x);

var v = 42;
Print(ref v);            // ✅ 调用方写 ref
Print(ref readonly v);    // ❌ 编译错误——调用方没有 ref readonly 语法
Print(v);               // ❌ 编译错误——必须写 ref
Print(ref 42);           // ⚠ 编译警告——字面量创建临时变量,ref 失去意义
为什么这样设计?ref readonly被调用方的承诺("我承诺不修改"),不是调用方要关心的新语法。调用方看到 ref 就知道"我在传引用"——这已经是最大的信息量了。方法是否修改那是方法的事。这和 in 的设计哲学不同——in 对调用方完全透明(可省略),因为它承诺的是"我不在乎是不是引用"。

第二部分:内联数组 Inline Arrays

四、问题——高性能缓冲区总要 unsafe

以前在 struct 里嵌入固定大小数组,只能用 unsafe fixed

// ❌ 旧世界:unsafe 是硬门槛
unsafe struct Buffer
{
    public fixed int items[10];  // 只能在 unsafe 上下文中使用
}
// 使用也必须 unsafe
unsafe { var x = buffer.items[0]; }

五、C# 12 的答案——[InlineArray] 属性

一个属性 + 一个 struct + 一个字段 = 固定大小的安全数组:

// ✅ C# 12:纯 safe code
[System.Runtime.CompilerServices.InlineArray(10)]
public struct Buffer10
{
    private int _element0;  // "哨兵字段"——编译器据此推断元素类型和布局
}

// 使用:和普通数组完全一样
var buf = new Buffer10();
for (int i = 0; i < 10; i++) buf[i] = i;  // 索引器
foreach (var v in buf) Console.WriteLine(v);   // foreach

// 关键:零开销转 Span
Span<int> span = buf;  // 隐式转换——struct 直接在内存中就是 Span 指向的区域

六、编译器做了什么?

// 你写:
[InlineArray(3)]
public struct Triple { private int _e0; }

// 编译器生成:
// 1. struct Triple 的大小 = 3 × sizeof(int) = 12 字节
// 2. 索引器:public ref int this[int index] { get { ... } }
// 3. GetEnumerator() → 支持 foreach
// 4. 隐式转换 → Span<int> / ReadOnlySpan<int>
//
// 本质上 = 把 unsafe fixed size buffer 包装成安全特性

七、vs stackalloc——什么时候用哪个?

stackalloc内联数组 [InlineArray]
安全上下文Span 接收可免 unsafe;否则需要✅ 纯 safe code
存储位置只能在栈上(局部变量)可以做 struct 字段——嵌入任何位置
是否可以传递只能通过 Span 传递(不能返回原始 stackalloc)整个 struct 可传、可返、可做泛型参数
典型用户应用开发者写临时缓冲区运行时/库作者暴露高性能 API
你会怎么遇到它?你不会自己声明 [InlineArray]——这是运行时团队的底层工具。但你写的 SearchValues.Create("abc"u8) 内部就在用它存储字节查找表。你的代码间接受益——更快的字符串搜索、更少的堆分配。

八、小测验

1/3 · ref readonly vs in——以下哪个场景必须用 ref readonly 而不能用 in?
2/3 · 调用 void M(ref readonly int x) 时,调用方怎么写?
3/3 · 关于内联数组,哪个说法正确?

九、下一步

💬 有问题?ref readonly 和 in 的边界仍然模糊?想知道自己有没有受益于内联数组?随时问。