C#相等性比较
本文阐述C#中相等性比较,其中主要集中在下面两个方面
==和!=运算符,什么时候它们可以用于相等性比较,什么时候它们不适用,如果不使用,那么它们的替代方式是什么?
什么时候,需要自定一个类型的相等性比较逻辑
在阐述相等性比较,以及如何自定义相等性比较逻辑之前,我们首先了解一下值类型比较和引用类型比较
值类型比较对比引用类型比较
C#中的相等性比较有两种:
- 值类型相等,两个值在某种场景下相等
- 引用类型相等,两个引用指向同一个对象
默认情况下,
- 值类型使用值类型相等
- 引用类型使用引用相等
实际上,值类型只能使用值相等(除非值类型进行了装箱操作)。看一个简单的例子(比较两个数字),运行的结果为True
int x = 5, y = 5; Console.WriteLine(x == y);
默认地,引用类型使用引用相等。比如下面的例子:返回False
object x = 5, y = 5; Console.WriteLine(x == y);
如果x和y指向同一个对象,那么将返回True:
object x = 5, y = x; Console.WriteLine(x == y);
相等性的标准
下面三个标准用于实现相等性比较:
- ==和!=运算符
- object中的虚方法Equals
- IEquatable<T>接口
下面我们来分别阐述
1. ==和!=运算符
使用==和!=的原因是它们是运算符,它们通过静态函数实现相等性比较。因此,当你使用==或!=时,C#在编译时就决定了所比较的类型,而且不会执行任何虚方法(Object.Equals)。这是大家所期望的相等行比较。比如在第一个数字比较的例子中,编译器在编译时就决定了执行==运算的类型是int类型,因为x和y都是int类型。
而第二个例子,编译器决定执行==运算的类型是object类型,因为object是类(引用类型),因此对象的==运算符采取引用相等去比较x和y。那么结果就返回False,这是因为x和y指向堆上不同的对象(被装箱的int)
2. Object.Equals虚方法
为了正确地比较第二个例子中的x和y,我们可以使用Equals虚方法。System.Object中定义了Equals虚方法,它适用于所有类型
object x = 5, y = 5; Console.WriteLine(x.Equals(y));
Equals在程序运行时决定比较的类型--根据对象的实际类型进行比较。在上面的例子中,会调用Int32的Euqals方法,该方法使用值相等进行比较,所以上面的例子返回True。如果x和y是引用类型,那么调用引用相等进行比较;如果x和y是结构类型,那么Equals会调用结构每个成员对应类型的Equals方法进行比较。
看到这里,你可能会想,为什么C#的设计者不把==设计成virtaul,从而使其与Equals一样,以避免上诉缺陷。这是因为:
- 如果第一个运算对象是null,Equals方法会抛出NullReferenceException异常;而静态的运算符则不会
- 因为==运算符在编译时决定了比较类型(静态解析比较类型),那么它的执行就非常快。这也就使得编写大量运算代码去执行相等性比较时对性能不会带来太大的影响
- 有时候,==和Equals适用于不同的场景的相等性比较。(后续的内容会涉及)
简而言之,复杂的设计反映了复杂的场景:相等的概念涉及到许多场景。
而Euqals方法,适用于比较两个未知类型的对象,下面的这个方法就适用于比较任何类型的两个对象:
public static bool AreEqual(object obj1, object obj2) { return obj1.Equals(obj2); }
但是,该函数不能处理第一个参数是null的情形,如果第一个函数是null,你会得到NullReferenceException异常。因此我们需要对该函数进行修改:
public static bool AreEqual(object obj1, object obj2) { if (obj1 == null) return obj2 == null; return obj1.Equals(obj2); }
object的静态Equals方法
object类还定义了一个静态Equals方法,它的作用与AreEquals方法一样。
public static bool Equals(Object objA, Object objB) { if (objA==objB) { return true; } if (objA==null || objB==null) { return false; } return objA.Equals(objB); }
这样就可以对编译时不知道类型的null对象进行安全地比较。
object x = 5, y = 5; Console.WriteLine(object.Equals(x, y)); // -> True x = null; Console.WriteLine(object.Equals(x, y)); // -> False y = null; Console.WriteLine(object.Equals(x, y)); // -> True Console.WriteLine(x.Equals(y)); // -> NullReferebceException, because x is null
请注意,当编写Generic类型时,下面的代码将不能通过编译(除非把==或!=运算符替换成Object.Equals方法的调用):
public class Test<T> : IEqualityComparer<T> { T _value; public void SetValue(T newValue) { // Operator '!=' cannot be applied to operands of type 'T' and 'T' // it should be : if(!object.Equals(newValue, _value)) if (newValue != _value) _value = newValue; } }
object的静态ReferenceEquals方法
有时候,你需要强行比较两个引用是否相等。这个时候,你就需要使用object.ReferenceEquals:
internal class Widget { public string UID { get; set; } public override bool Equals(object obj) { if (obj == null) return this == null; if (!(obj is Widget)) return false; Widget w = obj as Widget; return this.UID == w.UID; } public override int GetHashCode() { return this.UID.GetHashCode(); } public static bool operator == (Widget w1, Widget w2) { return w1.Equals(w2); } public static bool operator !=(Widget w1, Widget w2) { return !w1.Equals(w2); } } static void Main(string[] args) { Widget w1 = new Widget(); Widget w2 = new Widget(); Console.WriteLine(w1==w2); // -> True Console.WriteLine(w1.Equals(w2)); // -> True Console.WriteLine(object.ReferenceEquals(w1, w2)); // -> False Console.ReadLine(); }
之所以调用ReferenceEquals方法,这是因为自定义类Widget重写了object类的虚方法Equals;此外,该类还重写了操作符==和!=,因此执行==时操作也返回True。所以,调用ReferenceEquals可以确保返回引用是否相等。
3. IEquatable<T>接口
调用object.Equals方法实际上对进行比较的值类型进行了装箱操作。在对性能有较高要求的场景,那么就不适合使用这种方式。从C#2.0开始,通过引入IEquatable<T>接口来解决这个问题
public interface IEquatable<T> { bool Equals(T other); }
当实现IEquatable接口之口,调用接口方法就等同于调用objet的虚方法Equals,但是接口方法执行地更快(不需要类型转换)。大多数.NET基本类型都实现了IEquatable<T>接口,你还可以为Generic类型添加IEquatable<T>限制
internal class Test<T> where T : IEquatable<T> { public bool IsEqual(T t1, T t2) { return t1.Equals(t2); } }
如果,我们移除IEquatable<T>限制,Test<T>类仍可以通过编译,但是t1.Equals(t2)将使用object.Equals方法。
4. 当Equals结果与==的结果不一致
在前面的内容中,我们已经提到有时候,==或equals适用于不同的场景。比如:
double x = double.NaN; Console.WriteLine(x == x); // False Console.WriteLine(x.Equals(x)); // True
这是因为double类型的==运算符强制NaN不等于其他任何值,即使另外一个NaN。从数学的角度来讲,两个确实不相等。而Equals方法,因为具有对称性,所以x.Equals(x)总返回True。
集合与字典正是依赖于Equals的对称性,否则就不能找到已经保存在集合或字典中的元素。
对于值类型而言,Equals和==很少出现不同的相等性。而在引用类型中,则比较常见。一般地,引用类型的创建者重写Equals方法执行值相等比较,而保留==执行引用相等比较。比如StringBuilder类就是这样的:
StringBuilder buffer1 = new StringBuilder("123"); StringBuilder buffer2 = new StringBuilder("123"); Console.WriteLine(buffer1 == buffer2); // False Console.WriteLine(buffer1.Equals(buffer2)); // True
比较自定义类型
回顾一下默认的比较行为
- 值类型使用值相等
- 引用类型使用引用相等
进一步,
- 结构类型的equals方法会根据每个字段的类型进行相等行比较
有时候,在创建类型时,需要重写上述行为,一般在下面两种情形下需要重写:
- 更改相等的意义
- 提高结构类型的比较速度
1)更改相等的意义
当默认的==和Equals不适用(不符合自然规则,或悖离了使用者的期望)于自定义类型时,就需要更改相等的意义。比如DateTimeOffset结构,其有两个私有成员:一个DateTime类型的UTC,以及int类型的offset。如果是你在创建DateTimeOffset类型,那么你很可能只要UTC字段相等即可,而不去比较Offset字段。另外一个例子就是支持NaN的数字类型,比如float和double,如果你来创建这两个类型,你可能会希望NaN也是可以进行比较的。
而对于Class类型,很多时候,使用值比较更有意义。尤其是一些包含较少数据的类,比如System.Uri或System.String
2)提高结构类型的比较速度
结构类型的默认比较算法相对较慢。通过重写Equals方法可以提高5%的性能。而重载==运算和实现IEquatable<T>可以在不装箱操作的情况下实现相等性比较,这使得提高5%性能变得可能。
对于自定义相等比较,有一个特殊的情形,更改结构类型的hashing算法后,hashtable可以获得更好的性能。这是因为hashing算法和相等性比较都发生在栈上。
3)如何重写相等
总地来说,有下面三种方式:
- 重写GetHashcode()和Equals()
- 【可选】重载!=和==
- 【可选】实现IEquatable<T>
I)重写GetHashCode
object对象的虚方法GetHashCode,也就仅仅对于Hashtable类型和Dictionary<TKey,TValue>类型有益。
这两个类型都是哈希表集合,集合中的每个元素都是一个键值用于存储元素和获取元素。哈希表使用了一个特定的策略以有效地基于元素的键值分配元素。这就要求每个键值都有一个Int32数(或哈希码)。哈希码不仅对于每个键值是唯一的,而且还必须有较好的性能。哈希表认为object类定义的GetHashCode方法已经足够了,因此这两个类型都省略了获取哈希码的方法。
无论值类型还是引用类型,都默认实现了GetHashCode方法,所以你不用重写这个方法,除非你需要重写Equals方法。(因此,如果你重写了GetHashCode方法,那么你肯定是需要重写Equals方法)。
是否需要重写GetHashCode方法,可以参考下面的规则:
- 如果Equals方法返回True是,两个比较的对象必须返回相同的哈希码
- 不允许抛出异常
- 除非对象变化了,那么重复的对一个对象调用GetHashCode方法应返回相同的哈希码
为了提高哈希表的性能,GetHashCode需要重写以防止不同的值返回相同的哈希码。也这就说明了为什么需要对结构类型需要重写Equals和GetHashCode方法,因此这样重写比默认的哈希算法更有效率。结构类型的GetHashCode方法的默认实现是在运行时才发生,而且很可能基于结构的每个成员而实现。
// char type public override int GetHashCode() { return (int)m_value | ((int)m_value << 16); } // int32 public override int GetHashCode() { return m_value; }
而类(class)类型,GetHashCode方法的默认实现基于内部对象标识,这个标识在CLR中对于每个对象实例都是唯一的。
public virtual int GetHashCode() { return RuntimeHelpers.GetHashCode(this); }
II)重写Equals
object.Equal的规定(公理)如下:
- 一个对象不能和null相等(除非对象是nullable类型)
- 相等性是对称的(一个对象等于自身)
- 相等性是可交换的(如果a等于b,那么b也等于a)
- 相等性是可传递的(如果a等于b,b等于c,那么a等于c)
- 相等性是可重复的,并且是可靠的(不会抛出异常)
III)重载==和!=
除了可重写Equals,还可以重载等于和不等于运算符。
对于结构类型,基本都重载了等于和不等于运算符,如果不重载它们,那么对于结构类型,等于和不等于将返回错误的结果;
而对于类(class)类型,有两种处理方式:
- 不重载==和!=,因为它们会执行引用相等
- 重载==和!=,使其与Equals一致
第一种实现适用于大多数自定义类型,特别是可变(mutable)类型。它确保了自定义类型符合==和!=就应该执行引用相等性的比较,从而不会误导这些自定义的使用者。再次回顾一下前面举过的StringBuilder例子
StringBuilder buffer1 = new StringBuilder("123"); StringBuilder buffer2 = new StringBuilder("123"); Console.WriteLine(buffer1 == buffer2); // False, Reference equality Console.WriteLine(buffer1.Equals(buffer2)); // True, Value equality
而第二种实现适用于使用者永远都不希望自定义类型执行引用相等。一般地这些都类型都是不可变(immutable)类型,比如string类型和System.Uri类型,当然也包含一些引用类型。
III)实现IEquatable<T>
为了保持完整性,建议在重写Equals方法时,同时实现IEquatable<T>接口。接口方法的结果应当与自定义重写后Equals方法的结果一致。如果你已经重写了Equals方法,那么实现IEquatable<T>不需要额外的实现代码(直接调用Equlas方法即可)
internal class Staff : IEquatable<Staff> { public string FirstName { get; set; } // implements IEquatable<Staff> public bool Equals(Staff other) { return this.FirstName.Equals(other.FirstName); } // override Equals public override bool Equals(object obj) { if (obj == null) return this == null; if (!(obj is Staff)) return false; Staff s = obj as Staff; return this.FirstName == s.FirstName; } // override GetHashCode public override int GetHashCode() { return this.FirstName.GetHashCode(); } }
IV)可插入的相等比较器
如果你希望一个类型在某一个特定的场景下使用不同的比较,那么你可以使用可插件式的IEqualityComparer。它特别适用于集合类。(后续有内容介绍)
相等性比较总结
C#类库中,为相等性比较设计了三个接口:IEquatable<T>,IEqualityComparer,以及IEqualityComparer<T>。
IEqualityComparer与IEqualityComparer<T>的差别很简单,一个是非Generic的,需要把T转换成Object,然后调用Object的Equals方法;而后者直接调用T类型实例的Equals方法。
那么IEquatable<T>和IEqualityComparer<T>有什么差别,分别适用于什么场景呢?
1. IEquatable<T>用于比较与自己类型相同的另一个对象是否相等;而IEqualityComparer<T>则用于比较两个相同类型的实例是否相等。
2. 如果两个实例是否相等只有一种可能,或者有几个是否相等的比较但只有其中一个更有意义,那么应该选择IEquatable<T>,T类型自己实现IEquatable<T>接口。因此IEquatable<T>的实例自己就知道该如何比较自己和另外一个实例。与之相反,如果需要比较的实例之间存在多个相等性比较,那么IEqualityComparer<T>更适合这种情况;这个接口不会由T类型实现,相反需要一个外部的类实现IEqualityComparer<T>接口。因为,当比较两个类型实例是否相等时,因为T类型内部不知道如何比较,那么你就需要显示地指定一个IEqualityComparer<T>实例用于执行相等性比较从而满足特定的需求。
3. 例子
internal class Staff : IEquatable<Staff> { public string FirstName { get; set; } public string Title { get; set; } public string Dept { get; set; } public override string ToString() { return string.Format( "FirstName:{0}, Title:{1}, Dept:{2}", FirstName, Title, Dept); } // implements IEquatable<Staff> public bool Equals(Staff other) { return this.FirstName.Equals(other.FirstName); } // override Object.GetHashCode public override int GetHashCode() { return this.FirstName.GetHashCode(); } } internal class StaffTitleComparer : IEqualityComparer<Staff> { public bool Equals(Staff x, Staff y) { return x.Title == y.Title; } public int GetHashCode(Staff obj) { return obj.Title.GetHashCode(); } } internal class StaffDeptComparer : IEqualityComparer<Staff> { public bool Equals(Staff x, Staff y) { return x.Dept == y.Dept; } public int GetHashCode(Staff obj) { return obj.Dept.GetHashCode(); } } static void Main(string[] args) { IList<Staff> staffs = new List<Staff> { new Staff{FirstName="AAA", Title="Manager", Dept="Sale"}, new Staff{FirstName="BBB", Title="Accountant", Dept="Finance"}, new Staff{FirstName="BBB", Title="Accountant", Dept="Finance"}, new Staff{FirstName="AAA", Title="Sales", Dept="Sale"}, new Staff{FirstName="ABA", Title="Manager", Dept="HR"} }; Print("All Staffs", staffs); Print("No duplicated first name", staffs.Distinct()); Print("No duplicated title", staffs.Distinct(new StaffTitleComparer())); Print("No duplicated department", staffs.Distinct(new StaffDeptComparer())); Console.ReadLine(); } private static void Print(string group, IEnumerable<Staff> staffs) { Console.WriteLine(group); foreach (Staff s in staffs) Console.WriteLine(s.ToString()); Console.WriteLine(); }
--update--
最后一个例子,还可以通过扩展IEnumeable<T>来实现DistinctBy:
public static class IEnurambleExtension { public static IEnumerable<TSource> DistinctBy<TSource, TKey> (this IEnumerable<TSource> source, Func<TSource, TKey> keySelector) { HashSet<TKey> keys = new HashSet<TKey>(); foreach (TSource element in source) if (keys.Add(keySelector(element))) yield return element; } }
可以这样使用
staffs.DistinctBy(s => s),注意,staff类需要实现IEquatable<T>(或重写Equals和GetHashCode)
staffs.DistinctBy(s => s.Dept),这就省去了编写StaffDeptComparer类
进一步,如果staff的某个字段是一个类,那么这个类同样需要实现IEquatable<T>(或重写Equals和GetHashCode)
参考资料
1. C# 5.0 in a Nutshell;
2. MSDN, IEquatable<T>, http://msdn.microsoft.com/en-us/library/ms131187.aspx;
3. MSDN IEqualityComparer, http://msdn.microsoft.com/en-us/library/ms132151.aspx;
4. Stackoverflow, http://stackoverflow.com/questions/9316918/what-is-the-difference-between-iequalitycomparert-and-iequatablet