ARTICLE DETAIL

资讯详情

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

C#重载==运算符:null==null必须为true的完整实现指南

C#重载==运算符:null==null必须为true的完整实现指南 做C#开发这些年凡是绕过“值相等”这道坎的基本都在null上栽过跟头。类的默认比的是引用可业务里我们经常要的是“内容相同就算相等”。一旦动了重载的念头第一个要面对的坑就是null null必须是true。这个看起来像废话的语义实现时稍不留神就会变成栈溢出、空引用或者结果刚好相反的诡异问题。这篇文章就想把这件事彻底讲透。从值相等的设计思路到null判断的具体写法再到重载时的各种翻车现场我会结合实际的代码示例把每一步背后的逻辑说清楚。无论你是刚开始接触C#高级编程还是已经在项目里被这个坑折磨过这篇文章都值得花十分钟看完。1. 先从“值相等”说起类在什么场景需要值相等1.1 引用相等和值相等的本质区别C#里所有类class默认继承自System.Object而object的Equals和做的是引用比较——比较的是两个变量在托管堆上指向的地址不是里面的内容。打个比方两个盒子里面都装着一模一样的苹果但盒子本身不是一个盒子。默认情况下两个盒子不相等。值相等就不一样了。它关心的是盒子里面的内容只要苹果一样就认为两个盒子相等。这在很多场景下是合理的业务需求两个Person对象只要Id都是1001就认为是同一个人。两个ProductDTO属性值完全一致就认为数据没变化。两个配置项Key和Value相同就认为配置没被修改。问题来了在类上默认比的是引用你想要内容比较就必须自己动手把语言默认行为“改掉”。1.2 实现值相等的三条路在C#中让一个类支持值相等有几种常见途径途径作用说明重写object.Equals(object)给所有调用方提供统一的值比较入口List.Contains、Dictionary的键查找都会用它实现IEquatableT提供强类型版本的值比较避免装箱泛型集合里性能更好重载和!让left right也走值比较需要在重载方法里同时处理null真实项目里这三条路往往是配套出现的实现了IEquatableT通常也会重写object.Equals并重载。三者之间的调用关系要非常清晰否则就会出现“集合说相等、说不等”的割裂局面。2. null null 为什么是必须守住的底线2.1 一个容易被低估的语义先问一个问题null null在C#里应该等于什么答案显而易见true。两个变量都是空引用它们指向都不存在从任何角度看都“相等”。这个语义已经深入到所有开发者的直觉里也写进了C#语言规范——对引用类型如果两个操作数都是null那么的结果是true。但请注意这句话只是在“没有重载”的前提下成立。一旦你在自己的类里重载了那null null的结果就落到你的代码手里了。写得好语义保持写得不好直接翻车。2.2 你可能会写的错误实现很多初次重载的人会写出这样的代码public static bool operator (Person left, Person right) { if (left null right null) { return true; } if (left null || right null) { return false; } return left.Name right.Name; }这段代码看起来逻辑清晰两个都是null返回true有一个是null返回false否则比字段。但运行起来就会栈溢出。原因在于left null这里的是重载过的。当你在operator 方法体里写下left null时程序会再次调用刚才那个operator 于是无限循环直到StackOverflowException。这就是很多C#开发者第一个踩到的坑在重载时用去判断null等于自己调用自己。2.3 正确姿势用“引用比较”而不是“重载比较”要判断一个类引用是否为null必须用不经过重载的“引用判断”手段。C#提供了几种方式// 方式一object.ReferenceEquals if (ReferenceEquals(left, null)) { ... } // 方式二is null 模式匹配C# 7.0 if (left is null) { ... }ReferenceEquals是Object的静态方法专门做引用比较打死不会调用重载的用在这里最安全。is null是C# 7.0引入的模式匹配语法它编译时的语义也是引用判断同样不会调用重载的。这也是我现在更推荐的方式语法更直观。重要提示判断null时禁止在重载了的类内部使用left null这种写法会直接导致栈溢出。要用left is null或ReferenceEquals(left, null)。3. 一套稳妥的值相等实现方案3.1 从业务模型开始用一个实际场景来说明。假设你要做一个订单系统里面有个Address类用途是判断两个地址是否相同public class Address : IEquatableAddress { public string Province { get; set; } public string City { get; set; } public string Detail { get; set; } public Address(string province, string city, string detail) { Province province; City city; Detail detail; } public override bool Equals(object obj) { return Equals(obj as Address); } public bool Equals(Address other) { if (other is null) { return false; } if (ReferenceEquals(this, other)) { return true; } return Province other.Province City other.City Detail other.Detail; } public override int GetHashCode() { return HashCode.Combine(Province, City, Detail); } public static bool operator (Address left, Address right) { if (ReferenceEquals(left, right)) { return true; } if (left is null) { return false; } return left.Equals(right); } public static bool operator !(Address left, Address right) { return !(left right); } }这一套代码保持了null null为true同时null与其他非null比较为false。逐段解释Equals(object)是重写Object的版本把参数转成Address类型再转到强类型的Equals(Address)。用as转换如果参数不是Address或为null结果都是null会进入Equals(Address)的other is null分支返回false。Equals(Address)是IEquatableT的实现。先判断other is null因为this永远不会是null能从实例上调用方法说明对象存在。再判断引用是否一致能省掉属性比较的开销。最后才逐字段比较。operator 是关键。只用ReferenceEquals和is null来做空判断永远不会递归。当左右都是null时ReferenceEquals(left, right)直接返回true这就保证null null语义正确。operator !直接取的反值。注意这里不需要重新实现一遍逻辑两边的null处理已经由涵盖了。3.2 为什么重载时最先判断ReferenceEquals可能有人会问既然两个null时ReferenceEquals会返回true那个判断null null的语义不就得靠它吗对所以在operator 里的第一步永远是ReferenceEquals(left, right)。它同时涵盖三种情况左右都非null且引用相同快速返回true。左右都是null返回true。左右引用不同一个为null、一个非null的情况也包含在内但这里不能区分谁是null所以要继续处理。只有一种情况会导致后面需要判断单边nullReferenceEquals结果不相等说明至少有一边不是null或者两边都不是null但引用不同。这时用left is null就能排除“左边为空、右边非空”的组合。因为如果left为空而right非空就不再走后续的left.Equals(right)避免NullReferenceException。3.3 GetHashCode 必须配套改很多人实现值相等时会漏掉GetHashCode。这里有个硬性规则重写Equals就必须重写GetHashCode因为Dictionary和HashSet这类集合先比哈希码再确认Equals。哈希码的准则有两个相等的对象哈希码必须相同。哈希码尽量分散减少碰撞。我上面的代码用了C# 8.0的HashCode.Combine一次性把三个字段合在一起特别好用。如果你的项目还在用旧版本可以手工写public override int GetHashCode() { unchecked { int hashCode Province?.GetHashCode() ?? 0; hashCode (hashCode * 397) ^ (City?.GetHashCode() ?? 0); hashCode (hashCode * 397) ^ (Detail?.GetHashCode() ?? 0); return hashCode; } }注意Province、City、Detail都是字符串可能为null所以用了空合并运算符?? 0。这是新手容易忽略的字段为null时调用GetHashCode()会炸。4. 我在实际项目中踩过的坑4.1 坑一用 null判断导致递归这是我在一个老项目的公共基类里发现的。前人重载了然后写public static bool operator (MyClass a, MyClass b) { if (a null) { return b null; } ... }一运行任何两个同类对象的比较都会让栈溢出。现场的情况特别尴尬代码逻辑看起来没毛病但程序就是报StackOverflowException排查半天才发现a null内部的已经是重载的了。排查技巧如果你的程序在比较对象时突然栈溢出第一反应就该怀疑“重载的方法里是不是又用了”。打开调用堆栈如果反复出现operator 那就实锤了。4.2 坑二重载里调用left.Equals但left是null有人意识到不能用left null但没意识到left本身可能是null写出了这样的代码public static bool operator (MyClass left, MyClass right) { if (ReferenceEquals(left, null)) { return ReferenceEquals(right, null); } return left.Equals(right); }这段代码其实是正确的左边为null时直接比较右边是否为null两边都null返回true。但如果写成下面这样就有问题public static bool operator (MyClass left, MyClass right) { return left.Equals(right); }当left为null时这句代码等于在null上调用方法必然抛NullReferenceException。很多人以为重载时“肯定能保证left不为null”但实际上是静态方法完全可能被null anything这种表达式触发。比如person null里person就是null。所以operator 里的null检查一个都不能少。4.3 坑三Equals参数是null时直接强转重写Equals(object obj)时有人喜欢先强转public override bool Equals(object obj) { Address other (Address)obj; // 当obj为null时强转是安全的null能转引用类型 return Province other.Province; // 但other是null这行就炸了 }(Address)null本身不报错问题是强转后的other还是null下一步访问other.Province就炸了。我建议一律用as转换或者第一时间判断obj is nullpublic override bool Equals(object obj) { return Equals(obj as Address); }as转换时如果obj不是Address会返回null正好落进Equals(Address)的other is null分支返回false一举两得。4.4 坑四Equals认为相等但GetHashCode不同有一次帮同事排查HashSetPerson查不到人的问题。他的Person重写了Equals但没重写GetHashCode。结果是两个Person属性值完全一样personA.Equals(personB)返回true但HashSet把personA放进集合后用personB去查时先计算personB.GetHashCode()和personA.GetHashCode()发现哈希码不同直接判定两个对象不在一格根本不会走到Equals那一步。这个问题尤其隐蔽因为单独调用Equals是能返回true的。4.5 坑五继承体系下用GetType还是as如果你的类会被继承Equals里对类型的判断就要想清楚。比如基类Animal和子类Dog如果Equals里用obj is Animal来判断那么一只Dog和一只Animal可能因为字段相同就被判定相等这通常不是你想要的结果。有两个选择严格模式用obj.GetType() this.GetType()只有类型完全相同才比较。宽松模式用obj is Animal other只要都是动物就比属性。大多数领域模型我推荐严格模式避免因继承导致相等的定义被意外放宽。实现IEquatableT时T要选对你的业务类型不要随手填一个基类。4.6 常见问题速查表症状原因解决方案栈溢出重载内部用了left null改用left is null或ReferenceEquals空引用异常left为null时调用了left.Equals先判断left is null再调用EqualsHashSet查不到元素Equals和GetHashCode不一致重写GetHashCode并行维护null null返回false内部把null当普通对象比较字段第一步用ReferenceEquals(left, right)提前返回Equals把不相关类型强行比较强转参数没有类型检查用as或先判断GetType()集合比较走Equals、不生效没有重载和Equals必须同时配套实现5. 一些更进阶的细节5.1 可变对象做键的危险实现值相等之后类常常会被放进Dictionary或HashSet当键用。这时要小心如果对象是可变的属性在放入集合后被修改会导致哈希码变化把这个对象从集合里再查就会查不到。比如说上面那个Address类你已经把它变成了值相等的对象要求它在哈希集合里工作。假如放一个Address进去然后改了City属性集合内部按新的哈希码去找旧位置就找不到了。这种问题不是Equals本身的问题而是可变对象本身不适合做哈希键。如果业务上非这样用不可我建议改成不可变设计把属性改成只读或者用record class。C# 9.0之后record天然实现值相等还帮你搞定了null的匹配问题可以说是现代C#里处理这个需求的第一选择。5.2 record 和传统实现的取舍说一下record class。C# 9.0的record默认就走值相等的行为也是值比较并且对null的处理是规范里写好的。如果你还在用新版本C#很多情况下直接用record就够了public record Address(string Province, string City, string Detail);一行代码值相等、IEquatableT、GetHashCode全都自动生成。null null依然是true两个null记录也相等。那这篇文章讲的手写方案还有意义吗有。因为你依然会遇到这些情况项目还在用C# 7.x或更早版本没法上record。类需要继承、需要自定义比较逻辑record的默认行为满足不了。你手上已经有大量旧代码不能推倒重来改成record。理解手写值相等的底层原理是用好record的基础。我看过太多人一上来就用record等到要判断某个字段忽略比较时整个人都是懵的因为他不理解背后到底发生了什么。5.3 性能上的注意点重载之后left right就不再走虚方法的动态分发而是直接编译成对静态方法的调用在频繁比较场景下性能通常是更好的。但我建议你在Equals实现里保留ReferenceEquals(this, other)这个快速通道因为很多比较发生在同一对象身上比如把对象和自己的副本比引用相同能省掉字段逐一比较的耗时。在集合类型里IEquatableT的强类型版本能避免装箱。如果类是值类型装箱开销更明显对于引用类型object.Equals和T.Equals参数不同前者要类型转换后者直接强类型调用。所以在热路径上记得调用Equals(Address other)不要绕道Equals(object obj)。6. 现场测试验证 null 语义的关键用例无论实现写得多漂亮最终都要靠测试来兜底。下面是针对值相等和null语义的一组测试思路我建议每个实现了值相等的类都覆盖这些用例public static void RunEqualityTests() { Address a1 new Address(浙江省, 杭州市, 文三路100号); Address a2 new Address(浙江省, 杭州市, 文三路100号); Address a3 new Address(浙江省, 杭州市, 文三路200号); // 基本一致 Debug.Assert(a1.Equals(a2)); Debug.Assert(a1 a2); Debug.Assert(a1 ! a3); // null null 语义 Address nullA null; Address nullB null; Debug.Assert(nullA nullB); Debug.Assert(nullA null); Debug.Assert(null ! a1); Debug.Assert(a1 ! null); // Equals 的空参数 Debug.Assert(!a1.Equals(null)); Debug.Assert(!a1.Equals(nullA)); // object.Equals 对非本类型参数返回false object str 浙江省杭州市文三路100号; Debug.Assert(!a1.Equals(str)); // 哈希一致性 Debug.Assert(a1.GetHashCode() a2.GetHashCode()); Debug.Assert(nullA ! a3); Debug.Assert(a3 ! nullA); Debug.Assert(nullA nullB); }这里面最有价值的断言就是那几行null比较。很多项目代码逻辑写对了但没人专门测试null之间的比较等到线上某个对象缓存为null时才暴露问题。另外用Debug.Assert只是示例真实项目里建议用单元测试框架NUnit/xUnit/MSTest把这些断言固化成自动测试每次重构都能跑一遍。7. 踩坑之后我的体感建议整体来说实现类的值相等并且保持null null为true核心就两点重载时千万别用来判断null以及永远保证两边都为null时直接返回true。这两点做到了剩下的只是把Equals和GetHashCode的配套工作补齐。我个人在实际操作中的体会是最容易出问题的不是“不会写”而是“写完没测”。尤其像null null这种语义代码里看起来顺理成章但重载改变了语言的默认行为任何“看起来应该这样”的直觉都可能是错的。所以每实现一个值相等类我都会把那组测试用例放上去尤其是两个null和一边null的组合。再补一个细节在operator 里ReferenceEquals(left, right)放在最前面还有一个好处就是它能快速处理引用相同的对象和双null让后续逻辑更简单。你不需要在方法体里单独写一堆“左边是空怎么办、右边是空怎么办”的分支。后续只用关心“左边不是空那右边的空情况由Equals自己处理”这个思路能让代码清爽很多。最后如果项目允许使用新版本C#对于纯数据载体类我现在的做法是优先考虑record让编译器替我做值相等的繁琐工作。但如果是老代码、需要复杂比较逻辑、或者要控制继承行为那就老老实实用本文这套方案。理解了底层实现无论走哪条路都不会再被null null这个看似简单的问题绊倒。
返回列表