把 List 里的 struct 取出来改了字段、列表里的原值却纹丝没动像没被改过:C# 值类型与引用类型分不清导致改了副本的避坑复盘

这是一个我明明改了它它却没变的诡异 bug,让我对 C# 里值类型和引用类型的区别有了刻骨铭心的理解。事情是这样的:我们有一个表示坐标点的结构体 struct Point,我把一堆这样的点装进了一个 List 里,某个逻辑需要修改列表里某个点的 X 坐标,我很自然地写了先把那个点取出来 var p = list0 改一下 p.X = 100。改完我满以为列表里的那个点就变成 X=100 了,可一打印列表那个点的 X 还是原来的值纹丝没动。我盯着代码百思不得其解:我明明把它取出来改了它的 X 啊怎么列表里的它就跟没被改过一样?排查到最后真相指向了 C# 里一个最基础却最容易让人栽跟头的概念——值类型和引用类型的区别。原来 struct 是值类型,而值类型有一个和引用类型 class 截然不同的本质特性:当你把一个值类型赋值给另一个变量或者从集合里取出它时得到的是它的一份完整的拷贝而不是对原始数据的引用,所以我写的 var p = list0 p 拿到的是 list0 那个点的一份独立的副本,我接下来改 p.X 改的只是这份副本的 X,列表里那个原件和我手里的副本是两个不同的东西自然丝毫不受影响,我以为我在改列表里的那个点其实我只是在改它的一个影分身。这篇文章从这次改了值类型副本原值却没变的事故出发,讲透 C# 值类型与引用类型避坑:理解复制数据与复制引用的区别、要么取副本改完整体设回要么干脆用 class、按语义而非随手选 struct 还是 class、传参遍历装箱属性返回也都是复制的亲戚坑、把默认用 class 的判断练成本能,以及一个根本认知——越是基础的概念越要真正吃透。

这是一个"我明明改了它,它却没变"的诡异 bug,让我对 C# 里"值类型"和"引用类型"的区别,有了刻骨铭心的理解。事情是这样的:我们有一个表示坐标点的结构体 struct Point { public int X; public int Y; },我把一堆这样的点装进了一个 List<Point> 里。某个逻辑需要修改列表里某个点的 X 坐标,我很自然地写了:先把那个点取出来 var p = list[0];,改一下 p.X = 100;。改完,我满以为列表里的那个点就变成 X=100 了。可一打印列表,那个点的 X,还是原来的值,纹丝没动!我盯着代码,百思不得其解:我明明把它取出来、改了它的 X 啊,怎么列表里的它,就跟没被改过一样?

排查到最后,真相指向了 C# 里一个最基础、却最容易让人栽跟头的概念——值类型(value type)和引用类型(reference type)的区别。原来,struct值类型,而值类型有一个和引用类型(class)截然不同的本质特性:当你把一个值类型赋值给另一个变量、或者从集合里取出它时,得到的是它的一份"完整的拷贝",而不是对原始数据的"引用"。所以,我写的 var p = list[0];,p 拿到的,是 list[0] 那个点的一份独立的副本;我接下来改 p.X = 100;,改的只是这份"副本"的 X,而列表里那个"原件",和我手里的副本是两个不同的东西,自然丝毫不受影响。我以为我在改"列表里的那个点",其实我只是在改"它的一个影分身"。这篇文章,就从这次"改了值类型副本、原值却没变"的事故讲起,把 C# 里值类型与引用类型这个最基础、却处处暗藏玄机的区别,讲清楚。

故障现场:被改的,只是一个"影分身"

先把这个"影分身"的过程还原一下:

struct Point { public int X; public int Y; }   // struct 是【值类型】!

var list = new List { new Point { X = 1, Y = 1 } };

// 反面: 取出来改, 改的是副本, 原值不变!
var p = list[0];     // p 拿到的是 list[0] 的一份【拷贝】(值类型赋值=复制)
p.X = 100;           // 改的是 p 这个副本的 X
Console.WriteLine(list[0].X);   // 还是 1 ! 列表里的原值, 纹丝没动

// 更直接地改, 编译器直接报错:
list[0].X = 100;     // 编译错误! "无法修改返回值, 因为它不是变量"
//                      因为 list[0] 返回的是个临时副本, 改它没意义, 编译器直接拦住

// 对比: 如果 Point 是 class(引用类型), 上面就都正常了:
// class Point { ... }  → var p = list[0]; 此时 p 和 list[0] 指向【同一个对象】
//                         p.X = 100; → list[0].X 也变成 100 (引用同一个)

看明白这个"影分身"是怎么回事了吗?核心,就在于 struct 是"值类型"——值类型的变量,直接存的是"数据本身";所以当你把它赋值、传参、从集合取出时,是把"数据本身"完整地复制了一份。我的 var p = list[0],就是把列表里那个点的数据,原原本本地复制了一份给 p;此后 plist[0],就是两份独立的数据了,我改 p,跟 list[0] 没有半毛钱关系。而那个 list[0].X = 100 直接报编译错误,更是把这层意思摆到了明面上:list[0] 返回的是一个临时的副本,你去改一个"马上就要被丢弃的临时副本"的字段,毫无意义,所以编译器干脆直接拦住你、不让你这么写。

而如果 Point 是一个 class(引用类型),这一切就都对了——因为引用类型的变量,存的不是"数据本身",而是"指向数据的引用(地址)";var p = list[0] 复制的,只是那个"引用",所以 plist[0] 会指向同一个对象,我通过 p 改这个对象,list[0] 看到的自然也是改过的。同样一句 var p = list[0]; p.X = 100;,Point 是 struct(值类型)时,改的是副本、原值不变;是 class(引用类型)时,改的是同一个对象、原值跟着变——这一字之差(struct 还是 class),导致了天壤之别的行为。这就是值类型和引用类型最本质、也最坑人的区别:一个"赋值即复制(各自独立)",一个"赋值即共享引用(指向同一个)"。我那次的坑,正是把一个值类型(struct),当成引用类型(class)去用了——以为取出来的是"原件的引用",其实是"原件的副本"。

第一件事:理解值类型与引用类型——"复制数据" vs "复制引用"

要避开这个坑,核心是把"值类型"和"引用类型"这个 C# 最基础的区别,彻底搞清楚:值类型的变量,直接存储"数据本身";赋值/传参时,复制的是"整个数据"(各自独立)。引用类型的变量,存储的是"指向数据的引用(地址)";赋值/传参时,复制的是"引用"(于是两个变量指向同一份数据)。

// 值类型(struct, int, bool, enum, 以及自定义struct): 赋值=复制整个数据
int a = 5;
int b = a;     // b 是 a 的拷贝
b = 10;        // 改 b
// a 还是 5 (a 和 b 是两份独立的数据)

struct PointV { public int X; }
var p1 = new PointV { X = 1 };
var p2 = p1;   // p2 是 p1 的完整拷贝
p2.X = 99;     // 改 p2
// p1.X 还是 1 (各自独立)

// 引用类型(class, 数组, string, 各种对象): 赋值=复制引用(指向同一个)
class PointR { public int X; }
var r1 = new PointR { X = 1 };
var r2 = r1;   // r2 和 r1 指向【同一个对象】
r2.X = 99;     // 通过 r2 改这个对象
// r1.X 也变成 99 ! (r1 和 r2 是同一个对象的两个引用)

关键认知是:判断"改一个变量会不会影响另一个",根本上取决于它是值类型还是引用类型——值类型,赋值时各自复制了一份独立的数据,改一个不影响另一个;引用类型,赋值时大家指向了同一份数据,改一个,所有指向它的都跟着变。这是 C# 里一条贯穿始终的、最基础的规则,而 struct(值类型)和 class(引用类型)的选择,正是这条规则的总开关。所以,用一个类型时,第一件要搞清楚的事,就是:它是值类型还是引用类型?——这决定了它"赋值、传参、放进集合"时,是"复制一份独立的"还是"共享同一个";而这个区别,直接决定了你对它的修改,到底会不会"生效"、会不会"影响到别处"。我那次的根本失误,就是没意识到我那个 struct Point 是值类型,把它当引用类型去"取出来改"了。C# 里,intboolenum、以及所有你用 struct 定义的类型,都是值类型;而 class、数组、string、以及绝大多数对象,都是引用类型——这个归属,要心里有数。

第二件事:正解——要么整体替换,要么干脆用 class

知道了"struct 取出来改的是副本",那"修改集合里的 struct"该怎么正确地写?有几种办法,核心是:既然改副本没用,那就"改完副本,再把整个副本设回去",或者用索引器整体赋值;或者,如果这个类型本就该被共享修改,干脆把它定义成 class。

struct Point { public int X; public int Y; }
var list = new List { new Point { X = 1, Y = 1 } };

// 正解1: 取出副本 → 改副本 → 把整个副本设回去(覆盖原值)
var p = list[0];          // 取出副本
p.X = 100;                // 改副本
list[0] = p;              // ★ 把改好的副本, 整个设回 list[0] 覆盖原值
// 现在 list[0].X 才真的是 100

// 正解2(C# 7+): 用数组/Span 的 ref 索引, 或在数组上直接改
var arr = new Point[] { new Point { X = 1 } };
arr[0].X = 100;           // 数组的 arr[0] 返回的是 ref(可直接改, 不是副本)!
//                          (这是数组和 List 索引器的一个区别: 数组元素可直接改)

// 正解3(最该考虑): 如果这个类型"需要被多处共享、修改", 它就不该是 struct
class PointC { public int X; public int Y; }   // 改成 class(引用类型)
var listc = new List { new PointC { X = 1 } };
listc[0].X = 100;         // 直接改就生效! 因为是引用类型, 改的是同一个对象

这几种正解,对应不同的取舍:正解1(整体替换)是在"坚持用 struct"的前提下,最直接的修法——承认"改副本没用",那就改完副本后,用 list[0] = p 把整个改好的副本,覆盖回原来的位置。这虽然啰嗦,但语义清晰、绝不出错。正解2(数组直接改)利用了一个细节:数组的索引器(arr[0])返回的是元素的"引用"(可直接改),而 List<T> 的索引器返回的是"副本"——所以同样的 arr[0].X = 100,在数组上能改、在 List 上报错,这是个容易混淆的点。正解3(改成 class)是更根本的反思:如果你发现自己总想"修改集合里的某个元素、并希望这个修改生效",那说明这个类型,本质上是一个"需要被共享、被原地修改"的东西——而这恰恰是引用类型(class)的语义,而非值类型(struct)的语义。这时,与其用各种技巧去绕过 struct 的"复制"特性,不如顺其自然地把它定义成 class,问题就迎刃而解了。我把"struct 修改"的几种姿势画成图:

这张图的核心提醒是:当你和 struct 的"复制特性"反复较劲、总想原地改它却改不动时,先停下来问一句——这个类型,真的该是 struct(值类型)吗?很多时候,答案是"它本该是 class"。下一节,就来讲怎么从根上做对这个选择。

第三件事:struct 还是 class?——按"语义"选,而非随手定

这次事故的根源,其实是一个更前置的设计问题:当初定义这个 Point 时,我随手就用了 struct,根本没认真想过"它该是 struct 还是 class"。而 struct 和 class 的选择,绝不是随手的事——它决定了这个类型"复制还是共享"的根本语义。那么,到底该怎么选?

// 该用 struct(值类型)的情况: 小巧、不可变、表示一个"值"
struct Money { public readonly decimal Amount; public readonly string Currency; }
//   - 小(字段少, 复制开销小)
//   - 表示一个"值"(像数字一样, 两个相等的就是"一样"的, 没有"身份")
//   - 不可变(创建后不改, 也就没有"原地修改"的需求 → 自然避开本文的坑)

// 该用 class(引用类型)的情况: 较大、有身份、需要被共享和修改
class Order {           // 订单: 有唯一身份, 会被多处引用、修改状态
    public int Id;
    public string Status;   // 状态会变, 且改一处要让所有引用都看到
}

// 官方的经验法则(大致): 优先用 class; 只在同时满足这些时才考虑 struct:
//   - 逻辑上表示一个单一的值(像 int、像坐标)
//   - 实例很小(一般 < 16字节)
//   - 不可变(immutable)
//   - 不会被频繁装箱

选 struct 还是 class,要看这个类型的"语义":如果它表示一个"值"——像数字、坐标、金额这样,小巧、不可变、"两个相等的就是同一个"(没有独立的身份),那适合用 struct(值类型);如果它表示一个"实体"——像订单、用户、连接这样,有自己的身份、较大、需要被多处引用并共享地修改状态,那就该用 class(引用类型)。一个特别有用的判断是:这个类型,需不需要"在一处修改、让引用它的所有地方都看到这个修改"?需要,就该是 class(它的"共享"语义正好满足);不需要(每份各管各的),才考虑 struct。而我那个 Point,正因为我有"修改列表里的它、并希望生效"的需求,就说明它在我的场景里,其实需要"共享修改"的语义——它本该是 class,我却用了 struct,这才埋下了坑。所以,定义一个类型时,别随手就 struct 或 class,而要想清楚它的语义:它是一个"值",还是一个"有身份、需共享的实体"?——这个有意识的选择,能从源头避开值类型/引用类型用错而导致的一大类问题。(微软官方的经验法则是:默认优先用 class,只在"逻辑上是单一的值、实例很小、不可变、不会频繁装箱"这几个条件都满足时,才考虑用 struct。)

第四件事:值类型的"复制"特性,还藏着几个亲戚坑

"值类型赋值即复制"这个根源,还会在另外几个地方咬你。认全这些"亲戚坑",才能彻底驯服值类型。

struct Point { public int X; }

// 坑1: 传参也是复制! 方法里改 struct 参数, 不影响调用方
void Reset(Point p) { p.X = 0; }   // 改的是参数副本
var pt = new Point { X = 5 };
Reset(pt);
// pt.X 还是 5 ! (传值=复制, 方法内的修改不影响外面)
// 要让方法能改它: 用 ref 传引用 → void Reset(ref Point p)

// 坑2: foreach 遍历值类型集合, 拿到的也是副本, 改它无效
foreach (var item in list) { item.X = 0; }   // 改的是副本, list没变(且新版C#会编译报错)

// 坑3: 装箱(boxing) —— 值类型被当成 object/接口用时, 会被"装箱"复制到堆上
object o = pt;        // 装箱: pt 被复制一份包进一个堆对象
((Point)o).X;         // 拆箱又是一次复制 ... 频繁装箱拆箱有性能开销

// 坑4: 把可变 struct 设为属性, 改它的字段无效(返回的是副本)
//   obj.SomeStructProp.X = 1;  // 报错/无效, 因为 SomeStructProp 返回副本

// → 这些坑的根源都一样: 值类型在"传递、遍历、装箱、属性返回"时, 都是复制!

这几个坑,根源都是同一个——值类型在任何"传递"的场合(赋值、传参、从集合/属性取出、遍历),都是"复制一份"而非"共享同一个"。坑1(传参复制):把 struct 传进方法,方法里对它的修改,影响不到调用方的原值(因为传进去的是副本);要让方法能改它,得用 ref 关键字显式地"传引用"。坑2(foreach 副本):遍历值类型集合,每次拿到的 item 是副本,改它对集合无效(和我那次的坑同源)。坑3(装箱):当值类型被赋给 object 或接口类型时,会发生"装箱"——把这个值类型复制一份、包进一个堆上的对象里;频繁的装箱拆箱不仅是复制,还有性能开销(这也呼应了我之前讲 Java 自动拆箱时聊的装箱)。坑4(可变 struct 当属性):把一个可变 struct 作为属性,试图改 obj.Prop.X 也会无效或报错,因为属性返回的是副本。正因为这些"复制"特性如此处处藏坑,业界有一条强烈的建议:让你的 struct 尽量"不可变(immutable)"——如果一个 struct 创建后就不能改它的字段(字段都是 readonly),那么"改它的副本"这种事根本就不会发生,上面这一整类坑也就自然消失了。把值类型和引用类型的关键区别整理成一张表:

维度 值类型(struct/int...) 引用类型(class...)
变量存的是 数据本身 指向数据的引用(地址)
赋值/传参 复制整个数据(各自独立) 复制引用(指向同一个)
改一个影响另一个吗 不影响(独立) 影响(同一对象)
存储位置(通常) 栈 / 内联在对象里
典型 int/bool/enum/自定义 struct class/数组/string/对象
适合表示 小巧、不可变的"值" 有身份、需共享的"实体"

第五件事:把"struct 还是 class"的判断练成本能

这次事故让我把"该用 struct 还是 class"这个判断,郑重地纳入了每次定义类型时的考量。我把这个判断的要点,整理成一张速查表:

看这个类型... 倾向 struct 倾向 class
语义 是一个"值"(像数字/坐标) 是一个"实体"(有身份, 像订单)
大小 小(字段少, 一般<16字节) 较大
可变性 不可变最好 可变, 状态会改
需要共享修改吗 不需要(各份独立) 需要(改一处, 处处可见)
会频繁当 object/接口用吗 不会(否则装箱开销) 无所谓
默认建议 满足以上才用 优先用(默认选择)

这张表的核心判断,落到一个最实用的问题上:"这个类型,在我的场景里,需要被'共享地修改'吗?"——如果你会把它放进集合、传来传去,并期望"在一处改了它,别处也能看到这个改动",那它就需要"引用语义",该用 class;如果它只是一个被四处复制、各用各的、像数字一样的"值",才考虑用 struct(且最好是不可变的)。而一个最稳妥、最不容易踩坑的默认策略是:拿不准时,就用 class。因为 class 的"引用、共享"语义,符合我们大多数人对"对象"的直觉(取出来改、改了就生效),不容易产生"改了却没变"的意外;而 struct 的"复制"语义,虽然在特定场景下(小、不可变的值)有性能等优势,但它那"处处都是副本"的特性,恰恰是各种隐蔽 bug 的来源。所以,除非你明确知道"这个类型该是值类型、且用 struct 有明显好处",否则就老老实实用 class——把这个"默认 class、特殊才 struct"的策略当成本能,你就能从源头,避开值类型那一整套'改了副本原值不变'的坑。

一张"struct 还是 class、怎么改"的决策图

把这次踩坑沉淀成一张图。定义类型时、或要修改值类型时,照着它判断:

这张图的主线:拿不准就用 class;只有"小巧、不可变的值"才用 struct;真要改集合里的 struct,就整体设回去;改不动还较劲,说明它本该是 class。把这个判断变成习惯,值类型的"复制坑"就基本与你绝缘。

我立下的几条值类型使用规矩

这次"改了 struct 副本原值不变"的事故后,我给自己立了几条规矩:

  1. 分清值类型与引用类型:用一个类型前先搞清它是 struct(值,复制)还是 class(引用,共享),这决定了修改会不会影响别处。
  2. 默认用 class:拿不准就用 class;只在"小巧、不可变、表示单一值、不频繁装箱"都满足时才用 struct。
  3. struct 尽量不可变:如果用 struct,尽量设计成不可变(字段 readonly),从根上避开"改副本"的一整类坑。
  4. 改集合里的 struct 要整体设回:取出副本改完,用 list[i] = p 整体覆盖回去,别指望 list[i].X=... 生效。
  5. 方法要改 struct 用 ref:想让方法修改传入的值类型并影响调用方,显式用 ref 传引用。
  6. 警惕装箱开销:别让值类型频繁地被当 object/接口用(装箱),既有复制语义又有性能开销。
  7. 改了没生效先查值/引用:遇到"我明明改了它却没变",优先排查它是不是值类型、改的是不是副本。

这几条里,第一条是总纲,第二条"默认用 class"是最实用的避坑策略。我这次的教训,本质上是对"我用的这个类型,到底是值还是引用"这件最基础的事,缺乏清醒的认知——我随手用了 struct,却完全没意识到这个选择,给这个类型注入了"复制"的语义,而这个语义,和我"取出来改、期望生效"的用法,是根本冲突的。值类型和引用类型的区别,是 C#(乃至很多语言)最基础的概念之一,基础到我们常常觉得"这还用说",以至于反而疏于在每一次具体使用时去认真对待它。可恰恰是这个最基础的区别,在不经意间,导致了我这种"改了却没变"的、让人摸不着头脑的 bug。越是基础的概念,越要扎实——这是这次踩坑给我的一记提醒。

写在最后:越是基础的概念,越要真正吃透

这次被值类型坑到的经历,给我一个朴素却深刻的反思:我们作为有经验的开发者,常常会不自觉地"轻视"那些最基础的概念——值类型与引用类型、栈与堆、传值与传引用……我们觉得"这些我早就懂了",于是在实际编码时,反而不再认真地去想它们,凭着一种模糊的"感觉"在用。可这次事故狠狠地提醒我:对这些最基础概念的"模糊的懂",和"真正吃透、能在每个具体场景准确运用",是两码事;而恰恰是这种"自以为懂、实则模糊"的状态,最容易在某个不经意的地方,栽一个让你百思不得其解的跟头。"值类型赋值是复制"——这条规则我当然"知道",可在"取出 list 里的 struct 来改"这个具体场景里,我却没能把这条规则准确地应用上,反而下意识地按"引用"的直觉去用了。我知道这条规则,却没真正"吃透"它。

想通这一点,我对"打牢基础"这件事,有了新的敬畏。我们总是热衷于学习新框架、新技术、新概念,觉得那才是"进步";却容易忽略,对那些最基础、最底层的概念的"再深入、再吃透",同样、甚至更是一种重要的进步。因为越是基础的概念,它的影响面就越广、越底层——值类型与引用类型,影响着你写的几乎每一行涉及对象传递、修改的代码;你对它理解得有多透,直接决定了你能不能在那无数个具体场景里,都准确地预判出"这次修改,到底会不会生效、会不会影响别处"。一个对基础概念真正吃透的人,和一个只是"模糊地知道"的人,写出的代码,在可靠性上会有微妙却深刻的差距——后者会在各种基础概念的边界处,时不时地踩坑(就像我这次),而前者则能稳稳地绕开它们。地基的扎实程度,决定了上层建筑能盖多高、多稳。

所以,如果你也想成为一个更可靠的工程师,我想把这次踩坑最想说的话送给你:别轻视那些你"自以为早就懂了"的基础概念,时不时地、谦逊地,回过头去把它们再吃透一遍。值类型与引用类型、栈与堆、同步与异步、进程与线程……这些最基础的东西,值得你反复地、深入地去琢磨,直到你不仅"知道"它们的定义,更能在任何一个具体的、刁钻的场景里,都准确地推断出它们的行为。因为编程的可靠性,归根到底,是建立在对这些最基础概念的扎实掌握之上的;华丽的技巧、时髦的框架是锦上添花,而对基础的真正吃透,才是那个让你少踩无数隐蔽坑、行稳致远的根基。那个只是一个"影分身"的 struct,最终教给我的,正是这份对"基础"的敬畏——它让我明白,真正的高手,不是会的概念多新、多炫,而是把那些最基础、最朴素的概念,理解得比谁都更深、更透、更扎实。愿你我都能沉下心来,把脚下的地基,一寸一寸地夯得更实。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

用户下了单没收到短信、买了东西积分没加,订单明明下单成功后续处理却像从来没发生过一样凭空消失:消息队列消息丢失的端到端可靠性避坑复盘

2026-6-1 17:49:25

技术教程

一个合法的 0 被 || 悄悄吃成了 3000:我在 TypeScript 里因为分不清逻辑或和空值合并而踩的取默认值大坑,以及 || 与 ?? 精确区别的全面复盘

2026-6-1 18:03:47

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索