SW 随笔 002 — C# 设计一个值语意 struct
声明:个人笔记,概不负责
一直认为自己认识 C# 中的 struct ,就是设计一个 值语意 ,洒洒水啦。
直到……和 Copilot 开始掰扯……
引言
我随手设计了带自更新能力的 class PacketStateMachine ,里面就维护一个字段。
但我有点疑惑,在想,如果用 struct 的话是不是会香一点,省去一个引用啊。
我的代码长这样
enum PacketState
{}
//
// 经典错误:若是,定义为 struct
//
class PacketStateMachine
{
private PacketState Flags;
}
class PacketWithStatus
{
public PacketStateMachine PacketStatus;
}
(有问题,就问嘛!现在的工具麻溜的顺,比互联网搜索靠谱多了)
先找 Copilot 问,能不能改成 struct 类型,但 Copilot 反复警告不行。
和他掰扯了半天,终于明白了,的确不行!问题在 —— 隐式复制(implicit copying)
(我以为我懂了,还做了笔记,过了一会儿,……)
我找 DeepSeek 问,它说 在我的场景下,这个错误是经典错误 —— 官方教程的“反面教材”
但 DeepSeek 给的能用的场景比 Copilot 的多,我又拿 DeepSeek 答案来问 Copilot
接着 Copilot 又挤出不少东西……
简单结论
对于 struct 实例(值语意),在访问它的一刹那,就(可能)已经发生了隐式复制。此花非彼花,此人非那人。
它分三类:
- 明确「不会复制」(ref 语义,100%)
- 明确「一定复制」(值语义,100%)
- 不定(取决于访问路径)
所以,为 struct 写 改变自己的方法,用起来 不是那么随心所欲的啦!——它的效果是,不定
所以,为 struct 方法加上 readonly 关键字,其意义是非凡的啦!
每个语言都有自己的特色,长一样,那不是真一样,可能真不一样!
所以,我的场景用 class 是最佳选项,没有心智负担。(不会“操卖白粉的心,赚卖面粉的钱”)
自己的总结: 除非有明确需要,不搞 mutable 的 struct ,这个最简单。(别轻易“操卖白粉的心”)
问答 AI 录
问:那岂不是讲 所有带 自更新能力(方法)的 struct 都存在这个问题?
Copilot 答:
不完全是“所有”,但绝大多数“带自更新能力(可变方法)的 struct”在常见用法下都会踩隐式复制的坑。关键在于调用路径与传递方式。
问:在局部变量上调用 应该算个特例吧?
问:从语言完整性讲,在局部变量上调用,算个特例啊,它的更新干嘛没有 隐式复制,它又没用 标明是 ref
问:你没听懂意思啊! 哎…… 嗯,我自己明白了。 这和 局部变量 写个 int 一个道理
Copilot 答:
对,你说到点子上了——在局部变量上的行为,跟 int 一样:
• 局部变量上的 struct 调用(比如 s.Mutate())作用的就是那个局部变量本体,不涉及“先读出一个副本再调用”的步骤;
• 就像对 int 做 x++ 一样,没有隐式复制的问题;
• 隐式复制的坑主要发生在:字段/属性访问、按值传参、装箱/接口调用这些“读取值再用”的路径。
所以你的直觉是对的:局部变量路径 = 安全;像操作一个 int 一样直观。
问:嗯,所以说 局部变量,与 field 字段,不是一个东西,得区别对待
问:那 参数的行为 更像 filed ,而不是更像 局部变量?
问:参数,局部变量,field 外,应该没有类似 存东西、或传来传去的 C# 语言构造了吧?
Copilot 答:
C# 中“存储位点”不止局部变量和字段:属性、数组元素、闭包捕获、迭代器/async 状态机、栈内块等都会影响值/引用语义。
→ C# 中“存储位点”(storage location)不止局部变量(local variable)和字段(field):属性(property)、数组元素(array element)、闭包捕获(closure capture)、迭代器/async 状态机(iterator/async state machine)、栈内块(stack-allocated block, stackalloc)等都会影响值/引用语义(value/reference semantics)。对 struct 而言:只要不是“局部变量本体”,默认都要当作“可能复制”的路径处理。
→ 对 struct(struct)而言:只要不是“局部变量本体”(the local variable itself),默认都要当作“可能复制”的路径(potential implicit copying path)处理。一句话总结:
在 C# 中,除了局部变量(local variable)以外,经由字段(field)、属性(property)、按值参数(by‑value parameter)、装箱/接口调用(boxing/interface call)、数组/集> > 合元素(array/list element)、闭包捕获(closure capture)、以及迭代器/async 状态机(iterator/async state machine)访问 struct 都可能触发隐式复制(implicit copying);因此带副作用的更新(mutating/self‑updating)应倾向引用语义(reference semantics, class),而值语义(value semantics, readonly struct/record struct)更适用于不可变快照(immutable snapshot)。
(许多小时以后,其实就是 2、3 个小时以后)
问:你这个不是事实啊,DeepSeek 说,还有其他情况可以 调用 带 自更新能力(方法)的 struct ,不会隐式复制, 比如说 数组
问:请帮我整理成速查表
问:你速查表里,为啥没有 field 字段?
问:你搞个 不定,不就行了? 重出速查表。 速查表,就是要全啊
聊天:不,除非有明确需要,不搞 mutable 的 struct ,这个最简单。
Copilot 答:
你这个点说得对,我前面那种“只要不是局部变量本体就一律当可能复制”的表述 不够严谨,容易把一些 “有 ref 语义的访问路径” 一起误伤了。我们把事实摊开、一次说清楚,不跟 DeepSeek 对立,也不和你抬杠。行 👍,你这个要求完全合理:
速查表就应该“全”,哪怕结论是不定,也要明面写出来。✅ 明确「不会复制」(ref 语义,100%)
场景 示例 说明 局部变量 S s; s.Mutate(); s 是 variable 数组元素 arr[i].Mutate(); ✅ variable(你指出的反例) Span 元素 span[i].Mutate(); ref 访问 ref / out 参数 Foo(ref s); 明确 ref ref 返回值 GetRef().Mutate(); C# 7+ struct 的 field(ref 路径下) s.Field.Mutate(); 上层是 variable ❌ 明确「一定复制」(值语义,100%)
场景 示例 原因 属性 getter obj.Prop.Mutate(); getter 返回值 普通返回值 GetS().Mutate(); 非 ref 返回 接口 / 非约束泛型 IFoo f = s; f.Mutate(); 装箱 / 复制 foreach(非 ref) foreach (var x in arr) 迭代变量是副本 LINQ 投影 Select(x => x) 值流 struct 作为 class 成员 obj.StructField.Mutate(); 读出即副本【我的场景】 struct field(值路径下) obj.StructField.Field = 1; 父节点已是副本 ⚠️ 不定(取决于访问路径) ← 你要求的关键项
✅ 速查表必须有这一栏
项 示例 判定规则 struct field ?.Field 看上一跳是不是 variable struct 本身 expr.Mutate() expr 是否为 variable 方法链 A().B().Mutate() 中间是否产生值 成员访问 X.Y.Z 任一层返回值即断 this struct 实例方法 调用点决定 【说实话,我已经崩溃了,我就玩玩 C# 呀,操这个心】
✅ 正例
S s; s.Field.Mutate(); // 不复制 arr[i].Field.Mutate(); // 不复制❌ 反例
obj.StructField.Field = 1; // 复制 GetS().Field = 1; // 复制👉 结论:field 本身是「不定项」,对工程判断非常重要,必须显式写出来。
✅ 最终工程用定义(推荐你用这个)
- 在速查/审计层面,field 必须标为「不定项」:
- 它永远继承上一层访问点的语义,
- 上一层是 variable,它就是 ref;
- 上一层是 value,它必然是副本。
问:不对,struct 作为 field 的话,为什么它的行为和 int 不一样?
Copilot 答:
你看到「struct 作为 field 的行为和 int 不一样」本质上是:int 也是 struct(System.Int32),但它是不可变,而你关心的是可变 struct在不同“访问路径”下会被复制(产生副本)还是原地修改。当有复制发生时,行为就与不可变的 int(看不出差异)不同。一句压轴总结
struct 和 int 本质一样,都是值复制。
不一样只是:int 没成员可改,你永远感觉不到“副本坑”;
可变 struct 有成员可改,一旦没拿到原件(property/readonly/装箱)就全暴露了。
问:既然有这个坑,编译器就应该报警啊
问:struct 这玩意,没想到 mutable 起来,其威力 不亚于 C 的指针啊
Copilot 答:
确实有坑,但 编译器已经在“最容易误操作”的点上直接报错;其余情况是“按值复制”的正常语义,不适合一概报警(否则噪声巨大)。一句话:编译器该拦的已经拦了(改到副本/只读)。其余是值复制的正常行为,交给设计与分析器来管控。
==
结论:
mutable struct = C 指针的半安全包装版
它不像 pointer 那么自由(不能做 pointer math),
但它在 语义层面 引发的坑
——是否改到原件、是否隐式复制、只读上下文冻结、接口装箱丢失引用——
完全和 pointer 的问题同一类。
只是包装得更“现代化”,难以一眼识别。最稳的建议(业界通用规约)
struct 要么真的 immutable,要么不要用。
可变 struct = 掌握不慎的人间兵器。可变 struct 是 C# 里唯一能在无指针的前提下模拟“按引用修改状态”的类型结构。
因此它继承了全部指针级别的语义复杂性。
=== 简单的结束线 2025-12-26
浙公网安备 33010602011771号