in 参数 · 防御性拷贝深度解析 Defensive Copy

为什么说 in 是"只读引用,可能复制"?编译器何时复制、何时不复制?

一、in 做了什么承诺——以及没做什么

in 参数修饰符承诺两件事:

承诺含义
✅ 方法不能修改参数值在方法体内对 in 参数赋值是编译错误
✅ 调用方可传任意表达式字面量、方法返回值、属性、表达式都行——不需要是变量
保证零拷贝编译器可能为了安全而创建防御性拷贝
保证指向原始内存地址拷贝后指向的是临时变量,不是原始位置
核心误解:很多开发者以为 in = "传引用所以快"。真相是:in 只承诺"方法不会改",不承诺"不复制"。编译器在某些情况下必须复制以确保安全。

二、核心概念:为什么需要防御性拷贝?

问题出在可变 structMutable Struct上。

C# 的 struct 是值类型,但其实例方法可以修改自身(对字段赋值、调用 setter 等)。编译器在看到以下代码时面临两难:

struct Point
{
    public int X, Y;
    public void Offset(int dx) => X += dx;  // 会修改自身!
}

void Process(in Point p)
{
    p.Offset(1);   // 如果允许直接在原始内存上调用 Offset……
}

如果编译器在原始 p 上调用 Offset(1),就会修改调用方传入的值——这违反了 in 的"只读"承诺。但编译器无法静态分析Offset 的方法体(它可能在另一个程序集中),所以它不能信任 Offset 不会改。

编译器的保守策略:当不能确定安全时,先复制一份再在副本上操作。这份复制就是防御性拷贝Defensive Copy。它在语义上保护了只读约定,但在性能上可能事与愿违。

三、决策树:编译器到底什么时候复制?

Q1:参数是不是一个"变量"(有确定地址的存储位置)?
← 不是(字面量、表达式、方法返回值、需要类型转换)→ ✅ 必然复制
← 是一个变量 → 进入 Q2
Q2:struct 是 readonly struct 吗?
← 是 → ❌ 编译器知道它不可变,不复制(调用任何方法都不复制)
← 不是(可变 struct)→ 进入 Q3
Q3:你做了什么操作?
← 只读字段(直接访问字段,不调方法/getter)→ ❌ 不复制
← 调用标记了 readonly 的成员(C# 8.0+)→ ❌ 不复制
← 把 in 参数传给另一个 in 参数 → ❌ 不复制(引用透传)
← 访问属性(getter 没标 readonly)→ ✅ 复制
← 调用实例方法(没标 readonly)→ ✅ 复制

四、逐场景代码演示

场景 1:字面量 / 表达式 → 必然复制

void Show(in int x) => Console.WriteLine(x);

Show(42);                  // ✅ 42 复制到临时变量——字面量没有地址
Show(DateTime.Now.Second); // ✅ 表达式结果复制到临时变量
short s = 1;
Show(in s);               // ✅ 复制——short→int 需要隐式转换,新临时变量

场景 2:可变 struct + 实例方法 → 复制

struct Counter { public int Value; public void Inc() => Value++; }

void Use(in Counter c)
{
    c.Inc();    // ⚡ 防御性拷贝!编译器创建临时 Counter,在其上调用 Inc()
                //    调用方的原始 c.Value 不变——Inc 修改的是副本
    Console.WriteLine(c.Value);  // ⚡ 又一次防御性拷贝!getter 没标 readonly
}

var c = new Counter { Value = 0 };
Use(in c);
Console.WriteLine(c.Value);  // 输出 0——原始值没变(Inc 改的是副本)

场景 3:可变 struct + 只读字段访问 → 不复制

struct Vector3 { public float X, Y, Z; }

float Dot(in Vector3 a, in Vector3 b)
    => a.X * b.X + a.Y * b.Y + a.Z * b.Z;  // ❌ 零拷贝——纯字段访问,编译器不担心

场景 4:readonly struct → 永不复制

readonly struct ReadOnlyPoint
{
    public int X { get; }  // auto-property getter 隐式 readonly
    public int Y { get; }
    public double Distance => Math.Sqrt(X*X + Y*Y);
}

void Process(in ReadOnlyPoint p)
{
    var d = p.Distance;  // ❌ 零拷贝——编译器知道整个 struct 不可变
    Console.WriteLine(p.X);  // ❌ 零拷贝
}

场景 5:readonly 成员(C# 8.0+)→ 不复制

struct HybridPoint  // 可变 struct
{
    private int _x, _y;
    public int X { get => _x; set => _x = value; }
    public int Y { get => _y; set => _y = value; }

    public readonly double Distance  // readonly 承诺"我不会改"
        => Math.Sqrt((double)_x * _x + (double)_y * _y);
}

void Process(in HybridPoint p)
{
    var d = p.Distance;  // ❌ 零拷贝——Distance 标了 readonly
    Console.WriteLine(p.X);  // ⚡ 复制!getter 没标 readonly,编译器不信任
}

五、IL 层面——防御性拷贝长什么样

以下 C# 代码编译为 IL:

有防御性拷贝(可变 struct + 实例方法)

// C#: void Bad(in Point p) { Console.WriteLine(p.X); }
// IL:
ldarg.1            // 加载 in 参数的地址
ldobj Point        // 从该地址复制整个值到求值栈 ← 这就是防御性拷贝
stloc.0            // 存储到临时局部变量
ldloca.s 0         // 加载临时变量的地址
call instance int32 Point::get_X()
call void Console::WriteLine(int32)

无防御性拷贝(readonly struct / 纯字段访问)

// C#: void Good(in ReadOnlyPoint p) { Console.WriteLine(p.X); }
// IL:
ldarg.1            // 加载 in 参数的地址
call instance int32 ReadOnlyPoint::get_X()
call void Console::WriteLine(int32)

关键区别:有防御性拷贝时多出 ldobj + stloc 对,这组指令完成了"读整个 struct → 写到临时变量"的复制过程。

六、性能影响——in 可能比按值传递更慢

反直觉的事实:对于可变 struct,使用 in 可能比直接按值传递更慢。原因:按值传递只复制一次(调用时),但 in 传可变 struct 可能每次成员访问都复制——在循环和热路径中这是灾难。
struct BigMutable { /* 很多字段 */ public int Sum => /* 复杂计算 */; }

// ❌ 坏:每次循环都防御性拷贝一次(访问 Sum getter)
int TotalLoop(in BigMutable[] items)
{
    int total = 0;
    foreach (ref readonly var item in items)
        total += item.Sum;  // ⚡ 每次迭代都复制!
    return total;
}

// ✅ 好:按值传递——只在调用时复制一次
int TotalLoop(BigMutable[] items) { /*……*/ }

七、速查表

参数类型你做的操作防御性拷贝?说明
可变 struct(普通 struct)访问字段❌ 不复制直接字段读取,编译器确定安全
调用 readonly 成员❌ 不复制C# 8.0+,成员标记了 readonly
访问属性 / getter(未标 readonly)✅ 复制编译器不能信任 getter 不改状态
调用实例方法(未标 readonly)✅ 复制经典防御性拷贝触发场景
readonly struct任意成员访问❌ 不复制编译器确定整个 struct 不可变
调用任意方法❌ 不复制同上
非变量实参字面量(in 42✅ 复制字面量没有地址
表达式(in (a+b)✅ 复制表达式结果没有地址
类型不匹配(shortin int✅ 复制隐式转换产生新临时变量
in 参数传递给另一位 in引用透传❌ 不复制引用直接转发
Nullable<T>任何操作✅ 复制Nullable<T> 不是 readonly struct

八、和 ref readonly 的对比——这才是关键

inref readonly C# 12
承诺只读(可能通过复制来保证)只读(通过禁止修改 + 保证不复制来保证)
指向可能是原始地址,也可能是临时变量一定是原始地址
实参要求任意表达式必须是变量(有地址)
调用方语法in 可省略必须写 ref
适用场景大 struct 性能传参、API 用户友好需要确切地址的场景(互操作、安全代码)
防御性拷贝视情况不会——因为必须是变量 + 编译器有完整信息

九、最佳实践

  1. 始终把 inreadonly struct 配对使用。只有这样才能同时得到"只读保证"和"零拷贝性能"。
  2. 如果 struct 不能整体 readonly,至少把无副作用的成员标上 readonly(C# 8.0+)。
  3. 不要对可变 struct 用 in——性能可能反而比按值传递更差(每次成员访问都防御性拷贝 vs 调用时拷贝一次)。
  4. 不要对 Nullable<T>in——它不是 readonly struct。
  5. 对小于 IntPtr.Size(4 字节在 32 位 / 8 字节在 64 位)的类型,in 的性能收益微乎其微。
  6. 需要确切内存地址时用 ref readonly,不要用 in
一句话总结:in 的"可能复制" = 当编译器不能证明 struct 不可变时,它会在每次成员访问(方法调用、属性 getter)时先复制再操作——语义安全但性能有坑。消除防御性拷贝的唯一办法是用 readonly structreadonly 成员。

参考资源