值类型、装箱和防御性拷贝:一次 C1 复习复盘
这篇复盘解决一件事:把 C# 类型系统里「赋值怎么拷、值放哪、何时装箱、窄化与 checked、struct 防御性拷贝」从能写出代码,整理成面试里能讲清楚、讲对的说法。
写给同在补基础的同行,也写给以后可能读到它的面试官:下面按主题给口径,不铺过程流水账。
背景
离职求职期间,我把 C# 类型系统从「能用」推到「说得清」。方法是固定五步:读官方文档 → 追问式 Q&A → 自写「类型行为观察器」demo(先写预测再跑)→ 费曼复述 → 模拟面试,再用错题补强。
五步都做完了。文档侧对照了 Microsoft Learn 的类型系统、值/引用、装箱拆箱、可空值类型和数值转换;Q&A 把几个长期绝对化的判断掰正(值类型不总在栈上、装箱看最终调到的是不是基类方法、null 的 int? 装箱结果是空引用、常量窄化在编译期检查)。demo 跑出 20 行输出,与预测全部吻合。费曼口述里仍有过概念说反,纠正后才过。模拟面试压力下,已经纠正过的点又会退回去,于是针对「防御性拷贝」和「字符串驻留」做了补强复测,直到能稳定讲对。
下面用分栏按主题写结论。
赋值语义 :值类型赋值是完整复制一份新的副本;引用类型赋值只把地址交给对方,两个变量指向同一对象。
值类型存在它被声明的地方,而不是「一律在栈上」:
- 方法里的局部变量和参数:通常在栈上(未被提升时)
- 类的字段、数组元素、装箱后的值、被闭包捕获的变量、async 里跨
await的局部变量:在堆上
这句话比「值类型在栈、引用类型在堆」更准。存储位置由生命周期和归属决定,不由「是不是 struct」单独决定。
装箱 :在堆上为值类型分配一块空间,把值拷进去,再返回指向它的引用。不是「存个地址」——堆对象里是值的副本。
隐蔽装箱常见几类:
- 赋给
object或接口变量 - 放进非泛型集合(如
ArrayList) - 调用结构体没有重写的
Equals/GetHashCode/ToString(最终落到基类虚方法时会装箱)
是否装箱要看实际绑定到哪份实现。例如 Vector3 重写了 ToString,走重写路径时不会为了这次调用去装箱。(待核实:各 Unity 版本/运行时对具体 API 的 JIT 细节以 Profiler 的 GC Alloc 为准。)
int? 的本质是 Nullable<T> 泛型结构体(Nullable :HasValue + Value),不是引用类型。为 null 的 Nullable<T> 装箱不会抛异常,结果是空引用;再拆箱成 int 会得到空引用异常,而不是「装出一个 null 的 int」。
1 | object boxed = (int?)null; // boxed == null |
窄化规则:超出目标类型的高位直接截断。默认是 unchecked——变量转换按截断绕回,不抛异常。例如 int 变量 300 转 byte 得到 44(对 256 取模)。
常量表达式在编译期检查:字面量越界会直接编译报错(如 (byte)300)。写在 checked 里,运行时溢出抛 OverflowException。
1 | int n = 300; |
容易说反的一点:默认行为是静默截断,不是默认抛异常。checked 是显式打开检查;常量路径则更早,在编译期就拦。
何时用 struct:小、表示值语义、尽量不可变。滥用可变大 struct,会把复制成本和隐蔽副作用一起带进热路径。
防御性拷贝 :对 readonly 字段或 in 参数调用非 readonly 实例方法时,编译器先拷一份临时副本再调方法,避免可变方法改到原字段/原参数。表现是:你以为在改「当前这个」,实际改的是副本;同时多一次拷贝。
改法:
- 把类型做成
readonly struct,或 - 把不改状态的方法标成
readonly方法
这样编译器不必再防你改,也就不必多拷一次。
1 | readonly struct Point |
(若方法会改字段却未标 readonly,对 in/readonly 字段调用时仍会先拷贝——这正是要消除的那次拷贝。)
结论与未尽
到这里,赋值语义 、存储位置、装箱 与 Nullable<T>、窄化与checked 、struct 与防御性拷贝 ,我已经能按上面的口径讲清楚,也能用 demo 对上行为。
刻意往后挂的几块,不在这篇展开:
IEquatable<T>(避免当字典 key 时的装箱路径)→ 后续专题- Unity 假 null 与
?./??的坑 → 后续专题 - TMP
SetText与零分配字符串路径 → 性能专题
类型系统这一层,目标不是背术语,而是面试追问「为什么」时,判断还能站住。


