通常情况下引用类型的相等性是不应该被重定义 / 重写的.
例如两个引用类型的变量 x 和 y, 如果这样写: if(x == y) {...}, 那么大家都明白, 这个比较的是引用的相等性.
但是有少数情况下, 也可以为引用类型重写相等性.
例如这个类:
这个类里面只有两个 string 类型的属性和字段, 那么对它的相等性来说, 更合理的是去比较值, 而不是引用.
还有一种情况, 就是表示数学的引用类型.
例如有一个类表示矩阵 Matrix, 那么这样写 if(matrix1 == matrix2) {...} 更适合表示它们两个的值相等.
上述的这两个例子其实也不是十分的必要. 所以想为引用类型重写相等性的时候还是应该先想好, 重写后是否能够更加的直观, 使理解便得更简单了.
实际上如果想比较两个应用类型里面的值是否相等, 你不必非得去重写那些相等性的方法, 你可以通过实现 IEqualityComparer<T > 接口来写一个单独的相等性比较器. 但是这样的话不能使用 == 操作符, 需要这样写: if(eqComparer.Equals(x, y)) {...}
为引用类型重写相等性
一个类:
首先重写 object.Equals()方法:
这个逻辑比较简单, 就是判断 null, 引用和类型, 然后再判断各个属性 (字段) 的值是否相等.
然后还需要重写 object.GetHashCode()方法:
这个采用了 Resharper 生成的方法, 以前说过, 就不再介绍了.
最佳实践还要求重写 C# 的 == 操作符:
当然配套的!= 也必须重写.
在之前重写值类型相等性的文章里, 我还为值类型实现了 IEquatable<T > 接口, 而对于引用类型来说, 就没有必要去实现该接口了, 可以把相等性判断逻辑放在 object.Equals()方法里.
派生类
这是上面 Citizen 类的一个子类:
下面我重写 object.Equals() 方法:
大部分逻辑都在 base.Equals()方法里了, 首先如果父类的 Equals()方法返回 false, 那么下面也就不用做啥了. 但是如果父类 Equals()认为这两个实例是相等的, 这就意味着父类里所有的相等性检查都通过了, 然后我们仍然需要检查派生类里面的独有字段(属性), 而这个例子里只有一个字段(属性).
然后别忘了实现 GetHashCode()方法:
(resharper 生成的代码)
这个方法里使用了父类的 GetHashCode()方法, 把它按位异或 IdCard 的 GetHashCode()的结果.
然后实现 == 和!= 操作符:
好, 现在我们来测试一下:
其结果如下:
这个结果还都是对值进行比较的, 符合预期.
然后你可能以为这样实现没有问题了....
陷阱
现在我在 Citizen 这个父类里修改一下 == 的实现, 我想让它更有效率:
然后我再执行和上面同样的测试代码, 其结果输入是:
, 全都相等了.... 肯定不对..
那在父类里的 == 方法设一下断点看看:
这里面 x 和 y 其实都是 BeijingCitizen 的实例, 但是现在所处的位置是其父类 Citizen 的 == 方法里, 所以相等性检查会在这里发生, 所以这个相等性检查只会检查父类里面的字段, Citizen 这个类无法知道其它继承于它的类型, 所以这里也无法比较派生类独有的字段, 在这里就是 IdCard. 而所有这些实例的不同值就去别再 IdCard 这个派生类的字段上面了, 所以所有检查的结果都是相等的, 因为只比较了父类的那两个字段.
为什么会调用 Citizen 父类的 == 方法呢? 因为该方法是静态的, 也就不是 virtual 的. 而我的测试代码:
其参数类型是父类 Citizen, 所以 a==b 这句话会在编译时就决定采取哪个版本的 == 实现, 而编译器在这个方法里会看到 a 和 b 的类型都是 Citizen, 所以它会调用 Citizen 版本的 == 实现.
所以这确实是一个陷阱.
但是为什么原来的写法就没有问题呢?
原来的写法里, 在 Citizen 这个父类里,== 的实现调用了 object 的静态 Equals()方法, 而在这个静态 Equals 方法里:
又调用了 object 的 virtual Equals()方法, 而如果实际类型是 BeijingCitizen 的话, 那么就会调用 override 的 Equals()方法, 我们单独看这个比较:
在 BeijingCitizen 里设一个断点:
可以看到会击中该断点. 也可以看一下 CallStack:
现在再次运行所有测试, 其结果:
就是正确的了.
所以说, 相等性检查的逻辑需要放在 virtual 的方法里.
如果再往上一级, 把参数都变成 object 类型:
输出结果是:
这是因为 == 的实现不是 virtual 的, 在 object 类型上使用 == 就是判断引用的相等性. 而你也无法在重载操作符来防止上述事情的发生, 因为这段代码永远不会调用到你的操作符重载方法.
那么结论就是, 在操作符重载方法里调用 vitual 的方法, 就可以应付继承相关的相等性判断, 但是至少也得输入你定义的父类的类型(Citizen), 好让你定义的操作符重载方法可以被最先调用. 如果要满足继承, 相等性这两方面的要求, 那么就需要牺牲类型安全:
所以 == 操作符重载, 可以看作一种方便的语法糖法, 同时也把类型不安全的 Equals()方法包装了起来.
为什么不实现 IEquatable<T>
如果我在 Citizen 类里面实现了该接口:
那么方法里的调用也还是调用 virtual 的 Equals(), 否则的话还是一样的 bug. 那么这样看的话, 实现该接口几乎没有什么新鲜的作用, 虽然说该方法可以做到一定程度的类型安全, 但是性能上, 比直接调用 object.Equals()更慢了.
所以针对引用类型, 不建议实现 IEquatable<T > 接口.
非得实现的话建议 sealed
例如:
这样的话, 我们就可以把判断相等的逻辑写在该方法里了, 因为这个类是 sealed, 所以能传递到这个方法里的变量一定是该类型的, 没有继承的存在, 我们就可以同时拥有类型安全和相等性了.
为 sealed 的 class 实现 IEquatable<T > 接口肯定是可行的, 但是否值得呢?
优点: 能得到微小的性能提升, string 就是个例子.
缺点: class 本身就更复杂了, 你需要记住 3 种实现相等性判断的方式...
综上个人建议是针对引用类型不去实现 IEquatable<T > 接口.
来源: https://www.cnblogs.com/cgzl/p/10725700.html