我遍历一个 struct 列表去改它们的字段,改完发现一个都没变、修改全丢了,我对着 C# 值类型的拷贝语义排查了大半天的复盘
那是我用 C# 写的一段数据处理。我定义了一个 struct(结构体)Point,有一批 Point 放在 List 里。我用 foreach 遍历这个列表,在循环里修改每个 Point 的字段。代码读起来天经地义。可跑完一看,诡异极了:列表里的 Point,一个都没变!我在 foreach 里明明给每个 p.X 赋了新值,可循环结束后,列表里的值还是原来的。我对着代码反复看,赋值语句没问题啊。我甚至换了几种写法,有的直接编译报错(说不能修改),有的能编译但改了不生效。排查了大半天,我才真正理解了 C# 里 struct(值类型)和 class(引用类型)那个根本性的区别:值类型的拷贝语义。这篇就把这场"改了 struct 却不生效"的事故,从头复盘一遍。
故障现场:foreach 里改了 struct,列表却没变
先看现场。问题就藏在"struct 是值类型、处处是拷贝"上:
struct Point // ✗ 用了 struct(值类型)
{
public int X;
public int Y;
}
var points = new List { new Point { X = 1 }, new Point { X = 2 } };
// 坑1: foreach 里改 struct 字段 —— 改了不生效(甚至编译报错)
foreach (var p in points)
{
p.X = 100; // ✗ 编译报错: 无法修改foreach迭代变量(它是只读副本)
// (即使能改, 改的也是副本, 列表里的不变)
}
// 坑2: 用索引取出来改 —— 也不生效!
for (int i = 0; i < points.Count; i++)
{
var p = points[i]; // ★ points[i] 返回的是 struct 的【拷贝】!
p.X = 100; // 改的是拷贝 p, 不是列表里的那个
}
// 循环后: points[0].X 还是 1! (改的是拷贝, 列表里的没动)
// 为什么? struct 是"值类型", 赋值/传参/索引访问都是【拷贝】:
// 1. struct(值类型) vs class(引用类型):
// - class: 变量存的是"引用(地址)", 赋值/传参复制引用 → 指向同一对象。
// - struct: 变量存的是"值本身", 赋值/传参【复制整个值】→ 是独立的副本!
// 2. foreach 里的 p: 是列表元素的一个【拷贝】(且foreach变量是只读的)。
// → 改 p 不影响列表里的原值。
// 3. points[i] 取出: 也是返回元素的【拷贝】(List索引器返回值类型是拷贝)。
// → var p = points[i]; p.X=100; 改的是拷贝 p, 列表里的没变。
// 4. ★ 即: 对值类型(struct), 你"拿到"的几乎总是一个副本, 改副本不影响原值。
// 现象拼图:
// - struct 是值类型, 赋值/传参/索引访问/foreach 都是"拷贝出一个副本"。
// - 我以为我改的是"列表里的那个struct", 实际改的是"拷贝出来的副本"。
// - 副本改了, 列表里的原值纹丝不动 → 修改全丢。
// - ★ 根因: 我用了 struct(值类型), 却按 class(引用类型)的直觉去改它,
// 不知道值类型处处是拷贝, 改的全是副本。
看清真相后,我恍然大悟。问题的根源,是 struct 是"值类型",而值类型的赋值/传参/索引访问/foreach 都是"拷贝"。struct(值类型)vs class(引用类型)的根本区别:class 变量存的是"引用(地址)",赋值/传参复制引用、指向同一对象;而 struct 变量存的是"值本身",赋值/传参复制整个值、是独立的副本。所以:foreach 里的 p 是列表元素的拷贝(且 foreach 变量只读),改 p 不影响列表;points[i] 取出也是返回元素的拷贝,var p = points[i]; p.X=100 改的是拷贝 p、列表里的没变。即:对值类型,你"拿到"的几乎总是一个副本,改副本不影响原值。根因是:我用了 struct(值类型),却按 class(引用类型)的直觉去改它,不知道值类型处处是拷贝、改的全是副本。
第一件事:搞懂值类型和引用类型的根本区别
要解决它,得先彻底搞懂 C# 的值类型和引用类型。
值类型(struct) vs 引用类型(class)
# 一、最根本的区别: 变量里存什么?
# - 值类型(struct, 以及int/double/bool/枚举等): 变量直接存"值本身"。
# - 引用类型(class, 以及string/数组/集合等): 变量存"指向对象的引用(地址)"。
# 二、由此带来的行为差异: 赋值/传参/返回时
# - 值类型: 复制【整个值】→ 得到一个独立的副本。
# Point a = b; → a 是 b 的完整拷贝, 改 a 不影响 b。
# 传给方法: 也是拷贝, 方法内改它不影响外面(除非用 ref)。
# - 引用类型: 复制【引用(地址)】→ 两个变量指向同一对象。
# MyClass a = b; → a 和 b 指向同一对象, 改 a 就是改 b。
# 三、所以"改值类型"的坑:
# - 凡是"拿到一个值类型"的地方(赋值、索引器返回、foreach、传参),
# 拿到的几乎都是【副本】, 改它不影响"原来的那个"。
# - List[i] 返回拷贝; foreach 给拷贝; 方法参数是拷贝 → 改了都不生效。
# 四、什么时候用 struct, 什么时候用 class?
# - struct(值类型): 适合"小的、不可变的、表示一个值"的数据
# (如坐标Point、颜色Color、金额Money)。它无堆分配(在栈上/内联), 性能好。
# - class(引用类型): 适合"有身份的、可变的、较大的"对象(大多数业务对象)。
# - ★ 经验: 默认用 class; 只在"小、不可变、值语义、性能敏感"时才用 struct。
# - ★ 尤其: 可变的 struct(mutable struct)是公认的坑源(本文), 强烈建议
# struct 就设计成"不可变(只读)"的, 避免"改副本"的困惑。
# 核心: 值类型(struct)变量存值本身、赋值/传参/索引/foreach都复制整个值得到独立副本,
# 改副本不影响原值; 引用类型(class)存引用、共享同一对象; 默认用class, struct要设计成不可变。
想透值类型和引用类型,这个坑就清楚了。一、最根本的区别:变量里存什么?——值类型(struct、int/double/bool/枚举)变量直接存"值本身";引用类型(class、string/数组/集合)变量存"指向对象的引用"。二、由此带来的行为差异:值类型赋值/传参复制整个值、得到独立副本(改 a 不影响 b);引用类型复制引用、两个变量指向同一对象(改 a 就是改 b)。三、"改值类型"的坑:凡是"拿到一个值类型"的地方(赋值、索引器返回、foreach、传参),拿到的几乎都是副本,改它不影响原来的那个。四、什么时候用 struct/class?——struct 适合"小的、不可变的、表示一个值"的数据(坐标、颜色、金额),无堆分配性能好;class 适合"有身份的、可变的、较大的"对象;经验是默认用 class,只在"小、不可变、值语义、性能敏感"时用 struct;尤其可变的 struct 是公认的坑源(本文),强烈建议 struct 设计成不可变的。
第二件事:正解——改用 class、或重新赋值回去、或用不可变 struct
搞懂了原理,正解就清晰了:需要可变就用 class、用 struct 就重新赋值回列表、或把 struct 设计成不可变(用 record/with)。
// ====== 正解一(最常见): 需要"可变、引用语义"就用 class ======
class Point // ✓ 改成 class(引用类型)
{
public int X;
public int Y;
}
var points = new List { new Point { X = 1 }, new Point { X = 2 } };
foreach (var p in points)
{
p.X = 100; // ✓ p 是引用, 指向列表里的对象, 改它就是改列表里的!
}
// 循环后: points[0].X == 100 ✓
// → 大多数"需要在集合里修改"的场景, 用 class 最直观(引用语义, 改了就生效)。
// ====== 正解二: 坚持用 struct, 就"改完重新赋值回去" ======
struct PointS { public int X; public int Y; }
var list = new List { new PointS { X = 1 } };
for (int i = 0; i < list.Count; i++)
{
var p = list[i]; // 取出拷贝
p.X = 100; // 改拷贝
list[i] = p; // ★ 把改后的拷贝【写回】列表! (覆盖原来的)
}
// list[0].X == 100 ✓ (通过"取出-改-写回"实现修改)
// ====== 正解三(推荐, struct就该这样): 不可变 struct + 创建新实例 ======
readonly struct PointR // ★ readonly struct: 不可变
{
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); // "改"= 创建新实例
}
// 用: list[i] = list[i].WithX(100); // 不可变, "修改"就是创建新的赋回去
// → 不可变struct避免了"改副本"的困惑(它根本不能原地改, 只能创建新的)。
// ====== 正解四(C# 9+ record struct): 简洁的不可变值类型 ======
public readonly record struct Point2(int X, int Y);
// 用 with 表达式"修改"(创建新实例):
// list[i] = list[i] with { X = 100 }; // ✓ 创建一个X改了的新Point2
// → record struct + with, 是现代C#里"不可变值类型"最简洁的写法。
// ====== 正解五: 传 struct 给方法要改它, 用 ref ======
void Move(ref PointS p) { p.X += 10; } // ref: 传引用而非拷贝
// Move(ref myPoint); // ✓ 这样方法内的修改能影响外面
// (但: 一般更推荐"不可变 + 返回新值", 少用 ref 改值类型)
// 核心: 要可变/引用语义就用class(改了就生效); 坚持struct就"取出-改-写回list[i]=p";
// 推荐把struct设计成不可变(readonly struct/record struct + with), 避免"改副本"困惑。
修复的核心,是"要么用引用语义的 class,要么正确处理 struct 的值语义(写回/不可变)"。正解一(最常见):需要"可变、引用语义"就用 class——class 是引用类型,foreach 里的 p 是引用、指向列表里的对象,改它就是改列表里的;大多数"需要在集合里修改"的场景用 class 最直观。正解二:坚持用 struct 就"改完重新赋值回去"——"取出拷贝 → 改拷贝 → list[i] = p 写回",通过"取出-改-写回"实现修改。正解三(推荐,struct 就该这样):不可变 struct + 创建新实例——readonly struct 不可变,"改"就是创建新实例;不可变 struct 避免了"改副本"的困惑(它根本不能原地改)。正解四(C# 9+ record struct):简洁的不可变值类型——readonly record struct + with 表达式"修改"(创建新实例),是现代 C# 里不可变值类型最简洁的写法。正解五:传 struct 给方法要改它用 ref(但更推荐不可变 + 返回新值)。归根结底:要可变/引用语义用 class(改了就生效);坚持 struct 就"取出-改-写回";推荐把 struct 设计成不可变(readonly/record struct + with),避免"改副本"困惑。
第三件事:struct(可变值类型)的其他常见坑
排查后我把可变 struct 的其他常见坑也系统梳理了一遍,它们一样隐蔽。
可变 struct 的其他常见坑
# 1. foreach/索引器取出改不生效(本文): 拿到的是拷贝。→ 写回/用class。
# 2. struct 作为属性, 改它的字段:
# obj.Position.X = 5; // ✗ 可能编译报错或不生效!
# (obj.Position 返回的是拷贝, 改拷贝的X无意义)
# → 改成: var pos = obj.Position; pos.X = 5; obj.Position = pos;
# 3. struct 放进 readonly 字段, 调用会改它的方法:
# readonly Point p; p.Mutate(); // 编译器会"防御性拷贝"再调用!
# → readonly的值类型, 调用其方法时会先拷贝, 改的是拷贝。
# 4. 大 struct 频繁拷贝, 性能反而差:
# - struct 赋值/传参都是拷贝整个值。如果struct很大、又频繁传递,
# 大量拷贝反而比class(只拷贝引用)慢、占更多栈。
# → struct 要"小"(一般建议 ≤ 16字节/几个字段); 大的用 class。
# 5. struct 实现接口时装箱:
# - struct 赋给接口变量时会"装箱"(boxing, 拷贝到堆), 有性能开销。
# 6. 默认值: struct 不能有无参构造设默认值(C#10前), 字段都是零值。
# - new Point() 或 default(Point): 所有字段是0/null/false。
# 共同根源: struct 是值类型, 它的"值语义、处处拷贝"和我们对"对象"的
# 引用语义直觉相悖; 可变struct尤其容易因"改的是拷贝"而出错。
# 核心: 可变struct坑多(foreach/属性/readonly改不生效、大struct拷贝慢、接口装箱); 根源是
# 值语义处处拷贝、与引用直觉相悖; 强烈建议struct设计成不可变, 或干脆用class。
排查让我把可变 struct 的其他坑也梳理清了。一、foreach/索引器取出改不生效(本文)。二、struct 作为属性改它的字段——obj.Position.X = 5 可能编译报错或不生效(obj.Position 返回拷贝),要"取出-改-写回"。三、struct 放进 readonly 字段、调用会改它的方法——编译器会"防御性拷贝"再调用,改的是拷贝。四、大 struct 频繁拷贝性能反而差——struct 赋值/传参拷贝整个值,大 struct 频繁传递反而比 class 慢;struct 要小(≤16 字节)。五、struct 实现接口时装箱(有开销)。六、默认值都是零值。它们的共同根源是:struct 是值类型,它的"值语义、处处拷贝"和我们对"对象"的引用语义直觉相悖;可变 struct 尤其容易因"改的是拷贝"而出错。核心是:强烈建议 struct 设计成不可变,或干脆用 class。下面这张图,是这次改 struct 不生效的成因与解法:
第四件事:值类型 vs 引用类型行为对比速查
这次踩坑后,我把值类型和引用类型的行为差异整理成一张表,用 struct/class 时对照着想。
| 操作 | 值类型 struct | 引用类型 class |
|---|---|---|
| 变量存什么 | 值本身 | 引用(地址) |
| 赋值 a = b | 拷贝整个值,独立 | 复制引用,指向同一对象 |
| 传给方法 | 拷贝(改不影响外面) | 引用(改影响外面) |
| 从 List 取出改 | 改拷贝,不生效(本文) | 改的是原对象,生效 |
| foreach 里改 | 编译错/改拷贝 | 改的是原对象,生效 |
| == 比较 | 比值(逐字段) | 默认比引用(同一对象) |
| 分配位置 | 栈/内联(无堆分配) | 堆(有GC) |
这张表,把值类型和引用类型的行为差异钉死了。核心区别就一条:值类型是"拷贝"语义(处处独立副本),引用类型是"共享"语义(指向同一对象);这一条差异,贯穿了赋值、传参、取出、比较的所有行为。它给我的最大启发是:"值语义"和"引用语义",是两种根本不同的"数据如何被传递和共享"的模型;而很多 bug,都源于"用一种语义的直觉,去操作另一种语义的类型"(我用引用语义的直觉去改值类型的 struct,所以改了不生效)。这其实是一个跨语言的普遍主题:几乎每种语言都有"值 vs 引用"(或类似)的区分——C# 的 struct/class、Java 的基本类型/对象、Go 的值/指针、C++ 的值/引用、JS 的原始类型/对象……;它们的具体规则各异,但核心都是在回答"这个数据,赋值/传递时,是复制一份还是共享同一份?"。所以,每学一门语言、用一个类型,都要搞清楚它的"值语义还是引用语义";因为这个语义,直接决定了"你改它,会不会影响到别处"——而这,是写出正确代码的基础认知之一。
第五件事:可变性带来的复杂度
这次的坑,深层原因是 struct 的"可变性"。我把可变与不可变的对比再次梳理了一下(这次从值类型角度)。
| 维度 | 可变 struct | 不可变 struct(readonly/record) |
|---|---|---|
| 改副本不生效的坑 | ✗ 易踩(本文) | ✓ 避开(根本不能原地改) |
| 防御性拷贝 | readonly字段调方法会拷贝 | 无此问题 |
| 线程安全 | 差(可变共享) | ✓ 天然安全 |
| 可预测性 | 差(可能被意外改) | ✓ 创建后不变 |
| "修改"方式 | 直接改字段(各种坑) | 创建新实例(with) |
| 推荐度 | 不推荐(公认坑源) | ✓ 推荐 |
这张表,再次印证了"不可变"的价值——这次是从值类型的角度。核心是:可变 struct 是公认的坑源(本文就是一例),而不可变 struct(readonly struct/record struct)避开了所有这些坑(根本不能原地改,只能创建新的)。它给我的启发,和我在别的语言里(JS 浅拷贝、Java 集合)反复体会到的一致:"可变性(mutability)",尤其是"共享的可变状态",是大量隐蔽 bug 的温床;而"不可变(immutability)",虽然"修改时要创建新对象"看似麻烦,却从根上消除了一大类问题(意外修改、改副本不生效、并发竞态、状态难追踪)。这让我越来越坚定一个跨语言的设计倾向:默认优先用"不可变"——把数据设计成"创建后就不再改变",需要"变化"时创建新的数据;无论是 C# 的 record、JS 的不可变更新、Java 的 record、还是函数式编程的核心理念,整个行业都在越来越拥抱"不可变"。具体到 struct,这意味着:如果你要用 struct(值类型),就把它设计成不可变的(readonly record struct)——这样既享受值类型的性能,又避开可变 struct 的所有坑。拥抱不可变,是写出更可靠、更可预测代码的一条朴素而强大的原则。
第六件事:用 struct/class 时,我现在的判断习惯
现在每当我要定义一个类型、或操作一个值类型,我都会按这张图先想清楚:
这张图的精髓,是"定义类型先想值语义还是引用语义,操作值类型时记住处处是拷贝"。定义时:有身份/可变/较大的用 class;一个小的、不可变的值才用 struct,且设计成不可变(readonly record struct)。操作已有的值类型时:记住取出/foreach/传参拿到的都是拷贝;要改并生效就写回 list[i]=p、或用 class、或不可变创建新的。最后改完验证原值真的变了吗。这套习惯,让我用 struct/class 时,从"按引用直觉随便改"变成了"先想清楚值/引用语义、操作值类型时警觉拷贝"——核心始终是:struct 是值类型处处是拷贝改副本不生效,默认用 class、struct 要设计成不可变。
我立下的几条规矩
这场"改 struct 不生效"的事故,换来了我写 C# 时,刻进骨子里的几条铁律:
- struct 是值类型,处处是拷贝。赋值/传参/索引/foreach 拿到的都是独立副本。
- 改 struct 副本不影响原值。foreach/list[i] 取出来改,列表里的纹丝不动。
- 默认用 class。只在"小、不可变、值语义、性能敏感"时才用 struct。
- 可变 struct 是公认坑源。用 struct 就设计成不可变(readonly record struct)。
- 坚持用可变 struct 改它,要"取出-改-写回"。list[i] = p 把改后的写回。
- 分清值语义和引用语义。它决定"改它会不会影响别处",是写对代码的基础。
- 不确定改没改成,验证一下。对值类型尤其要确认"原值真的变了"。
附:一段亲眼看清 struct vs class 行为差异的实验
口说无凭。下面用一段对比代码,让你亲眼看到 struct(值)和 class(引用)在"改"时的不同表现:
using System;
using System.Collections.Generic;
class StructVsClassDemo
{
struct PointStruct { public int X; } // 值类型
class PointClass { public int X; } // 引用类型
static void main()
{
// ====== 1. 赋值: struct是拷贝, class是共享 ======
var s1 = new PointStruct { X = 1 };
var s2 = s1; // 拷贝
s2.X = 100;
Console.WriteLine($"struct 赋值: s1.X={s1.X}, s2.X={s2.X}");
// s1.X=1, s2.X=100 ← s1不受影响(独立副本)
var c1 = new PointClass { X = 1 };
var c2 = c1; // 共享引用
c2.X = 100;
Console.WriteLine($"class 赋值: c1.X={c1.X}, c2.X={c2.X}");
// c1.X=100, c2.X=100 ← c1也变了(同一对象)
// ====== 2. 从List取出改: struct不生效, class生效 ======
var listS = new List { new PointStruct { X = 1 } };
var ps = listS[0]; // 拷贝
ps.X = 100; // 改拷贝
Console.WriteLine($"struct 从List改: listS[0].X={listS[0].X}");
// listS[0].X=1 ← 列表里的没变(改的是拷贝)!
var listC = new List { new PointClass { X = 1 } };
var pc = listC[0]; // 引用
pc.X = 100; // 改的是列表里的对象
Console.WriteLine($"class 从List改: listC[0].X={listC[0].X}");
// listC[0].X=100 ← 列表里的变了 ✓
// ====== 3. struct 正确改法: 写回 ======
var p = listS[0];
p.X = 100;
listS[0] = p; // ★ 写回
Console.WriteLine($"struct 写回后: listS[0].X={listS[0].X}");
// listS[0].X=100 ← 写回后才生效 ✓
// ====== 4. 传给方法: struct不影响外面, class影响 ======
Modify(ref ps); // struct要改外面得用ref
}
static void Modify(ref PointStruct p) { p.X = 999; }
}
/* 输出(对比鲜明):
struct 赋值: s1.X=1, s2.X=100 ← struct独立(改拷贝)
class 赋值: c1.X=100, c2.X=100 ← class共享(改同一对象)
struct 从List改: listS[0].X=1 ← struct改不动(本文的坑!)
class 从List改: listC[0].X=100 ← class改得动
struct 写回后: listS[0].X=100 ← struct写回才生效
*/
// 核心: 同样的"改",struct(值)改的是独立拷贝(原值不变)、class(引用)改的是共享对象(原值变);
// struct从List改要写回才生效。跑一遍, 值/引用语义的差别一目了然。
这段对比代码,把"struct 和 class 在'改'时的不同"变成了可以亲眼对比的输出。它用同样的"赋值后改""从 List 取出改""传给方法改"操作,分别试了 struct 和 class:你会清清楚楚地看到,struct 赋值后改副本原值不变(s1.X 还是 1)、从 List 取出改列表里的纹丝不动(listS[0].X 还是 1,正是本文的坑)、写回后才生效;而 class 因为是共享引用,改了到处都变(c1.X、listC[0].X 都变 100)。这一组对比,把抽象的"值语义 vs 引用语义",变成了具体的、肉眼可辨的数字差异。这,正是我想用这段代码,留给每个 C# 开发者的最后一课:"值类型 vs 引用类型"是 C#(乃至很多语言)一个最基础、却最容易因直觉而搞错的概念;而搞清它最好的方式,就是写一段对比代码,让 struct 和 class 在同样的操作下,把它们行为的差异'跑'给你看。当你亲眼看到 listS[0].X 那个固执的 1(struct 改不动)、和 listC[0].X 那个听话的 100(class 改得动)的对比,"值类型是拷贝、引用类型是共享"这个概念,就会以一种具体而牢固的方式刻进你的认知。把抽象的语义差异,变成具体的、可对比的运行现象——这,是我这一整个系列复盘里贯穿始终、屡试不爽的、理解一切"抽象概念"和"反直觉行为"的核心方法。对任何拿不准的语义、行为、规则,别在脑子里空想,写个对比小实验,让代码自己把答案演给你看——这份"动手验证"的习惯,胜过读一百遍文档。
写在最后
回头看,这场由"struct 值类型拷贝"引发的、改了不生效的事故,真正教给我的,远不止"用 class 或写回"这一个技巧。它让我对"同一个动作,在不同语义下含义不同"有了又一次深刻的体会。我栽跟头,是因为"修改一个对象的字段"这个动作,在我熟悉的"引用类型(class/对象)"世界里,含义是"改那个唯一的、共享的对象"(改了到处都生效);可在"值类型(struct)"的世界里,同样一个动作,含义却变成了"改我手里这个独立的副本"(改了别处不受影响)。我用引用世界的直觉("改字段就是改那个对象"),去操作值世界的类型,自然就出了"改了不生效"的偏差。这让我领悟到一个深刻的认知:同样的代码、同样的操作,其"真实含义和效果",会因为操作对象的"底层语义(值 vs 引用)"不同而截然不同;我们写代码时,不能只看"语法上做了什么操作"(p.X = 100),还要看"这个操作作用在什么语义的类型上、实际会产生什么效果"。这其实是一个普遍的道理:"形式相同的操作",在"不同性质的对象/上下文"下,可以有"不同的语义和后果";真正理解代码,不能停留在"它写了什么",而要深入到"它在这个具体的语义环境下,实际做了什么"。透过操作的"形式"看它在具体语义下的"实质"——这,是我用一次"struct 改不动"的事故,换来的、关于 C#、也关于"值语义与引用语义"的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次用 struct 时,先想想"它是值类型、我拿到的是拷贝",那我对着那个怎么改都不变的 struct 列表熬的这大半天,就值了。
—— 别看了 · 2026