知

模块一 · 语言与类型

第 2 / 11 节19 分钟阅读型

C# 类型系统:值语义、引用语义与它的代价

struct 不是"轻量 class",record 也不是"自动 class"。这一节讲清值语义的代价、record 的适用边界、接口与泛型约束。

读完这一节,你能做到

  1. 判断一个模型该用 struct、class 还是 record。
  2. 说清可变结构体的陷阱与 readonly struct 的作用。
  3. 区分接口、抽象类与默认接口方法。
  4. 用泛型约束表达类型契约。

一、struct 与 class

维度structclass
语义值语义,赋值即拷贝引用语义,共享对象
分配栈/内联(可能被装箱)堆
继承不支持类继承支持
默认值全零值,不是 nullnull
适用小、不可变、短生命周期有身份、可变、需要继承

struct 不是"更快的 class":拷贝成本随字段增长,装箱会抵消收益。经验法则是"小于 16 字节、逻辑上像值(坐标、金额、范围)"才用 struct。

二、可变结构体的陷阱

public struct Counter { public int Value; public void Add() => Value++; }

var c = new Counter();
c.Add();            // 直接调用,修改的是变量
Console.WriteLine(c.Value); // 1

var list = new List<Counter> { c };
list[0].Add();      // 编译错误:不能修改返回值的成员

原因:属性/索引器返回的是副本,修改副本没有意义。修法:

  • 把 struct 设计成不可变(readonly struct + 返回新值)。
  • 需要可变时用 class。
  • 用 ref 返回或 CollectionsMarshal 访问引用(不推荐滥用)。

readonly struct 让编译器知道所有字段不可变,减少防御性拷贝;in 参数避免大 struct 拷贝。

三、record:值语义的引用类型

record 默认是引用类型,但生成值相等性、ToString、with:

public record Money(decimal Amount, string Currency);
var a = new Money(10, "CNY");
var b = a with { Amount = 20 };
Console.WriteLine(a == new Money(10, "CNY")); // true

适用:DTO、事件、配置、不可变模型。不适合:需要可变状态、需要引用身份、频繁修改的大对象(每次 with 都新建)。

record struct 是值类型版本,适合小数据;readonly record struct 更彻底。

四、接口与抽象类

维度接口抽象类
多继承可以只能单继承
字段不能可以
构造逻辑不能可以
默认实现C# 8+ 支持支持
适用能力契约共享实现

选择标准:描述"能做什么"用接口,共享"怎么实现"用抽象类。默认接口方法适合给已有接口加方法而不破坏实现类,但不要用它替代抽象类。

五、泛型与约束

public T Max<T>(T a, T b) where T : IComparable<T>
  => a.CompareTo(b) >= 0 ? a : b;

常见约束:

约束含义
where T : class引用类型
where T : struct非空值类型
where T : new()有无参构造
where T : IComparable<T>实现接口
where T : unmanaged非托管类型

泛型在运行时会为值类型生成专用代码,避免装箱;引用类型共享同一份代码。

可空与泛型的交互

where T : notnull 可以约束泛型参数不接受可空类型;T? 在引用类型与值类型下语义不同:值类型是 Nullable<T>,引用类型只是可空注解。写通用库时要特别小心这两种差异。

六、委托、事件与表达式树

  • 委托:类型安全的函数指针,Func/Action 是常用泛型委托。
  • 事件:委托的封装,外部只能 += / -=,不能直接触发。
  • 表达式树:把 lambda 表示为数据结构,EF Core 用它翻译 SQL。

区分 Func<T> 与 Expression<Func<T>>:前者是编译后的委托,后者是可分析的树。ORM 需要后者。

七、模式匹配与类型判断

is 模式匹配可以在判断的同时声明变量:

if (obj is User { Age: >= 18 } user)
    Console.WriteLine(user.Name);

switch 表达式适合把“类型 → 结果”的映射写成表达式,避免长串 if-else。注意模式匹配按顺序命中,范围窄的放前面。

八、不可变与 with 的边界

with 是浅拷贝:引用类型字段仍然共享同一个对象。想要深不可变,字段本身也要用不可变类型(ImmutableArray、record、只读集合)。

不可变的代价是每次修改都新建对象;高频修改的场景用可变对象 + 局部控制,不要为了“纯”牺牲性能。

装箱与泛型约束

把 struct 传给 object 或非泛型接口会装箱;泛型方法用 where T : IEquatable<T> 可以避免装箱并调用强类型方法。

补充:record 的相等性细节

record 的相等性逐字段比较,但引用类型字段仍用引用相等;字符串会按值比较(string 重写了 Equals)。集合字段默认比引用,需要值比较得自己重写或换不可变集合。

选择清单:DTO/事件用 record;坐标/金额等小值用 readonly struct;有身份、要继承、要可变用 class。选择先看语义,再看性能;语义优先。语义优先。先语义。

九、常见坑

  1. 用可变 struct 做集合元素,修改无效。
  2. 大 struct 频繁拷贝,性能反而不如 class。
  3. record 里有可变引用字段,相等性仍按引用比较该字段。
  4. 接口默认方法里访问不到实现类的私有状态。
  5. 表达式树里写多语句 lambda,无法翻译。

十、动手任务:自写 → AI 写 → 对比 → 取舍

四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。

① 自己写

  1. 写一个 readonly record struct 表示金额,验证相等性与 with。
  2. 复现"修改 List 中 struct 元素失败",再用 class 修好。
  3. 写一个泛型方法,用 where T : IComparable<T> 求最大值。

② 让 AI 写同一题

把第 2 题(struct 与 class 的选择)交给 AI,补一句 说明各自的分配位置与拷贝代价。

③ 对比要盯的点

  • 它把 struct 说成“一定在栈上”了吗?被装箱或在类字段里就不在。
  • record 的相等语义它讲对了吗?值相等是基于所有字段的。
  • 装箱发生在哪里,它指出来了吗?比如把 struct 塞进 object 或接口。

④ 一句话取舍

它选 struct 的理由,在大对象或频繁传参时还成立吗?

十一、自测

  1. struct 与 class 的适用边界?
  2. 可变 struct 为什么会修改无效?
  3. record 与 class 的核心差异?
  4. 接口与抽象类怎么选?
  5. Func<T> 与 Expression<Func<T>> 的区别?

参考答案要点:值/引用与大小;返回副本;值相等与 with;能力 vs 共享实现;委托 vs 表达式树。

附录:高频追问

问:struct 一定在栈上吗? 不一定。作为类字段、被装箱、被闭包捕获时都在堆上;"栈分配"只是常见情况。

问:为什么推荐 readonly struct? 避免防御性拷贝,明确不可变语义,编译器可以优化。

问:record 的相等性怎么实现的? 编译器生成 Equals 与 GetHashCode,逐字段比较;引用类型字段仍按引用比较。

问:record 能被继承吗? 可以,但只能继承 record;record class 支持继承,record struct 不支持。

问:接口能有字段吗? 不能,只能有属性、方法、事件、索引器;C# 8+ 可以有静态字段与默认实现。

问:抽象类能有构造函数吗? 能,供子类调用;接口不能有实例构造函数。

问:泛型约束能组合吗? 能,顺序有要求:class/struct 在前,new() 在后。

问:委托是值类型还是引用类型? 引用类型;Action/Func 都是委托类型。

问:事件为什么不能外部触发? 事件只暴露 +=/-=,触发由声明方控制,避免外部误触发。

问:表达式树能编译成委托吗? 能,Compile() 可以,但有编译成本,适合缓存后复用。

配套题目

★ 越多越该先背;点题目可看完整答案与深入版。

提示:读完可以顺手做几道对应题目,检验是否真的理解