struct 与 class 选型
本教程共 100 篇 · 第 92 篇 · 更新于 2026-07-31 · 约 9 分钟阅读
92. struct 与 class 选型
本节目标:学完能根据「值语义、大小、是否要继承」判断一个类型该用 struct 还是 class。
第 9 章我们说过值类型(value type)和引用类型(reference type)的区别,本章落到具体选型上。C# 里 struct 是值类型,class 是引用类型。选错不只是风格问题,还会影响正确性和性能。
一、赋值时的根本差异
struct 赋值时复制整份数据,class 赋值时只复制引用(内存地址)。看这段代码最直观:
namespace CSharpDemo;
struct Point
{
public int X;
public int Y;
}
var a = new Point { X = 1, Y = 2 };
var b = a; // 复制整个 Point
b.X = 99;
Console.WriteLine(a.X); // 仍是 1,a 没被改
var c = new Point { X = 1 };
Console.WriteLine(c.Equals(a)); // False:字段不完全相同
改 b.X 不影响 a,因为 b 是 a 的独立副本。引用类型则相反:两个变量指向同一个对象,一个改了另一个也能看到。
把这个差异记住一个比喻:struct 像复印件,你改复印件不影响原件;class 像微信好友的链接,谁点进去看到的都是同一个对话框。
二、默认相等性怎么算
struct 默认按字段逐值比较(Equals 会比较每个字段)。所以两个字段都相同的 Point 视为相等。但注意:== 运算符对自定义 struct 默认不可用,除非你自己重载 == 或改用 record struct。
namespace CSharpDemo;
record struct Point(int X, int Y);
var a = new Point(1, 2);
var b = new Point(1, 2);
Console.WriteLine(a == b); // True:record struct 自动生成 == 与 Equals
Note逐字段比较靠反射实现,对字段很多的 struct 会比较慢。若频繁比较相等,用
record struct让编译器生成高效的Equals和==。
class 默认比较引用:只有两个变量指向同一个对象才算相等,除非你重写 Equals 或用 record class。
三、readonly struct:把不可变写进类型
如果你希望 struct 永远不被改动,标成 readonly struct。编译器会禁止任何修改其状态的成员,还能帮你在传 in 参数时避免「防御性复制」。
namespace CSharpDemo;
readonly struct Point
{
public double X { get; }
public double Y { get; }
public Point(double x, double y) => (X, Y) = (x, y);
public double DistanceTo(Point other)
{
var dx = X - other.X;
var dy = Y - other.Y;
return Math.Sqrt(dx * dx + dy * dy);
}
}
var p = new Point(0, 0);
Console.WriteLine(p.DistanceTo(new Point(3, 4))); // 5
Tip大多数「数据为主」的 struct 都应声明为
readonly。它既表达设计意图,又让编译器做优化,是稳赚不赔的写法。
四、什么时候用 struct
满足下面几条,优先考虑 struct:
- 类型很小(官方经验值约 16 字节以内,存几个相关数值)。
- 语义上是「一个值」:坐标、颜色、金额、配置项,两个内容相同的实例就该相等。
- 主要是装数据,行为很少,不需要继承。
- 会被大量创建和传递,希望避免引用类型的堆分配开销。
namespace CSharpDemo;
readonly struct Money
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency) => (Amount, Currency) = (amount, currency);
}
var price = new Money(9.99m, "CNY");
Console.WriteLine($"{price.Amount} {price.Currency}");
判断一个类型该用 struct 还是 class,有个实用的「反向清单」:先假设它是 class;只有当它同时满足「小、值语义、不可变、不继承、不需要身份」五条,才值得下沉成 struct。反过来,只要有一条不满足,就安心留在 class。照着这张清单走,你很少会纠结太久,也不会因选错类型埋下性能或逻辑隐患。
五、什么时候用 class
以下情况请用 class:
- 需要继承基类或实现多态,struct 不能继承其他 struct/class(但能实现接口)。
- 类型有唯一「身份」,两个字段相同的对象应被视为不同个体(比如两个
Person即便同名同岁也不是同一人)。 - 对象较大,频繁复制成本高;或需要引用语义(改一处处处可见)。
- 需要用到
class专属能力,如析构函数、基类object引用语义等。
Note旧教程常说 struct 不能有「无参构造函数」(parameterless constructor),那是 C# 9 及更早的说法。从 C# 10 起,struct 也能写无参构造函数来自定义默认值;
default表达式仍会零初始化所有字段。这点请以 C# 14 为准。
Note留意 struct 的「默认值」陷阱:
default(Point)会把所有字段清零,而不会去调用你写的构造函数。所以别在 struct 构造函数里假设「字段一定有合理初始值」——读取方完全可能拿到全零的 default 实例。这也是为什么配合readonly struct并走明确的构造入口更稳,能避免「以为初始化了其实没初始化」的错觉。
Tip若你只是想避免引用分配、又舍不得 struct 的诸多写法限制,别忘了还有
readonly struct配in参数这套组合:既能享受值传递的确定性,又能借助in避免「防御性复制」带来的额外开销,是性能敏感场景下的常用手法。
六、新手踩坑
- 大 struct 当参数传来传去,每次都是整份复制,性能反而更差。超过几十字节就考虑 class。
- 可变的 struct 传给方法时,方法里改的是副本,原值没变,容易误以为「改了」。优先用
readonly struct。 - struct 装箱(转成
object或接口)会失去值类型优势,还可能产生意料外的行为。 - struct 不能当其他类型的基类,需要多态就选 class。
Warning不要用可变 struct(mutable struct)当作字典的键或放进
HashSet。因为值类型会被复制,存入后你在外面对副本的修改不会反映到集合内部那份,导致「怎么也查不到刚放进去的键」这类诡异 bug。要么用readonly struct,要么干脆用class。
七、为什么这样设计
为什么 C# 要同时给 struct 和 class 两种东西?因为「值语义」和「引用语义」解决的是两类完全不同的问题。值类型的好处是确定性和性能:它直接在栈或所在容器里存放数据,赋值即复制,没有额外的堆分配,也不需要 GC 操心。引用类型的好处是共享和身份:多个地方拿着同一个对象的引用,修改互相可见,还能被继承、被多态。
语言设计者不想让你在「性能」和「灵活」之间二选一,于是把两种语义都给了你。代价是:你得自己判断这个类型更像「一个值」还是「一个对象」。判断错,要么逻辑出 bug(以为改了其实没改),要么性能拉胯(大对象反复复制)。
八、与其它语言对比
- Java:早期只有引用类型(类),基本类型(int、double)不是对象。C# 的
struct让值类型也能拥有方法和属性,更统一。 - C++:struct 和 class 几乎没区别,靠默认访问权限区分。C# 里二者语义截然不同,不能混用。
- Go:类似 C# struct 的值语义,但没有「class」这个关键字,所有复合类型都是 struct 思路。
Tip不确定时用哪?经验法则:先默认写
class。当你发现「这个类型很小、像个数、两个内容相同就该相等、从不继承」,再把它下沉成readonly struct。从 class 起步永远不会错。
九、本节小结
一句话:小、值语义、不可变、不继承,用 struct;要身份、要继承、要大对象,用 class。不确定时,从 class 起步永远不会错,需要极致性能再下沉到 readonly struct。下章我们聊内存回收与资源释放。