我把结构体放进 List 里改它的字段,改了半天发现原数据纹丝不动,我盯着这个见了鬼的结果排查了大半天才搞懂值类型复制语义的深度复盘
这是一个让我对 C# "值类型"刻骨铭心的故事。我定义了一个 struct(结构体),用来表示一个小数据(比如一个二维点 Point,有 X 和 Y);然后,我把一批这样的结构体,放进了一个 List 里。接着,我想遍历这个 List,修改其中某些元素的字段(比如把某个点的 X 改成新值)。在我朴素的认知里,这天经地义——从集合里拿出元素、改它的字段嘛。
可结果,把我整懵了:我明明改了啊,可改完之后,回头一看 List 里的原数据,纹丝不动——我的修改,压根没生效!那个点的 X,还是原来的旧值。我反复确认,我的赋值语句确实执行了、值确实赋进去了(我把那个变量打印出来,X 确实变了);可 List 里的那个元素,就是不变。我当时百思不得其解:我明明从 List 里拿出了那个元素、改了它、值也确实变了啊,怎么 List 里的原数据,就是不跟着变?难道我拿出来的,不是 List 里的那个元素吗?直到我去深究 C# 的值类型和引用类型,才恍然大悟,补上了关于类型最重要的一课:问题的核心,在于 struct 是"值类型(value type)",而不是"引用类型(reference type)"!这两者,有一个根本性的区别:值类型,在"赋值"或"传参"的时候,传的是一份"拷贝(copy)";而引用类型,传的是"引用(指向同一个对象)"。所以,当我写 var p = list[0]; 时,我以为我"拿到了 List 里的那个元素";实际上,我拿到的,是 List 里那个元素的一份"拷贝"!p 和 list[0],是两个独立的、互不相干的副本;我修改 p.X,改的,是那份拷贝的 X,而 list[0] 里的原元素,根本没被碰到——这就是为什么我"改了、值也变了,但 List 纹丝不动"。(其实,如果我直接写 list[0].X = 5;,C# 编译器还会直接报错,因为它知道 list[0] 返回的是个临时拷贝、改它没意义,干脆禁止你这么写——这本是个善意的提醒,但我当时用了 var p = list[0] 这种绕过它的写法,就掉进了坑里。)同理,如果我把一个 struct 传给一个方法、在方法里改它,改的也是传进去的那份拷贝,方法外的原值,纹丝不动。归根结底:我把 struct(值类型),错当成了像 class 那样的引用类型——以为"拿到的就是那个对象本身、改它就改了原数据";殊不知,值类型,每一次赋值和传参,都悄悄地复制了一份;我一直在改那些副本,却以为在改原数据。
故障现场:从 List 里取出 struct,拿到的是副本
我把这个"改了没生效"的现场,用代码摊开给你看:
// struct: 值类型!
struct Point { public int X; public int Y; }
var list = new List<Point>();
list.Add(new Point { X = 1, Y = 1 });
// ✗ 灾难: 以为取出的是 list 里那个元素, 其实是它的"拷贝"
var p = list[0]; // ← p 是 list[0] 的一份"拷贝"(值类型赋值=复制)!
p.X = 99; // 改的是"拷贝 p 的 X"
Console.WriteLine(p.X); // 99 (拷贝确实变了)
Console.WriteLine(list[0].X); // 1 ← ??? list 里的原数据纹丝不动!
// 直接写 list[0].X = 99; 呢?
// → 编译报错: "Cannot modify the return value of 'List.this[int]'
// because it is not a variable"
// → C# 知道 list[0] 返回的是临时拷贝, 改它没意义, 干脆禁止(善意提醒)。
// 传参给方法也一样(值类型按值传递):
void Move(Point pt) { pt.X += 10; } // 改的是"传进来的拷贝"
var q = new Point { X = 5 };
Move(q);
Console.WriteLine(q.X); // 5 ← 没变! 方法改的是拷贝, 外面的 q 纹丝不动。
// 为什么? struct 是"值类型":
// - 赋值(var p = list[0]): 复制一份。p 和 list[0] 是两个独立副本。
// - 传参(Move(q)): 复制一份传进去。方法里改的是副本。
// - 改副本, 不影响原值。 ← 这就是"改了没生效"的真相。
// 对比 class(引用类型): 赋值/传参传的是"引用"(指向同一个对象),
// 改它就是改原对象 → 会生效。
// 根因: 把 struct(值类型, 赋值/传参=复制), 当成了 class(引用类型)用。
// 一直在改"副本", 却以为在改"原数据"。
看着这段代码,我才算真正理解了这个"改了没生效"的根源。问题的核心,在于 struct 是"值类型(value type)",而不是"引用类型(reference type)";这两者,有一个根本性的区别:值类型,在"赋值"或"传参"时,传的是一份"拷贝";而引用类型,传的是"引用"(指向同一个对象)。所以,当我写 var p = list[0]; 时:我以为"我拿到了 List 里的那个元素";可实际上,我拿到的,是 List 里那个元素的一份"拷贝"!p 和 list[0],是两个独立的、互不相干的副本;我修改 p.X,改的,是那份拷贝的 X,而 list[0] 里的原元素,根本没被碰到——这就是"改了、值也变了,但 List 纹丝不动"的真相。(其实,如果我直接写 list[0].X = 99;,C# 编译器还会直接报错——因为它知道 list[0] 返回的是个临时拷贝、改它没有意义,干脆禁止你这么写;这本是编译器一个善意的提醒,可我当时用了 var p = list[0] 这种绕过它的写法,就一头掉进了坑里。)而传参给方法,也是完全一样:把一个 struct 传进方法、在方法里改它,改的,也是传进去的那份拷贝,方法外的原值,纹丝不动。这就完美解释了我的困惑:值类型,在赋值(var p = list[0])时复制一份、在传参(Move(q))时也复制一份;而改副本,不影响原值。这,和 class(引用类型)截然不同:class 赋值/传参传的是"引用"(指向同一个对象),改它就是改原对象,会生效。归根结底:我犯的错,是把 struct(值类型),错当成了 class(引用类型)来用——我以为"拿到的就是那个对象本身、改它就改了原数据";殊不知,值类型,每一次赋值和传参,都悄悄地复制了一份;我一直在改的,是那些副本,却以为在改原数据。这,是值类型和引用类型那道根本性的鸿沟,给我上的一课。
第一件事:搞懂值类型 vs 引用类型——复制还是共享
定位到根源,我必须把"值类型和引用类型"的根本区别,彻底搞清楚:
值类型 vs 引用类型: 赋值/传参时, "复制"还是"共享"?
# 值类型(value type): struct, int, double, bool, char, enum, DateTime...
# - 赋值/传参时: "复制一份"(拷贝整个值)。
# - 两个变量, 是两份独立的数据, 互不影响。
# - 改一个, 不影响另一个。
# - (默认在栈上, 或作为对象的一部分内联存储)
# 引用类型(reference type): class, 数组, string(特殊), 接口, 委托...
# - 赋值/传参时: "复制引用"(两个变量指向同一个对象)。
# - 改这个对象, 所有指向它的变量都看得见。
# - (对象在堆上, 变量存的是指向它的引用)
# 关键区别, 一句话:
# 值类型: 赋值/传参 = 复制"内容"。各改各的, 互不影响。
# 引用类型: 赋值/传参 = 复制"引用"。改同一个对象, 处处可见。
# 由此, 值类型的"坑":
# 1. 从集合取出 struct 改字段 → 改的是拷贝, 原集合不变(本文)。
# 2. struct 传给方法里改 → 改的是拷贝, 外面不变。
# 3. list[0].X = 5 → 编译报错(返回的是临时拷贝)。
# → 想改原数据? 改完要"赋值回去"(list[0] = p), 或干脆用 class。
# 怎么判断一个类型是值还是引用?
# - struct/enum + 内置数字/bool/char/DateTime = 值类型。
# - class/数组/string/接口/委托 = 引用类型。
# - (string 虽是引用类型, 但"不可变", 用起来像值类型)
# 核心: 值类型赋值/传参是"复制", 引用类型是"共享(引用)"。
# 改 struct 的副本不影响原值。想改原数据, 要么赋值回去, 要么用 class。
原理终于刻进脑子里了。C# 的类型,分为两大类。值类型(value type):struct、int、double、bool、enum、DateTime 等——它们在赋值/传参时,是"复制一份"(拷贝整个值);所以,两个变量,是两份独立的数据,互不影响,改一个不影响另一个。引用类型(reference type):class、数组、string、接口、委托等——它们在赋值/传参时,是"复制引用"(两个变量指向同一个对象);所以,改这个对象,所有指向它的变量都看得见。一句话概括这个关键区别:值类型,赋值/传参是复制"内容"(各改各的、互不影响);引用类型,赋值/传参是复制"引用"(改同一个对象、处处可见)。由此,就能理解值类型的那些"坑":从集合取出 struct 改字段,改的是拷贝、原集合不变(本文);struct 传给方法里改,改的是拷贝、外面不变;list[0].X = 5 会编译报错(返回的是临时拷贝)。想真正改到原数据?要么改完赋值回去(list[0] = p),要么干脆用 class。而怎么判断一个类型是值还是引用?struct/enum + 内置的数字/bool/char/DateTime 是值类型;class/数组/string/接口/委托 是引用类型(其中 string 虽是引用类型,但因为它不可变,用起来很像值类型)。归根结底:值类型赋值/传参是"复制",引用类型是"共享(引用)";改 struct 的副本,不影响原值;想改原数据,要么赋值回去,要么用 class——这,是我用一次"改了 List 却纹丝不动"的事故,补上的、关于 C# 类型系统最根本的一课。
第二件事:正解——改完赋值回去,或用 class,或别用可变 struct
搞懂了根因——"struct 是值类型、取出来的是副本"——正解就清晰了:如果你需要修改并生效,要么改完那份副本后,再把它"赋值回去"(覆盖原位置);要么,如果这个数据本就需要"被共享、被修改",干脆用 class(引用类型);而更被推崇的实践,是让 struct 不可变(immutable),从根上避免"改 struct"这件容易出错的事。
// 正解1: 改完"赋值回去"(覆盖原位置)
var p = list[0]; // 拿到拷贝
p.X = 99; // 改拷贝
list[0] = p; // ✓ 把改好的拷贝, 赋值回 list[0](覆盖原元素)
Console.WriteLine(list[0].X); // 99 ✓ 生效了!
// → 值类型改不到原值, 那就"用改好的副本, 整个替换掉原值"。
// 正解2: 如果数据本就要"共享 + 可变", 用 class(引用类型)
class PointClass { public int X; public int Y; } // class!
var list2 = new List<PointClass>();
list2.Add(new PointClass { X = 1 });
list2[0].X = 99; // ✓ 直接改就生效(引用类型, 改的就是那个对象)
Console.WriteLine(list2[0].X); // 99 ✓
// → 需要"多处共享、且就地修改"的数据, 用 class 更自然。
// 正解3(最推荐): 让 struct"不可变"(immutable), 别做可变 struct
readonly struct PointR
{
public int X { get; } // 只读
public int Y { get; }
public PointR(int x, int y) { X = x; Y = y; }
public PointR WithX(int x) => new PointR(x, Y); // "改" = 返回一个新的
}
var pr = list3[0];
list3[0] = pr.WithX(99); // 不"改"原 struct, 而是用"新 struct"替换
// → 不可变 struct: 没有"原地改"这回事, 自然就没有"改了没生效"的坑。
// (微软官方也建议: struct 尽量设计成不可变的)
// 选择:
// - 需要共享、就地修改的数据 → 用 class。
// - 小而简单、表示一个"值"的数据 → 用 struct, 且做成不可变。
// - 用了可变 struct 又要改 → 记得"赋值回去"。
// 核心: 值类型改不到原值。改完赋值回去, 或用 class, 或(最好)用不可变 struct。
// 关键是想清楚: 你要的是"复制的值", 还是"共享的对象"。
这套正解,核心是根据你"到底想要什么",选对应的做法。正解1(改完赋值回去):既然值类型,你改不到原值,那就用改好的那份副本,把原值整个替换掉——var p = list[0]; p.X = 99; list[0] = p;;这样,list[0] 就被你改好的副本覆盖了,修改生效。正解2(用 class):如果这个数据,本就需要"被多处共享、并就地修改",那就干脆用 class(引用类型)——引用类型,赋值/传参传的是引用,list2[0].X = 99 直接改就生效(改的就是那个被共享的对象)。正解3(最推荐:让 struct 不可变):更被推崇的实践,是把 struct 设计成不可变(immutable)的(用 readonly struct、只读属性);"修改"它,就返回一个新的 struct(如 WithX(99)),而不是原地改;这样,压根就没有"原地改 struct"这回事,自然也就没有"改了没生效"的坑了(微软官方也建议:struct 尽量设计成不可变的)。而选择上:需要共享、就地修改的数据,用 class;小而简单、表示一个"值"的数据,用 struct 且做成不可变;万一用了可变 struct 又要改,记得"赋值回去"。归根结底:值类型改不到原值;改完赋值回去、或用 class、或(最好)用不可变 struct;而最关键的,是想清楚:你要的,是一个"复制的值",还是一个"共享的对象"——想清楚了这一点,自然就知道该用值类型还是引用类型了。我那次的错误,正是用了可变 struct、又没赋值回去;而正解,任选其一,都能解决。
下面这张图,对比了"直接改 struct 副本"和"正确做法"两条路径:
这张图的对比很清楚:要修改集合元素并生效,先看元素是 struct 还是 class——struct(值类型)取出来的是拷贝,只改拷贝不赋值回去就纹丝不动、不生效,改完赋值回去才生效;class(引用类型)取出的是引用、直接改就生效;而最推荐的,是用不可变 struct("改"=返回新 struct 再替换,根本没有原地改的坑)。根本分野,在于你有没有理解"取出来的,是副本还是引用"。
第三件事:值类型的其它坑
填平了"改 struct 没生效"这个坑,我系统排查了一遍值类型的其它常见坑:
// 值类型的其它常见坑:
// 1. 从集合取 struct 改字段不生效(本文): 改副本, 要赋值回去 / 用 class。
// 2. 装箱(boxing): 把值类型当 object/接口用 → 被"装箱"成堆上对象, 有开销。
object o = 5; // int 被装箱(分配堆对象)
int i = (int)o; // 拆箱
// → 大量装箱(如把 struct 放进 ArrayList、非泛型集合)有性能开销。
// 3. 可变 struct 的各种诡异: 属性返回 struct、struct 作为只读字段等,
// "改"它常常改的是临时拷贝 → 改不动。→ 别用可变 struct(微软建议)。
// 4. struct 的默认值: 不是 null, 而是"所有字段都是默认值"的实例。
Point pt = default; // X=0, Y=0(不是 null! 值类型不能为 null, 除非 Nullable)
// 可空值类型: int? / Point?(Nullable)才能为 null。
// 5. struct 的相等: 默认按"所有字段逐个比较"(值相等), 但默认实现用反射、慢;
// 要重写 Equals/GetHashCode(或用 record struct 自动生成)。
// 6. 大 struct 传参/复制开销: struct 太大, 频繁复制(传参/赋值)开销大。
// → 大数据用 class; 或用 in/ref 传递避免复制(只读用 in)。
// 7. 别把"会变的、要共享的"东西做成 struct: 它的"复制"语义会让你困惑。
// 共同点: 都源于"值类型是复制语义 + 装箱"。
// 原则: struct 用于"小、不可变、表示值"的场景; 需要共享/可变就用 class。
这一排查,让我对值类型的"雷区",有了全面的认识。除了"改 struct 没生效"(本文),还有几个常见坑:装箱(boxing)(把值类型当 object/接口用时,会被"装箱"成堆上的对象、有开销;大量装箱有性能问题);可变 struct 的各种诡异(属性返回 struct、struct 作为只读字段时,"改"它常常改的是临时拷贝、改不动——所以微软建议别用可变 struct);struct 的默认值(不是 null,而是"所有字段都是默认值"的实例——值类型不能为 null,除非用可空值类型 int?/Nullable<T>);struct 的相等(默认按所有字段逐个比较,但默认实现用反射、慢,要重写 Equals/GetHashCode,或用 record struct 自动生成);大 struct 的复制开销(struct 太大,频繁复制开销大——大数据用 class,或用 in/ref 传递避免复制);别把"会变的、要共享的"东西做成 struct(它的复制语义会让你困惑)。这些坑的共同点,都源于"值类型是复制语义,加上装箱"。所以,核心原则就是:struct,用于"小、不可变、表示一个值"的场景;而需要"共享/可变"的,就用 class。把这条原则刻在心里,值类型的这些坑,就基本都能规避了。
第四件事:struct 和 class,到底该用哪个
这次踩坑,逼我把"什么时候用 struct、什么时候用 class"系统地想清楚了:
struct(值类型) vs class(引用类型): 该用哪个?
# 用 struct(值类型)的场景:
# 1. 表示一个"小的、单一的值"(如坐标 Point、颜色 Color、金额、时间段)。
# 2. 数据小(微软建议 ≤ 16 字节, 复制开销才小)。
# 3. 不可变(创建后不变)——值类型最适合表示"不变的值"。
# 4. 生命周期短、大量创建(值类型常在栈上, 不给 GC 添负担)。
# → 即: "小、不可变、像一个值"的东西。
# 用 class(引用类型)的场景:
# 1. 需要"共享、且可被修改"的对象(多处引用同一个, 改了都看见)。
# 2. 数据大(避免复制开销)。
# 3. 有身份/生命周期(它"是谁", 不只是"它的值是多少")。
# 4. 需要继承、多态。
# → 即: "大、可变、有身份、要共享"的东西。
# 一个判断: 你关心的是"值"还是"对象身份"?
# - 关心"值"(两个 X、Y 相同的点, 就是"同一个"点)→ 值类型(struct)。
# - 关心"身份"(两个人即使同名同岁, 也是"不同的人")→ 引用类型(class)。
# 默认选择:
# - 拿不准时, 默认用 class(引用类型语义更符合直觉、坑更少)。
# - 只在"小、不可变、表示值、且性能敏感"时, 才用 struct。
# - 用 struct 就尽量做成"不可变"(readonly struct / record struct)。
# 现代 C#: record(引用类型) 和 record struct(值类型)
# - record: 自动生成值相等、不可变等, 适合"数据载体"。
# - 想要值语义又少坑 → record struct(不可变值类型, 自动生成相等)。
# 核心: struct 表示"小、不可变的值"; class 表示"可变、共享、有身份的对象"。
# 想清楚要"值语义"还是"引用语义", 是选对的关键。
这一思考,让我对 struct 和 class 的选择,有了清晰的标准。用 struct(值类型)的场景:表示一个"小的、单一的值"(如坐标、颜色、金额、时间段)、数据小(微软建议 ≤ 16 字节,复制开销才小)、不可变(值类型最适合表示"不变的值")、生命周期短且大量创建(值类型常在栈上,不给 GC 添负担)——即"小、不可变、像一个值"的东西。用 class(引用类型)的场景:需要"共享、且可被修改"的对象、数据大(避免复制开销)、有身份/生命周期、需要继承/多态——即"大、可变、有身份、要共享"的东西。而一个很好的判断标准是:你关心的,是"值"还是"对象身份"?——关心"值"(两个 X、Y 相同的点,就是"同一个"点)用值类型;关心"身份"(两个人即使同名同岁,也是不同的人)用引用类型。至于默认选择:拿不准时,默认用 class(引用类型语义更符合直觉、坑更少);只在"小、不可变、表示值、且性能敏感"时,才用 struct;而用了 struct,就尽量做成"不可变"(readonly struct/record struct)。现代 C# 里,还有 record:record(引用类型,自动生成值相等、不可变,适合"数据载体")、record struct(想要值语义又少坑时,用这个不可变值类型)。归根结底:struct 表示"小、不可变的值";class 表示"可变、共享、有身份的对象";而想清楚你要的是"值语义"还是"引用语义",是选对的关键。我那次的错,正是把一个想"共享修改"的数据,做成了 struct。把 struct 和 class 的对比,整理成一张表:
| 维度 | struct(值类型) | class(引用类型) |
|---|---|---|
| 赋值/传参 | 复制内容(各改各的) | 复制引用(改同一对象) |
| 适合 | 小、不可变、表示值 | 大、可变、有身份、共享 |
| 相等 | 默认按值(逐字段) | 默认按引用(是否同一对象) |
| 默认值 | 各字段默认值(非 null) | null |
| 选择 | 性能敏感的小值类型 | 拿不准就用它(默认) |
第五件事:理解"复制语义"和"引用语义"的根本区别
这次踩坑,在认知层面给了我最大的纠偏——它让我意识到,要看清一个变量背后,到底是"复制"还是"共享"。我把这层反思,沉淀了下来:
认知纠偏: 看清一个变量是"复制语义"还是"引用语义"
# 我的误解(错误的):
# 我默认"从集合拿出元素 = 拿到那个元素本身, 改它就改了原数据"——
# 这是"引用语义"的直觉。但 struct 是"复制语义", 拿到的是副本。
# → 我没分清, 手里的变量, 到底是"原数据"还是"它的一份拷贝"。
# 真相: 一个变量背后, 可能是"复制"也可能是"共享", 行为天差地别
# - 复制语义(值类型): 变量持有"一份独立的拷贝"。改它, 不影响别处。
# - 引用语义(引用类型): 变量持有"指向共享对象的引用"。改它, 处处可见。
# → 同样一行"改字段"的代码, 在两种语义下, 一个不生效、一个会生效。
# 这是个跨语言的普遍问题: "值 vs 引用 / 复制 vs 共享"
# - C#: struct(值) vs class(引用)。
# - Java: 基本类型(值) vs 对象(引用); 但没有自定义值类型。
# - Go: struct 默认值传递(复制), 指针才共享。
# - Python: 一切是引用, 但不可变对象表现像值。
# - C/C++: 值传递 vs 指针/引用。
# → 每门语言都有这套, 但规则不同; 用错就会"改了没生效"或"误改了共享数据"。
# 正确的习惯:
# 1. 时刻清楚: 我手里这个变量, 是"原数据本身", 还是"它的一份拷贝"?
# 2. 想"修改并生效"时, 确认它是引用语义(共享); 否则要赋值回去。
# 3. 想"独立副本、互不影响"时, 确认是值语义(复制); 否则要主动 copy。
# 4. 搞清你用的语言, 哪些是值语义、哪些是引用语义。
核心: 看清变量是"复制(值)"还是"共享(引用)"语义。
这决定了"改它"到底改的是原数据还是副本——是理解一大类 bug 的钥匙。
这层反思,是这次踩坑给我最高维度的收获。复盘我的误解,根源是:我默认"从集合拿出元素 = 拿到那个元素本身,改它就改了原数据"——这,是一种"引用语义"的直觉;可 struct 是"复制语义",我拿到的,是一份副本。我没分清,我手里的变量,到底是"原数据"、还是"它的一份拷贝"。可真相是:一个变量的背后,可能是"复制",也可能是"共享",而这两者的行为,天差地别——复制语义(值类型),变量持有"一份独立的拷贝",改它不影响别处;引用语义(引用类型),变量持有"指向共享对象的引用",改它处处可见;同样一行"改字段"的代码,在两种语义下,一个不生效、一个会生效。而这,是一个跨语言的普遍问题:C# 是 struct(值) vs class(引用);Java 是基本类型(值) vs 对象(引用);Go 是 struct 默认值传递(复制)、指针才共享;Python 一切是引用、但不可变对象表现像值;C/C++ 是值传递 vs 指针/引用——每门语言都有这套"值 vs 引用 / 复制 vs 共享",但规则各不相同;用错了,就会"改了没生效",或反过来"误改了共享数据"。由此,我立下了几条习惯:第一,时刻清楚:我手里这个变量,是"原数据本身",还是"它的一份拷贝"?第二,想"修改并生效"时,确认它是引用语义(共享),否则要赋值回去;第三,想"独立副本、互不影响"时,确认是值语义(复制),否则要主动 copy;第四,搞清你用的语言,哪些是值语义、哪些是引用语义。归根结底:看清一个变量,是"复制(值)"还是"共享(引用)"语义——这,决定了"改它"到底改的是原数据还是副本,是理解一大类 bug 的钥匙。我那次"改了没生效",正是没看清,我改的,是一份副本。把"没分清语义"和"看清语义"对比成一张表:
| 维度 | 没分清语义(踩坑) | 看清语义(掌握) |
|---|---|---|
| 手里的变量 | 以为都是原数据 | 知道是副本还是引用 |
| 改 struct | 以为改了原值 | 知道改的是副本 |
| 要修改生效 | 直接改(不生效) | 引用语义直接改/值语义赋值回 |
| 要独立副本 | 共享了还以为独立 | 值语义天然独立/引用要 copy |
| 跨语言 | 照搬一种直觉 | 搞清各语言的规则 |
一套"该用 struct 还是 class、怎么改"的决策流程
把这次踩坑的全部教训,我浓缩成了一张"定义类型、以及修改它时、该怎么做"的决策图,贴在了团队的 C# 规范里:
这张图,把我"血泪换来"的整套方法论,串成了一条可执行的路径:定义类型,先问需不需要"共享且可变"——是(多处引用同一个、要就地改)就用 class;否(小、表示一个值)就尽量做成不可变的 readonly struct/record struct,要用可变 struct 就当心"改副本不生效"。而修改集合元素时:class 元素直接改就生效,struct 元素则改完要赋值回去(list[i] = p)才生效。这条"按共享/可变选类型、按值/引用语义改数据"的决策链,现在是我们团队定义和修改每一个数据类型时的准则。
我立下的几条值类型规矩
这次"改 struct 没生效"的踩坑,让我把 C# 值类型的注意事项,认真地立成了几条规矩:
- 记牢 struct 是值类型,赋值/传参是复制。取出来的是副本,改副本不影响原值。
- 改集合里的 struct 要赋值回去。
var p = list[i]; p.X = 5; list[i] = p;,否则不生效。 - 需要共享+可变就用 class。引用类型直接改就生效;别把要共享修改的数据做成 struct。
- struct 尽量做成不可变。
readonly struct/record struct,从根上避免"改副本"的坑。 - 注意装箱开销。值类型当 object/接口用会装箱;别放进非泛型集合大量装箱。
- struct 默认值非 null。是各字段的默认值;要可空用
T?;相等用 record struct 或重写。 - 分清复制语义和引用语义。看清手里是原数据还是副本,这是理解一大类 bug 的钥匙。
写在最后
这次"我改 List 里的 struct、改了半天原数据纹丝不动"的经历,是我在 C# 路上,一次很经典、也很受用的成长。它教给我的,远不止"struct 改副本要赋值回去"这一条具体的技术经验,更是一个跨越语言的根本认知——要看清一个变量背后,到底是"复制(值)语义"还是"共享(引用)语义"。我那个"改了没生效"的诡异结果,根源就在于,我用"引用语义"的直觉(拿到的就是原数据、改它就改了原数据),去对待一个"复制语义"的值类型(struct);我一直在改一份份的副本,却以为在改原数据。
所以,当你操作一个变量、尤其是想"修改"它时,请别想当然——而要先问自己一句:"我手里这个变量,是'原数据本身',还是'它的一份拷贝'?改它,到底改的是原数据,还是一个副本?"就像 C# 的 struct,你只要清楚"它是值类型、取出来的是副本",就要么改完赋值回去、要么干脆用 class,绝不会再经历那种"改了半天、原数据却纹丝不动"的困惑。从"分不清值与引用"到"看清复制与共享的语义",从把 struct 当 class 用到选对值类型与引用类型,是从一个"会写代码"的开发,走向一个"懂类型系统、行为可预判"的工程师,必经的修炼。愿你写的每一次修改,都精准地改到你想改的那个数据;也愿你我,在用每一门语言时,都拎得清:我手里的,究竟是一份拷贝,还是那个共享的本体。共勉。
—— 别看了 · 2026