☰
C#类值相等与null语义:Equals、GetHashCode和==重载完整指南
2026/9/30 9:21:17 网站建设 项目流程

做C#开发这些年,我碰到过不少跟对象比较有关的诡异现象。两个对象身上的字段明明一模一样,用==比较却返回 false;两个变量都老老实实赋了 null,==一比,要么行为不符合预期,要么直接抛空引用异常。后来才意识到,根源在于 C# 的类默认使用的是“引用相等”而不是“值相等”,没重写等于号之前,一切按内存地址说话。C# 中实现类的值相等、同时保留null == null为 true,听起来只是一个小问题,但真做起来涉及Equals、GetHashCode、运算符重载、空引用保护好几层,任何一个环节漏掉都会留下隐藏雷。这篇我把自己的完整实现思路、踩过的坑、以及适合直接抄的代码都整理出来,适合刚接触 C# 的新手,也适合项目里写实体类、值对象或 VO 的老手。

1. 先搞清楚:值相等到底要解决什么问题

1.1 引用相等与值相等的区别

C# 里的类属于引用类型,变量里存的是对象在托管堆上的地址。默认情况下,==走的是object.ReferenceEquals的语义,只看两个变量是不是指向同一块内存。这就像门禁系统只认“你是不是拿着同一张卡”,不看你这个人是不是长一样。两个new Person("张三"),哪怕姓名、年龄、身份证号全相同,只要地址不同,==就是 false。

值相等则完全换了一套逻辑:只要两个对象的“内容”相同,就认为它们相等。典型场景是金额、地址、日期区间这类值对象,或者领域驱动设计里的 Value Object。一个Money(100, "CNY")和另一个Money(100, "CNY"),业务上显然应该相等,没人关心它们住在堆的哪个位置。

需要特别说明的是,结构体struct天生自带值相等的能力,但它是通过反射逐字段比较,性能一般,而且struct不存在null引用的问题。类不一样,类既默认引用相等,又要处理 null,所以实现的复杂度都集中在类身上。

1.2 为什么 null 的语义会被单独拎出来

在 C# 的日常约定里,null == null应当为 true,这一点几乎所有开发者都认。但这里有个容易忽略的细节:一旦你重载了==运算符,等于号的语义就完全交到你手里了,编译器不会再帮你兜底。很多人第一次实现类比较时,只想着比较字段,写完return left.Equals(right)就完事,结果两个 null 进来直接抛 NullReferenceException,或者因为递归调用把自己玩到栈溢出。

还有人会拿数据库里的 NULL 来类比,说“SQL 里 NULL 不等于 NULL”,于是认为 C# 也应该是这样。这是把两套体系混在一起了。C# 的 null 代表“没有引用”,不是一个未知值,两个“没有引用”的对象自然相同;数据库的 NULL 是“未知”,未知和未知不能判断相等。做值相等实现时,必须以 C# 语义为准,也就是null == null返回 true。

1.3 什么时候才需要自己实现这套逻辑

并非所有类都需要值相等。普通业务实体如果以主键标识身份,==用引用相等反而更省心。需要自己实现的情况,通常有这么几类:

  • 值对象:比如金额、坐标、时间段,它们没有唯一标识,所有字段共同决定“是谁”。
  • DTO / 配置项:从接口或配置文件读出来的对象,需要判断内容有没有变化。
  • 单元测试断言:写测试时比较两个对象内容是否一致,比逐字段断言省事。
  • 旧代码改造:项目早期没考虑比较语义,现在要把类放进集合、作为字典 key。

如果你是从零开始写新代码,优先考虑 C# 9 的record类型,它默认就实现了值相等。但“默认实现”不等于“你不需要理解原理”,尤其是当 record 的默认语义不满足需求、或者你还在维护老类库时,下面的手写方案依然是基本功。

2. 核心设计:重写 Equals、GetHashCode 并重载 == 运算符

2.1 重写 Equals 的正确姿势

要谈值相等,第一个绕不开的就是Equals。C# 的object.Equals(object obj)是所有对象的统一入口,但直接覆写它还不够,通常还要实现IEquatable<T>接口,提供强类型的Equals(T? other)。好处是避免装箱拆箱,也能让List.Contains、Dictionary这些泛型集合走高性能的强类型路径。

覆写Equals(object? obj)时,最常见写法是:

public override bool Equals(object? obj) { return Equals(obj as Money); }

as转换在类型不匹配时返回 null,然后被强类型Equals里的other is null拦下。这里要提醒一句:如果类不是sealed,而且你允许子类参与比较,as这种写法会带来对称性危机。比如Money有个子类SpecialMoney,父类比较子类时返回 true,子类反过来比较父类却可能返回 false,这就违反了Equals的自反性要求。稳妥的做法是让值对象sealed,或者在Equals里加上GetType()判断。

2.2 GetHashCode 必须跟着 Equals 走

很多人实现值相等时只盯着Equals和==,把GetHashCode当成可有可无。实际上一旦对象进了HashSet、Dictionary,哈希码不对,什么妖蛾子都可能出现。C# 的哈希集合先比哈希码,哈希码相同再调Equals确认。如果两个对象Equals返回 true,但GetHashCode返回不同的整数,那它们在 HashSet 里就是两个对象,Contains查不到你想要的答案。

反过来,GetHashCode不同但Equals返回 true 不违法,但哈希码相同且Equals返回 false 完全允许。所以实现原则是:所有参与Equals比较的字段,都必须参与GetHashCode的计算,计算方式要保持一致。

一个通用写法是用质数不断乘加,比如:

public override int GetHashCode() { unchecked { int hash = 17; hash = hash * 23 + Amount.GetHashCode(); hash = hash * 23 + (Currency?.GetHashCode() ?? 0); return hash; } }

unchecked关键字防止整数溢出抛异常,溢出后自然回绕也没问题,哈希码允许冲突。

2.3 重载 == 和 != 的关键:null 必须单独处理

重载运算符很多人会写,但很少有人注意到 null 检查的顺序。最安全的模板是这样的:

public static bool operator ==(Money? left, Money? right) { if (left is null && right is null) { return true; } if (left is null || right is null) { return false; } return left.Equals(right); } public static bool operator !=(Money? left, Money? right) { return !(left == right); }

这套顺序是经过实战检验的:先判断两个 null,再判断一个 null,最后才进入真正的值比较。left is null用的is模式,对可空引用类型很友好。注意一点:千万不要把left == right写在Equals方法体里,也不要试图用left.Equals(right)来统一处理 null,因为left为 null 时运行时根本进不了方法,直接抛异常。

2.4 null==null 为 true 是怎么保住的

“保留 null==null 为 true”这句话听着简单,本质在于运算符是静态方法,允许两个参数都是null。编译器在a == b处做重载决议后,调用的是你写的静态方法,而不是某个实例的实例方法。所以只要在方法开头显式判断left is null && right is null,两个无引用的值就会确认相等。

这个检查还有一层副作用:防止栈溢出。假设你没写这句,而是直接写return left.Equals(right),当 left 和 right 都是 null 时,先抛异常;假设你写了return left == right,当你把两个 null 交给运算符时又走到这个方法本身,形成无限递归。换句话说,null 判断不仅是语义需要,更是运行时安全的防火墙。

3. 完整可跑代码示例:做一个 Money 类

3.1 类骨架

下面是一个完整的可编译示例,包含IEquatable<T>、Equals覆写、GetHashCode和运算符重载。我把比较字段设计为只读属性,避免可变字段带来的哈希坑。

#nullable enable public sealed class Money : IEquatable<Money> { public decimal Amount { get; } public string Currency { get; } public Money(decimal amount, string currency) { Amount = amount; Currency = currency ?? throw new ArgumentNullException(nameof(currency)); } public override bool Equals(object? obj) { return Equals(obj as Money); } public bool Equals(Money? other) { if (other is null) { return false; } if (ReferenceEquals(this, other)) { return true; } return Amount == other.Amount && string.Equals(Currency, other.Currency, StringComparison.Ordinal); } public override int GetHashCode() { unchecked { int hash = 17; hash = hash * 23 + Amount.GetHashCode(); hash = hash * 23 + (Currency?.GetHashCode() ?? 0); return hash; } } public static bool operator ==(Money? left, Money? right) { if (left is null && right is null) { return true; } if (left is null || right is null) { return false; } return left.Equals(right); } public static bool operator !=(Money? left, Money? right) { return !(left == right); } }

这段代码在 .NET 6 以上的 SDK 中可以直接跑。注意两个细节:货币字段在构造函数里不允许传 null,所以GetHashCode里的空值分支其实很少触发,但保留它能让代码在更宽松的构造条件下也不至于崩;string.Equals指定了StringComparison.Ordinal,避免区域文化差异导致货币符号判断出错。

3.2 三个关键测试用例

第一组测两个 null:Money? a = null; Money? b = null;此时a == b返回 true。这也验证了静态运算符对 null 参数的“包容性”,在C#中这是最容易被人忽略的一层语义。

第二组测一个 null 一个非 null:Money? c = null; Money? d = new Money(100, "CNY");此时c == d返回 false。如果运算符方法里漏了第三个判断,这一组就会抛异常或返回错误结果。

第三组测值相同:Money e = new Money(100, "CNY"); Money f = new Money(100, "CNY");此时e == f返回 true,e.Equals(f)同样返回 true。此外也建议测试一下不相等情况,比如金额或币种任何一个不同,结果都要为 false。

3.3 用 record 替代手写代码

从 C# 9 开始,record类型自带值相等实现。同样一个 Money 类,用 record 写就是一行:

public sealed record Money(decimal Amount, string Currency);

record 自动生成Equals、GetHashCode、ToString,并且自动重载==和!=,对 null 的处理也已经内建。null == null当然也是 true。如果你只是需要一个标准值对象,优先用 record,代码量少,语义不易出错。

但 record 不是万能钥匙。它基于位置参数生成的相等比较,是逐字段做“值比较”,如果某个字段是数组或自定义引用类型,你仍然要额外处理。而且 record 的相等逻辑藏得比较深,调试时不如手写代码直白。我的建议是:新代码用 record,老代码改造或者需要高度自定义时,用手写方案,并且把上面这套 null 保护逻辑照抄进去。

4. 实战中一定会踩的坑

4.1 Equals 内部不能使用 ==

我在一次 Code Review 里见过这样的代码:

public bool Equals(Money? other) { if (other is null) return false; return this == other; }

看起来思路很“复用”:既然==已经实现了完整比较,那Equals直接调用它不就行了?结果运行时直接 StackOverflow。因为==的最终实现是return left.Equals(right),里面又调用了return this == other,两个方法互相调用,永远停不下来。

正确做法是:运算符负责处理 null 和入口逻辑,Equals负责逐字段比较。两者职责分开,谁也别调用谁。如果你想在Equals里先判断“是不是同一个引用”,请直接用ReferenceEquals(this, other),它不会触发任何重载。

4.2 null 实例上调用 Equals 会翻车

任何实例方法都不能在 null 引用上调用,这是一个物理限制。即使编译通过了,运行到null.Equals(other)时照样抛NullReferenceException。比如:

Money? x = null; x.Equals(null); // 运行时异常

很多人在使用x.Equals(y)判断两个可空对象时没意识到这一点。安全的替代方案有三:一是使用静态方法object.Equals(x, y),它能正确处理 null;二是使用你重载后的x == y;三是在泛型场景中使用EqualityComparer<Money?>.Default.Equals(x, y)。这三种方式都可以在参数为 null 时安全返回。

这也反过来说明,为什么重载==时必须保留 null 检查:因为泛型框架代码可能拿一个 null 实例去调用比较,只有把 null 判断放在静态运算符里,才能兜住这些场景。

4.3 可变字段与哈希的隐患

如果参与GetHashCode的字段在对象生命周期内会变化,哈希集合会立刻“失聪”。最典型的例子:把对象加入HashSet时,计算了一次哈希码,然后修改了对象的字段,哈希码变了。之后你再Contains这个对象,HashSet 按新哈希码去老桶里找,找不到,返回 false。你明明把对象放进去了,却查询不到。

这个坑对值对象来说尤其危险,因为值对象本应不可变。所以我强烈建议把参与相等比较的字段设为readonly或者使用init属性,构造函数一次性赋值,后续不允许修改。如果实在有可变需求,就不要把它放进哈希集合,也别让它参与GetHashCode,但这又可能违反“所有比较字段参与哈希”的一致性约定,属于两难,最好从设计上规避。

4.4 重载 == 后字典和 HashSet 的行为

很多人会误以为只要重载了==,Dictionary和HashSet就会使用这个运算符。实际上,这两个集合默认走的是EqualityComparer<T>.Default,它调用的是Equals(object?)和GetHashCode(),跟==没有半毛钱关系。也就是说,哪怕你把==写上天,只要Equals和GetHashCode不一致,字典照样按错误的键查找。

反过来也成立:如果你的类只需要做“字典 key”语义,其实不重载==也行,只要有正确的Equals和GetHashCode。但业务代码里直接用==比较对象的情况太常见了,所以三个东西通常要一起实现。我的经验是每次改完Equals后,立刻跑一遍单元测试,把“相同对象放进 HashSet 能查到”“Dictionary 去重生效”这两个断言写进去,别等集成测试来抓。

4.5 继承关系下的平等博弈

如果值对象有继承层级,比较逻辑很容易失衡。比如Money有子类SpecialMoney,父类的Equals用obj as Money判断,传入子类实例时返回 true;但子类如果用更严格的规则,发现传入的不是同类型,返回 false。两个对象a.Equals(b)和b.Equals(a)结果不一致,违反Equals的对称性,很多集合操作会因此产生诡异行为。

最简单粗暴的解法是让值对象sealed,禁止继承。如果确实要继承,那么Equals里应该比较GetType():

public override bool Equals(object? obj) { if (obj is null || obj.GetType() != GetType()) { return false; } return Equals((Money)obj); }

但这个写法更严格,比较范围更窄,不是所有场景都想要。我自己的偏好是优先sealed,让值对象的边界清楚,避免在继承树上玩平等游戏。

5. 常见问题速查与调试心得

5.1 问题速查表

现象可能原因建议处理
(null == null)返回 false重载运算符时没有同时处理两个 null在==开头加left is null && right is null分支
null.Equals(null)抛 NullReferenceException在 null 实例上调用实例方法改用object.Equals、==或EqualityComparer<T>.Default
调用==时栈溢出运算符内部调用Equals,Equals内部又调用==明确职责:运算符处理 null 和入口,Equals只做逐字段比较
Dictionary 查不到已放入的对象GetHashCode没跟着Equals重写,或字段可变同步实现GetHashCode,并让比较字段不可变
两个对象内容相同但 HashSet 判定不同GetHashCode没有覆盖所有比较字段用所有比较字段参与哈希计算,优先用质数乘加算法
基类与子类Equals结果不对称继承层级下用as做类型判断值对象标记sealed,或比较GetType()

这张表基本覆盖了我这些年遇到的高频问题。如果你写了重载后发现行为不对,先按表里的顺序排查,通常第一根雷都可以定位到 null 检查或Equals递归上。

5.2 调试小技巧

调试Equals时,最容易让人抓狂的是“看起来调用了却没进断点”。原因往往是框架使用了EqualityComparer<T>.Default,它访问的可能是自定义的IEquatable<T>.Equals,不是object.Equals。所以调试时不要把断点只打在object.Equals上,要两个Equals重载都打,或者直接给GetHashCode也加断点,排查链条更完整。

我还会在Equals开头做一次ReferenceEquals的快速返回,这不仅是性能优化,更是在调试时帮你区分“同一个引用”和“值完全相同”。如果你的调试器命中了快速返回分支,至少能确定对象是同一个引用,下一步不需要再逐字段看数据。

另一个实用做法是用[DebuggerDisplay]给类加一个友好的显示文本:

[DebuggerDisplay("Money({Amount} {Currency})")] public sealed class Money : IEquatable<Money>

这样在 Visual Studio 的监视窗口里看到的就是可读的金额,而不是一坨类名加地址。比较对象时,眼睛扫一遍就能发现字段差异。

5.3 后续扩展方向

如果这个类还需要支持排序、比较大小,那就要考虑实现IComparable<Money>;如果它会被 EF Core 持久化,建议研究一下复杂类型的配置方式;如果项目里大量手写值对象,可以考虑用 record 或者代码生成器统一处理,减少手写出错的机会。但不管怎么扩展,Equals、GetHashCode、==三者必须保持一致的语义,特别是 null 保护这一层,一定要当成默认标配。我自己写新代码时,已经默认先评估 record,正因为它是底层语义的正确“标准答案”,读懂手写方案后,你才真正知道 record 帮你拦掉了多少雷。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询