# 原则6:理解几个不同相等概念的关系
**By D.S.Qiu**
尊重他人的劳动,**支持原创,转载请注明 [出处](/blog/1980083) :[http://dsqiu.iteye.com](http://dsqiu.iteye.com)**
当你定义类型(类或结构体)时,你同时要定义类型的相等。 C# 提供四种不同的函数决定两个不同对象是否“相等”:
```
public static bool ReferenceEquals (object left, object right);
public static bool Equals (object left, object right);
public virtual bool Equals(object right);
public static bool operator ==(MyClass left, MyClass right);
```
C# 语言运行你实现这四个函数的自己的版本。但不意味着你需要这么做。你不需要重定义前面两个静态函数。你经常会创建你自己实例方法 Equals() 去定义你定义类型的语义,有时也会重写操作符==() ,尤其是值类型。此外,这四个函数是有关联的,所以你改变其中一个,可能会影响其他函数的行为。所以你需要完全测试这个四个函数。但是不担心,你可以简化它。
当然,这四个方法不是判断是否相等的唯一选择。还可以通过类型去实现 IEquatable<T> 重写 Equals() 。如果类型是值类型的需要实现 IStructuralEquality 接口。这就说,总共有6中不同的方法去表达相等。
和 C# 中复杂的元素一样,这个也遵守这个事实: C# 运行你同时创建值类型和引用类型。两个引用类型变量当它们引用相同的对象时相等。好像引用它们的ID一样。两个值类型的变量当它们是相同的类型而且包含相同的内容时才相等。这就是为什么对这些方法都进行相等测试。
我们先从两个不会修改的方法开始。 Object.ReferenceEquals() 当两个变量指向相同对象——也就是说,两个变量含有相同对象的ID时返回 true 。是否比较值类型或引用类型,这个方法总是测试对象ID,而不是对象的内容。也就是说,当你使用测试两个值类型变量相等时, ReferenceEquals() 会返回 false 。即使你拿一个值类型变量和自己比较, ReferenceEquals() 也会返回 fasle 。这是因为封箱操作,你可以在原则45找到相关内容。
```
int i = 5;
int j = 5;
if (Object.ReferenceEquals(i, j))
Console.WriteLine("Never happens.");
else
Console.WriteLine("Always happens.");
if (Object.ReferenceEquals(i, i))
Console.WriteLine("Never happens.");
else
Console.WriteLine("Always happens.");
```
你绝不需要重定义 Object.ReferenceEquals() ,因为它已经支持了它的功能了:测试两个不同变量的对象ID。
第二个你不要重新定义的是静态方法 Object.Equals() 。当你不知道两个参数的运行时参数是,用这个方法可以测试两个变量是否相等。记住 System.Object 是 C# 所有类型的最终基类。无论什么时候,你比较的两个变量都是 System.Object 的实例。值类型和引用类型都是 System.Object 的实例。来看下当不知道类型是,这个方法是如何判断两个变量是否相等的,相等是否依赖类型?答案很简单:这个方法即使把职责委托给其中一个正在比较的类。静态 Object.Equals() 方法的是像下面这样实现的:
```
public static new bool Equals(object left, object right)
{
// Check object identity
if (Object.ReferenceEquals(left, right) )
return true;
// both null references handled above
if (Object.ReferenceEquals(left, null) || Object.ReferenceEquals(right, null))
return false;
return left.Equals(right);
}
```
上面实例代码引入一个还没有介绍的方法:即,实例的 Equals() 方法。我将会详细介绍,但是我还没打算终止对静态 Equals() 的讨论。我希望你能明白静态 Equals() 方法使用了左参数的实例 Equals() 方法来判断两个对象是否相等。
和 ReferenceEquals() 一样,你不需要重载或重定义自己版本的静态 Object.Equals() 方法因为它已经做了它需要做的事情:当我们不知道运行时类型时,决定两个对象是否相等。因为静态 Equals() 把比较委托给左边参数的实例 Equals() 方法,就是利用这个规则来处理类型的。
既然你明白了为什么不需要重定义静态 ReferenceEquals() 和静态 Equals() 方法。接下来就讨论下你需要重写的方法。但是首先,让我们简要来讨论相等的关系的数学特性。你需要保证你定义的和实现的方法要和其他程序员的期望是一致的。这几意味着你需要关心数学的相等关系:相等是自反的,对称的,可传递的。自反性就是说任何对象都和自身相等。无论类型是什么 a == a 总是 true 的。对称型即与比较的次序是没有关系的:如果 a == b 是 true ,b == a 同样也是 true 的。如果 a == b 是 false , b == a 也是 false 。最后一个性质就是如果 a == b 而且 b == c 都是 ture ,那么 a == c 必须是 true 的。这就是传递性。
现在是时候讨论实例的 Object.Equals() 函数了,包括什么时候和怎么样重写它。当你发现默认的 Equals() 的行为和你的类型不一致时,你就需要创建自己的实例 Equals() 版本。 Ojbect.Equals() 方法使用对象的ID来决定两个变量是否相等。默认的 Object.Equals() 函数和 Object.ReferenceEquals() 的表现是一样的。等等——值类型是不同的, System.ValueType 没有重写 Object.Equals() 。记住 ValueType 是所有值类型(使用 struct 关键字)的基类。两个值类型变量当它们类型相同和有相同的内容时是相等的。 ValueType.Equals() 实现就是这个行为。不好的是, ValueType.Equals() 没有一个很高效的实现。 ValueType.Equals 是所有值类型的基类。为了提供正确的行为,你必须在不知道对象的运行时类型的情况下比较子类的所有成员变量。在 C# ,会使用反射来做。你可以查看下原则43。反射有很多不足的地方,尤其当性能是目标时。 相等是在程序中会被频繁调用的集成操作之一,所以性能是值得考虑的。在大多数情况下,你可以重写一个更快的值类型 Equals() 。对于值类型的建议是很简单的:当你创建一个值类型,总是重写 ValueType.Equals() 。
只有当你想要定义引用类型的语义是,你需要重写实例 Equals() 函数。 .NET 框架的一些类都是使用值类型而不是引用类型来判断是否相等。两个 string 对象相等当它们的内容是一样的。两个 DataRowView 对象相等当它们指向同一 DataRow 。关键就是要你的类型服从值语义(比较内容)而不是引用语义(比较对象的ID),你应该重写你自己的实例 Equals() 。
既然你知道什么时候去重写你自己的 Object.Equals() ,你需要命名怎么样实现它。值类型的相等关系封箱有很多补充,在原则45会被讨论。对于引用类型,你的实例方法需要保留之前的行为,避免给使用者惊讶。当你重写 Equals() ,你的类型要实现 IEquatable<T> 。对这点,我会解释的更多一点。这里标准模式只是重写了 System.Object.Equals 。高亮的代码是改为实现 IEquatable<T>。
```
public class Foo : IEquatable<Foo>
{
public override bool Equals(object right)
{
// check null:
// this pointer is never null in C# methods.
if (object.ReferenceEquals(right, null))
return false;
if (object.ReferenceEquals(this, right))
return true;
// Discussed below.
if (this.GetType() != right.GetType())
return false;
// Compare this type's contents here:
return this.Equals(right as Foo);
}
#region IEquatable<Foo> Members
public bool Equals(Foo other)
{
// elided.
return true;
}
#endregion
}
```
首先, Equals() 不能抛出异常——这个没有任何意义。两个变量比较只有相等和不相等,没有其他结果。像 null 引用或错误参数类型的所有错误情况都应该返回 false 。现在,我们详细分析下这个方法的代码,命名为什么每一步的检查和哪些检查是可以被遗漏的。第一个检查右边蚕食是否为 null 。没有任何在 this 的引用上没有任何检查。 C# 中,它是一定不会为 null 的。 CLR 在通过 null 引用调用实例方法会抛出异常。下一个检查是否两个对象的引用是否一样,测试两个对象的ID。这是一个非常有效的测试,内容要相同对象的ID一定要相同。
在下一个检查要比较的两个对象是否是同一个类型。正确的形式是非常重要的。首先,主要是不是假定这就是 类 Foo ;而是调用 this.GetType() 。实际类可能是 Foo 的子类。第二,代码检查被比较对象的真正类型。这还不足以保证你可以把右边参数转换为当前类型。这个测试会导致两个微妙的错。考虑下面有关继承结构的例子:
```
public class B : IEquatable<B>
{
public override bool Equals(object right)
{
// check null:
if (object.ReferenceEquals(right, null))
return false;
// Check reference equality:
if (object.ReferenceEquals(this, right))
return true;
// Problems here, discussed below.
B rightAsB = right as B;
if (rightAsB == null)
return false;
return this.Equals(rightAsB);
}
#region IEquatable<B> Members
public bool Equals(B other)
{
// elided
return true;
}
#endregion
}
public class D : B, IEquatable<D>
{
// etc.
public override bool Equals(object right)
{
// check null:
if (object.ReferenceEquals(right, null))
return false;
if (object.ReferenceEquals(this, right))
return true;
// Problems here.
D rightAsD = right as D;
if (rightAsD == null)
return false;
if (base.Equals(rightAsD) == false)
return false;
return this.Equals(rightAsD);
}
#region IEquatable<D> Members
public bool Equals(D other)
{
// elided.
return true; // or false, based on test
}
#endregion
}
//Test:
B baseObject = new B();
D derivedObject = new D();
// Comparison 1\.
if (baseObject.Equals(derivedObject))
Console.WriteLine("Equals");
else
Console.WriteLine("Not Equal");
// Comparison 2\.
if (derivedObject.Equals(baseObject))
Console.WriteLine("Equals");
else
Console.WriteLine("Not Equal");
```
在任何可能的情况下,你都希望看到相等或不想的打印两次。因为一些错误,这已经不是前面的代码了。第二个比较不会返回 true 。基类 B 的对象,不会被转换为 D 的对象。然后第一个比较可能会评估为 true 。子类 D 可以被隐式转换为 B 。如果右边参数的 B 成员可以匹配左边参数的 B 成员, B.Equals() 认为两个对象是相等的。即使两个对象是不同的类型,你的方法还是认为他们是想的的。这就违背了相等的对称性。这是因为在类的继承结构中自动转换的发生。
如果这样写:把类型 D 对象显式转换为 B :
```
baseObject.Equals(derived);
```
derivedObject.Equals() 方法总是返回 false 。如果你不精确检查对象的类型,你会很容易进入这种情况,比较对象的次序会成为一个问题。
上面所有的例子中,重写 Equals() ,还有另外一种方法。重写 Equals() 意味着你的类型应该实现 IEquatable<T> 。IEquatable<T> 包含一个方法: Equals(T other) 。实现 IEquatable<T> 意味着你的类型要支持一个类型安全的相等比较。如果你认为 Equals() 只有左右两个参数的类型都相等才返回 true 。 IEquatable<T> 很简单地让编译器捕捉多次两个对象不相等。
还有另外一种方法重写 Equals() 。只有当基类的版本不是 System.Object 或 System.ValueType ,你就应该调用基类的方法。前面的例子,类 D调用 Equals() 就是在基类B中定义的。然而,类 B 调用的不是 baseObject.Equals() 。System.Object 的版本只有当两个参数引用同一个对象才会返回 true 。这并不是你想要的,或者你应该没有在第一个基类中重写自己的方法。
原则就是这样,如果你创建一个值类型你就要重写 Equals() ,如果是引用类型你不想遵循 System.Object 的引用语义就要重写 Equals() 。当你重写你自己的 Equals() ,你应该遵循上面列出的要点实现。重写 Equals() 意味着你要重写 GetHashCode() 查看原则7。
我们几乎完成了本原则。 操作符 ==() 是简单的。无论什么时候你创建一个值类型,重定义操作符 ==() 。原因和实例 Equals 函数一样。默认的版本使用反射区比较两个值类型的内容。这是比任何你实现的都更低效的,所以你要自己重写。遵循原则46的建议避免封箱当比较两个值类型时。
注意的是我们没有说当你重写实例Equals() 你就应该重写操作符 ==() 。我说的是你应该重写操作符 ==() 当你创建值类型的时候。你几乎不用重写操作符 ==() 当你创建引用类型是。 .NET 框架期望操作符 ==() 所有引用类型遵循引用语义。
最后,我们说下 IStructuralEquality ,System.Array 和 Tuple<> 泛型实现这个接口。它让这些类型实现值语义而不强制比较时的值类型。你会对创建一个值类型是否实现 IStructuralEquality 留有疑惑。这个只有在创建轻量级类型需要。实现 IStructuralEquality 声明一个类是可以被组合成一个基于值语义的大对象。
C# 提供了很多方式去测试是否相等。但是你需要考虑提供自定义的它们其中的两种,支持类似的接口。你不需要重写静态 Object.ReferenceEquals() 和静态Object.Equals() 因为它们能提供正确的参数,尽管不知道它们的运行时类。你总是要重写实例 Equals() 和操作符 ==() 对于值类型可以提供性能。当你想要引用类型相等而不是对象ID相等,你需要重写实例 Equals() 。当你重写 Equals() ,你就应该是实现 IEquatable<T> 。很简单,是不是?
小结:
这篇的内容是我们最熟悉的,翻译的有点匆忙,平时虽然用的很多,但是都没有深入研究过,还是有很多细节很受用的,比如值类型效率问题等。一个星期翻译六篇,从量上看,还是比较满意的,虽然每天都折腾的很晚,今天虽然是周末,我也没有出去过,不过今天的效率太高了。但质还是不尽如人意的,至少没有一点原创的感觉,以后多做回顾和修改。
下周要好好工作,把手上的工作完成到100%,无可挑剔,加油,我可以做得到的!
欢迎各种不爽,各种喷,写这个纯属个人爱好,秉持”分享“之德!
有关本书的其他章节翻译请[点击查看](/category/297763),转载请注明出处,尊重原创!
如果您对D.S.Qiu有任何建议或意见可以在文章后面评论,或者发邮件(gd.s.qiu@gmail.com)交流,您的鼓励和支持是我前进的动力,希望能有更多更好的分享。
转载请在**文首**注明出处:[http://dsqiu.iteye.com/blog/1980083](/blog/1980083)
更多精彩请关注D.S.Qiu的博客和微博(ID:静水逐风)
- 第一章 C# 语言习惯
- 原则1:使用 属性(Poperty)代替可直接访问的数据成员(Data Member)
- 原则2:偏爱 readonly 而不是 const
- 原则3:选择 is 或 as 而不是强制类型转换
- 原则4:使用条件特性(conditional attribute)代替 #if
- 原则5:总是提供 ToString()
- 原则6:理解几个不同相等概念的关系
- 原则7:明白 GetHashCode() 的陷阱
- 原则8:优先考虑查询语法(query syntax)而不是循环结构
- 原则9:在你的 API 中避免转换操作
- 原则10:使用默认参数减少函数的重载
- 原则11:理解小函数的魅力
- 第二章 .NET 资源管理
- 原则12:选择变量初始化语法(initializer)而不是赋值语句
- 原则13:使用恰当的方式对静态成员进行初始化
- 原则14:减少重复的初始化逻辑
- 原则15:使用 using 和 try/finally 清理资源
- 原则16:避免创建不需要的对象
- 原则17:实现标准的 Dispose 模式
- 原则17:实现标准的 Dispose 模式
- 原则18:值类型和引用类型的区别
- 原则19:确保0是值类型的一个有效状态
- 原则20:更倾向于使用不可变原子值类型
- 第三章 用 C# 表达设计
- 原则21:限制你的类型的可见性
- 原则22:选择定义并实现接口,而不是基类
- 原则23:理解接口方法和虚函数的区别
- 原则24:使用委托来表达回调
- 原则25:实现通知的事件模式
- 原则26:避免返回类的内部对象的引用
- 原则27:总是使你的类型可序列化
- 原则28:创建大粒度的网络服务 APIs
- 原则29:让接口支持协变和逆变
- 第四章 和框架一起工作
- 原则30:选择重载而不是事件处理器
- 原则31:用 IComparable&lt;T&gt; 和 IComparer&lt;T&gt; 实现排序关系
- 原则32:避免 ICloneable
- 原则33:只有基类更新处理才使用 new 修饰符
- 原则34:避免定义在基类的方法的重写
- 原则35:理解 PLINQ 并行算法的实现
- 原则36:理解 I/O 受限制(Bound)操作 PLINQ 的使用
- 原则37:构造并行算法的异常考量
- 第五章 杂项讨论
- 原则38:理解动态(Dynamic)的利与弊
- 原则39:使用动态对泛型类型参数的运行时类型的利用
- 原则40:使用动态接收匿名类型参数