
做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)是所有对象的统一入口但直接覆写它还不够通常还要实现IEquatableT接口提供强类型的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 nullnull 为 true 是怎么保住的“保留 nullnull 为 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 类骨架下面是一个完整的可编译示例包含IEquatableT、Equals覆写、GetHashCode和运算符重载。我把比较字段设计为只读属性避免可变字段带来的哈希坑。#nullable enable public sealed class Money : IEquatableMoney { 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 三个关键测试用例第一组测两个 nullMoney? a null; Money? b null;此时a b返回 true。这也验证了静态运算符对 null 参数的“包容性”在C#中这是最容易被人忽略的一层语义。第二组测一个 null 一个非 nullMoney? 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返回 truee.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三是在泛型场景中使用EqualityComparerMoney?.Default.Equals(x, y)。这三种方式都可以在参数为 null 时安全返回。这也反过来说明为什么重载时必须保留 null 检查因为泛型框架代码可能拿一个 null 实例去调用比较只有把 null 判断放在静态运算符里才能兜住这些场景。4.3 可变字段与哈希的隐患如果参与GetHashCode的字段在对象生命周期内会变化哈希集合会立刻“失聪”。最典型的例子把对象加入HashSet时计算了一次哈希码然后修改了对象的字段哈希码变了。之后你再Contains这个对象HashSet 按新哈希码去老桶里找找不到返回 false。你明明把对象放进去了却查询不到。这个坑对值对象来说尤其危险因为值对象本应不可变。所以我强烈建议把参与相等比较的字段设为readonly或者使用init属性构造函数一次性赋值后续不允许修改。如果实在有可变需求就不要把它放进哈希集合也别让它参与GetHashCode但这又可能违反“所有比较字段参与哈希”的一致性约定属于两难最好从设计上规避。4.4 重载 后字典和 HashSet 的行为很多人会误以为只要重载了Dictionary和HashSet就会使用这个运算符。实际上这两个集合默认走的是EqualityComparerT.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、或EqualityComparerT.Default调用时栈溢出运算符内部调用EqualsEquals内部又调用明确职责运算符处理 null 和入口Equals只做逐字段比较Dictionary 查不到已放入的对象GetHashCode没跟着Equals重写或字段可变同步实现GetHashCode并让比较字段不可变两个对象内容相同但 HashSet 判定不同GetHashCode没有覆盖所有比较字段用所有比较字段参与哈希计算优先用质数乘加算法基类与子类Equals结果不对称继承层级下用as做类型判断值对象标记sealed或比较GetType()这张表基本覆盖了我这些年遇到的高频问题。如果你写了重载后发现行为不对先按表里的顺序排查通常第一根雷都可以定位到 null 检查或Equals递归上。5.2 调试小技巧调试Equals时最容易让人抓狂的是“看起来调用了却没进断点”。原因往往是框架使用了EqualityComparerT.Default它访问的可能是自定义的IEquatableT.Equals不是object.Equals。所以调试时不要把断点只打在object.Equals上要两个Equals重载都打或者直接给GetHashCode也加断点排查链条更完整。我还会在Equals开头做一次ReferenceEquals的快速返回这不仅是性能优化更是在调试时帮你区分“同一个引用”和“值完全相同”。如果你的调试器命中了快速返回分支至少能确定对象是同一个引用下一步不需要再逐字段看数据。另一个实用做法是用[DebuggerDisplay]给类加一个友好的显示文本[DebuggerDisplay(Money({Amount} {Currency}))] public sealed class Money : IEquatableMoney这样在 Visual Studio 的监视窗口里看到的就是可读的金额而不是一坨类名加地址。比较对象时眼睛扫一遍就能发现字段差异。5.3 后续扩展方向如果这个类还需要支持排序、比较大小那就要考虑实现IComparableMoney如果它会被 EF Core 持久化建议研究一下复杂类型的配置方式如果项目里大量手写值对象可以考虑用 record 或者代码生成器统一处理减少手写出错的机会。但不管怎么扩展Equals、GetHashCode、三者必须保持一致的语义特别是 null 保护这一层一定要当成默认标配。我自己写新代码时已经默认先评估 record正因为它是底层语义的正确“标准答案”读懂手写方案后你才真正知道 record 帮你拦掉了多少雷。