ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C#值相等与null==null:运算符重载与Equals实现完整指南

C#值相等与null==null:运算符重载与Equals实现完整指南 接手这个需求之前, 我先把背景说清楚我们在 C# 里讲值相等value equality指的是两个不同对象只要内部关键数据相同业务上就视为同一个对象而默认情况下类class的相等性是引用相等也就是两个变量必须指向同一个堆实例才算相等。一旦你为了业务需要把类的相等性改成值相等就必须同时考虑 null 的行为——尤其是这个看起来有点反直觉的约定null null 仍然必须返回 true。这个题目看着小真做起来坑不少。最典型的翻车现场就是重载了 运算符之后代码里if (left null right null) return true;直接栈溢出又或者两个对象都为 null 时新的 返回了 false导致后续业务判断全部出错。这篇文章会从一个实际业务模型出发把 null 语义为什么必须保留、怎么避免运算符递归、完整参考实现怎么写、以及相等性改动会影响到的隐蔽场景全部拆开讲。适合正在手写 Equals / GetHashCode / 运算符重载的 .NET 开发者也适合想真正理解 record 类型为什么能免费获得值相等的同学。1. 先从两个相等打架的场景说起我们到底想要什么语义1.1 同一个业务对象两种相等并存是常态假设我们有一个很常见的领域对象金额。一个Money类包含两个属性数值Amount和币种Currency。业务上new Money(100, CNY)和另一个new Money(100, CNY)就应该被视为同一个金额。但默认情况下C# 的类使用引用相等这两个对象指向不同的堆空间比较结果是 false。这时候我们就得自己动手把类的相等性从引用相等改成值相等。常规做法是重写Equals、GetHashCode再顺手重载和!运算符。很多开发者把这一步理解为让两个不同对象在某些条件下相等这没有错但容易忽略一个隐藏的前提我们没有理由改变 null 相关的语义。null不是对象它表示这里没有引用。两个 null 之间做比较本质上是没有引用和没有引用的对比这个对比结果应该是 true。这跟两个不同对象通过值比较得到 true 完全是两码事。默认的引用相等实现里null null天然是 true改造成值相等之后这个结果必须原封不动地保留。1.2 值相等在业务代码里绕不开的几个场景为什么会频繁需要值相等我整理了一下实际项目里最常见的几种诉求集合去重ListMoney().Distinct()需要判断两个Money实例是否相同默认行为是引用比较结果往往不符合业务预期。字典键把对象作为DictionaryTKey, TValue的键时键的相等性决定ContainsKey能不能命中。LINQ 连接和聚合Join、GroupBy、Contains等操作都依赖相等性比较。单元测试断言断言两个领域对象相等是很常用的需求如果类本身不支持值相等测试代码就得写一堆字段逐个比较。也就是说只要一个领域对象会进入集合运算、字典键、去重逻辑值相等基本是逃不掉的。一旦你要做这件事null 边界就是第一批要处理的用例绝不只是一个加分项。2. null null 的默认语义与自定义运算符的性格突变2.1 默认语义回顾null 在相等性坐标系里的位置先看一段最朴素的代码object? a null; object? b null; Console.WriteLine(a b); // True Console.WriteLine(object.Equals(a, b)); // True Console.WriteLine(object.ReferenceEquals(a, b)); // True这段代码说明了三件事在引用类型上默认走引用相等运算符null 和 null 比较结果是 true。静态方法object.Equals(object? x, object? y)内部有 null 保护如果两个参数都是 null 返回 true只有一个是 null 返回 false否则调用x.Equals(y)。object.ReferenceEquals同样把两个 null 视为相等。这些行为是 .NET 运行时约定俗成的基础语义几乎所有的集合类、LINQ 操作、序列化工具都在隐式依赖它们。自定义类的值相等实现不能和这套约定对着干。2.2 改成值相等之后null 的三种性格突变当我们给类重载了运算符情况就变了。编译器在静态类型为Money的地方看到a b会优先选择我们定义的运算符重载而不是默认的引用比较。如果实现得不对null 的语义会变成下面三种意外之一第一种抛异常。public static bool operator (Money? left, Money? right) left.Equals(right);这段代码看着简洁但left为 null 时left.Equals(right)会直接抛NullReferenceException。两个 null 比较不仅不是 true直接程序崩溃。第二种两个 null 返回 false。public static bool operator (Money? left, Money? right) left?.Equals(right) ?? false;left?.Equals(right)在left为 null 时结果是null?? false把整个表达式变成了 false。于是null null成了 false这跟默认语义完全相反。第三种栈溢出。public static bool operator (Money? left, Money? right) { if (left null right null) return true; // ... }left null里的left是Money类型编译器会解析到我们正在定义的operator 。方法体一开始又走left null于是无限递归栈溢出。在自定义类型内部用判断该类型的 null是最容易踩的递归陷阱。2.3 递归坑的底层原因运算符是静态绑定的要理解为什么left null会递归得知道运算符重载的解析机制。与虚方法不同运算符重载在编译期根据操作数的静态类型决定调用哪个方法。left的静态类型是MoneyMoney恰好又定义了operator 所以left null不会退回引用比较而是再次进入自定义运算符。解决办法有两个本质都一样绕开重载的 走不经过重载的判空方式。left is null或right is nullC# 7.0 引入的模式匹配判断引用是否为 null不会调用任何重载的。object.ReferenceEquals(left, null)把比较变成纯引用比较也安全。我个人推荐is null可读性最好也符合现代 C# 风格。记住一条经验原则在被重载了 的类型内部判断 null 一律使用is null绝不使用left null这种写法。3. 四个成员的分工Equals、GetHashCode、IEquatable 与运算符3.1 谁负责什么别混在一起一个完整的值相等实现通常涉及四个成员。很多人把它们当成一个功能来写结果要么漏了 GetHashCode要么 Equals 和 不一致。我建议先把职责分开理解成员职责典型调用方override bool Equals(object? obj)多态入口供所有走object的代码调用object.Equals、非泛型集合bool Equals(T? other)实现IEquatableT强类型相等判断避免类型转换EqualityComparerT.Default、泛型集合override int GetHashCode()哈希码与 Equals 保持契约Dictionary、HashSetoperator /operator !运算符语义暴露给业务代码直接书写a b这里最关键的是Equals负责真正的字段比较逻辑运算符则相当于一个外壳。的重载不能自己另写一套业务判断否则很可能出现a.Equals(b)为 true 但a b为 false 的诡异局面。3.2 Equals 内部的判空顺序先看IEquatableT.Equals的实现顺序。一个合格的值相等方法应该按这样的顺序处理判断other是否为 null。因为调用this.Equals(other)的时候this一定非 null所以只需要管对方。判断引用是否相等。两个变量指向同一个实例就不用再逐个比较字段了这是最廉价的快速路径。判断类型是否匹配。尤其是存在继承关系时other是子类实例或者反过来都可能破坏相等性的对称性。逐字段比较。字段之间用什么方式比较取决于字段类型值类型直接用引用类型建议用Equals或EqualityComparerT.Default。对应到override bool Equals(object? obj)入口现代 C# 推荐直接用模式匹配简化判空和类型转换public override bool Equals(object? obj) obj is Money other Equals(other);obj is Money other这个表达式有两个作用首先检查obj是不是Money类型如果obj为 null直接短路为 false其次把obj强转为Money赋值给other。这样代码里不需要再写if (obj null)之类的判断可读性和安全性都更好。3.3 运算符重载的判空顺序运算符重载是静态方法参数可能有一个 null 或两个都是 null。这时候判空顺序要非常讲究。正确姿势是先检查两个都是 null返回 true——这是要保留的null null语义。再检查只有一个 null返回 false。最后才能安全调用left.Equals(right)。因为第 1 步和第 2 步已经把 null 的可能性排除干净第 3 步里的left必然非 null所以调left.Equals(right)不会抛异常。这里也可以把ReferenceEquals(left, right)快速路径放在第 2 步之后、第 3 步之前if (ReferenceEquals(left, right)) return true;它在两个变量指向同一实例时跳过字段比较对字段多、比较开销大的类型很有价值。4. 一个同时满足值相等和 nullnull 的完整参考实现4.1 领域类模板理论说完了直接给一个可以直接抄走的模板。我以一个Money类型为例它是 sealed 类包含decimal Amount和string Currency两个字段。using System; namespace ValueEqualityDemo { public sealed class Money : IEquatableMoney { public decimal Amount { get; } public string Currency { get; } public Money(decimal amount, string currency) { Amount amount; Currency currency ?? string.Empty; } public override bool Equals(object? obj) obj is Money other Equals(other); 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() HashCode.Combine(Amount, Currency); 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) !(left right); } }提示如果你在使用旧版 .NET Framework 或者项目没有开启可空引用类型把参数里的Money?都改成Money即可运行效果一样。HashCode.Combine需要 .NET Core 2.1 及以上或 .NET 5旧版本可以用自定义的组合逻辑。4.2 逐段理解这个模板Equals(object? obj)入口非常精简。它把 null 判断、类型判断、强转全部交给了obj is Money other模式表达式。other为 true 时才继续调用Equals(Money? other)。这里没有单独写obj is null的判断是因为模式匹配本身对 null 就会短路。Equals(Money? other)是真正做业务比较的地方。other is null先排除对方为 null 的情况ReferenceEquals(this, other)处理同一个实例的快速路径最后比较Amount和Currency。Amount是decimal值类型的就是值比较Currency是字符串我刻意用了string.Equals并指定StringComparison.Ordinal避免大小写、文化差异导致的意外结果。实际项目里如果币种本来就大小写敏感这是最稳的写法。GetHashCode用HashCode.Combine(Amount, Currency)一行完成组合。这里要特别强调GetHashCode的契约两个对象 Equals 返回 true它们的 GetHashCode 必须一致。因为Amount和Currency也正是Equals里比较的两个字段所以哈希码和相等判断天然对齐。operator 是整个 null 语义的核心。三个步骤逐层过滤left is null right is null双 null 场景返回 true保住null null。left is null || right is null单 null 场景返回 false保证null 对象和对象 null都是 false。left.Equals(right)两个都非 null进入正常业务比较。operator !只是对取反。这样保证!与永远互补不会出现两者同时为 true 或同时为 false 的奇怪状态。4.3 常见错误对照表我在实际代码评审里见过不少类似写法列一个对照表方便大家自查错误写法表面现象正确写法if (left null right null)栈溢出if (left is null right is null)left?.Equals(right) ?? false双 null 返回 false先双 null 判断再单 null 判断left.Equals(right)不做 null 检查left 为 null 时抛异常null 判断后再调用GetHashCode返回常量哈希集合性能暴跌HashCode.Combine(字段...)Equals里用obj is Money类未 sealed父子类型比较不对称类型检查用GetType()或让类 sealed里另写一套字段比较与 Equals 不一致统一调用Equals5. 相等性不和谐会波及哪些隐蔽地方5.1 HashSet 与 Dictionary 的键查找字典键查询是受相等性影响最明显的地方。DictionaryTKey, TValue内部先通过GetHashCode定位桶再用Equals确认是否真的相等。如果你的GetHashCode没有与Equals对齐或者与Equals结果不一致会出现一个非常隐蔽的问题同一个业务对象用ContainsKey查不到但遍历Keys用肉眼能看出来明明存在。更麻烦的是 null 键的问题。DictionaryTKey, TValue允许 null 作为引用类型键HashSetT允许 null 作为元素。默认的EqualityComparerT.Default在处理 null 键时会先做 null 判断不会把 null 直接丢给对象的GetHashCode。但如果我们在该类型内部实现了自定义的IEqualityComparerT就必须自己补上这个判断public sealed class MoneyComparer : IEqualityComparerMoney? { public bool Equals(Money? x, Money? y) x y; public int GetHashCode(Money? obj) obj is null ? 0 : obj.GetHashCode(); }GetHashCode(Money? obj)里如果忽略obj is null这一行遇到 null 元素时就会抛NullReferenceException。集合框架允许 null 存在但比较器自己得会处理。5.2 LINQ 集合运算与 EqualityComparer .DefaultLINQ 的Distinct、Union、Intersect、GroupBy、Contains等操作默认使用EqualityComparerT.Default。这个默认比较器有一个优先级逻辑如果T实现了IEquatableT优先调用T.Equals(T)。否则回退到object.Equals(object)。对 null 元素默认比较器自己会做 null 守卫保证 null 与 null 相等null 与非 null 不相等。所以只要IEquatableT和Equals实现正确Distinct这类操作就能正确工作。如果只重载了而没实现IEquatableTDistinct并不会自动使用你的它走的是object.Equals。这也是为什么我们既要重载运算符又要实现IEquatableT的原因。另一个被忽视的点是OrderBy和Sort。它们默认用ComparerT.Default依赖IComparableT而不是相等性比较。但排序结果里 null 被放在最前面这是默认比较器的一致行为。如果你的类被用于排序建议额外实现IComparableT否则 null 元素和普通元素的比较可能会抛出异常。5.3 序列化、反序列化与测试断言的连带影响考虑一个常见场景从数据库或 Redis 读出 JSON反序列化出一个Money对象和代码里直接new出来的Money做比较。如果类没有值相等结果一定是 false有了值相等之后还要确认 null 语义没有破坏。否则测试用例里一旦出现期望结果为 null的断言就会莫名失败。单元测试框架的Assert.Equal(expected, actual)通常也走相等性比较。我见过不少团队在项目里大量依赖这个断言但类的相等性实现只覆盖了字段比较、没覆盖 null 情况结果测试数据里偶然出现 null 时断言结果和预期不一致排查成本很高。所以我的建议是任何实现值相等的类都必须有一组固定覆盖 null 矩阵的测试用例而不是只在正常对象之间比较。这部分在最后一节会给出具体清单。6. 几个从实战里沉淀下来的额外建议6.1 能直接使用 record 吗可以但建议先懂原理C# 9 的record class从设计上就内置了值相等public sealed record Money(decimal Amount, string Currency);这行代码编译器会帮你生成Equals、GetHashCode、IEquatableT、、!并且 null 语义也是正确的Money? a null; Money? b null; a b会返回 true。所以新项目里如果只是单纯需要值相等直接用 record 是最省事的选择。但我仍然建议手写一个版本不是因为 record 不好而是因为你需要理解它到底做了什么。真实项目里往往会有 record 覆盖不到的需求比如相等性只比较一部分字段、比较时需要忽略大小写、需要自定义GetHashCode以配合某个老框架、类型已经定义成 class 不方便迁移等等。这些情况都要求你回到手工实现。理解了这篇文章的模板再看 record 的生成代码你会立刻明白它每一步都在解决什么问题。6.2 sealed、类型检查与继承的对称性我在模板里把Money定义成了sealed这是有意为之。如果类允许被继承相等性会变得非常棘手。比如父类Animal和子类Dog如果Equals里写obj is Animal那么dog.Equals(animal)可能是 true而animal.Equals(dog)在只比较父类字段时也可能是 true。一旦子类增加了字段对称性和传递性就很容易被破坏。两种常见策略只允许顶层业务对象被比较直接将其定义为sealed class。大部分领域对象放在集合里去重时都用这个方案。如果一定要支持继承Equals开头用obj.GetType() ! GetType()做严格类型检查保证两个对象必须运行时类型完全一致才可能相等。运算符在继承场景下更难处理因为编译期静态类型决定调用哪个重载。所以我个人的经验是值相等类型优先设计为 sealed这能省掉一整类烦恼。6.3 可变性与 GetHashCode 的死结GetHashCode的返回值和对象字段强相关而对象的字段如果是可变的问题就来了对象放入HashSet之后如果你修改了它的Amount字段GetHashCode的结果就变了。这个对象还在集合里但集合已经无法通过新哈希值定位到它于是它变成了幽灵数据——用Contains查不到也无法删除。解决思路有三个按推荐程度排序把参与相等性的字段设计成只读构造函数里一次性初始化。这也是模板里Amount、Currency只暴露get的原因。参与相等性的字段用稳定的业务标识比如数据库主键 ID而不是可变的描述文本。如果实在无法不可变那就不要用对象本身做字典键改用键的字符串或整型标识。6.4 一条可靠的自测清单最后分享一套我在实际项目中固定的测试矩阵。不管类有多简单这四个组合必须全覆盖Money m1 new Money(100, CNY); Money m2 new Money(100, CNY); Money? n1 null; Money? n2 null; // 1. 双 null必须为 true Debug.Assert(n1 n2); Debug.Assert(n1 null); Debug.Assert(null n2); Debug.Assert(object.Equals(n1, n2)); // 2. 单 null必须为 false Debug.Assert(n1 ! m1); Debug.Assert(m1 ! n1); Debug.Assert(!object.Equals(n1, m1)); Debug.Assert(!object.Equals(m1, n1)); // 3. 对象值相等必须为 true Debug.Assert(m1.Equals(m2)); Debug.Assert(m1 m2); Debug.Assert(m1.GetHashCode() m2.GetHashCode()); // 4. 不同值必须为 false Money m3 new Money(200, CNY); Debug.Assert(!m1.Equals(m3)); Debug.Assert(m1 ! m3);特别注意一点n1.Equals(m1)这种调用是禁止写进测试的因为n1是 null调用实例方法会抛NullReferenceException。判断 null 与对象的相等性时要使用、!或静态object.Equals。真正交付到团队里的单元测试建议直接使用xUnit或NUnit的Assert.Equal来替代Debug.Assert。我自己以前写这类代码习惯性地在operator 里写left null然后看着调试器里的栈溢出一脸懵。后来养成了一个规矩只要是在被重载了的对象类型上判断 null一律使用left is null或ReferenceEquals(left, null)绝不使用left null。再把上面这套 null 矩阵固化成单元测试后面基本没再翻过车。现在产品里凡是要求值相等的类型我的第一选择是 sealed record或者按本文模板写 sealed class遇到需要自定义哈希策略、需要兼容老框架的时候再回到手工实现——这些基本功始终是绕不开的。
返回列表