C# 相等比较
有两种类型的相等:
- 值相等:即两个值是一样的
- 引用相等:即引用是一样的,也就是同一个对象
默认地,对于值类型来讲,相等指的就是值相等;对于引用类型,相等就是指的引用相等。
int a = 5;
int b = 5;
Console.WriteLine(a == b);
class Foo { public int x; }
Foo f1 = new Foo { x = 5 };
Foo f2 = new Foo { x = 5 };
Console.WriteLine(f1 == f2);
标准相等协议
有三种标准相等协议:
==
和!=
运算符- object的虚函数
Equals
- `IEquatablee
接口
另外还有pluggable
协议,IStructuralEquatable
接口。
==和!=
当使用==
和!=
,C#在编译的过程就确定哪个类型来进行比较,不需要调用虚方法。
下面例子,编译器用int类型的==
:
int x =5;
int y=5;
Console.WriteLine(x==y);//return True;
下面例子,编译器用object类的==
:
object x=5;
object y=5;
Console.WriteLine(x==y);//return false;
Foo f1 = new Foo { x = 5 };
Foo f2 = null;
Console.WriteLine(f2==f2);//true
Console.WriteLine();
虚方法 Object.Equals
object x = 5;
object y = 5;
Console.WriteLine(x.Equals(y));
虚方法默认为这样的:
public virtual bool Equals(object obj)
{
if(obj==null) return false;
if(GetType() != obj.GetType()) return false;
if(obj==this)
Return true;
}
由此可以看出,默认的实现其实比较的是两个对象的内存地址。值类型和string类型除外,因为所有值类型继承于System.ValueType()(System.ValueType()同样继承于Object,但是System.ValueType()本身却是引用类型),而System.ValueType()对Equals()和==操作符进行了重写,是逐字节比较的。而string类型是比较特殊的引用类型,所以strIng在很多地方都是特殊处理的,此处就不做深究了。
int a = 5;
int b = 5;
Console.WriteLine(a.Equals(b));//true
string a = "abc";
string b = "abc";
Console.WriteLine(a.Equals(b));//true
string a = "abc";
string b = "abc";
Console.WriteLine(a==b);//true
int a = 5;
int b = 5;
Console.WriteLine(a == b);//true
Equals
方法在运行时根据object的实际类型来调用,在上例中,它调用了Int32
的Equals方法,所以是true.
int x = 5;
double y = 5;
Console.WriteLine(x.Equals(y));//return false
object x = 3, y = 3;
Console.WriteLine(x.Equals(y));
x = null;
Console.WriteLine(x.Equals(y));
y = null;
Console.WriteLine(x.Equals(y));
虚函数,如果调用者本身就是null,那么将抛出异常。
调用Int32
的Equals方法,然而x,y类型不一样,所以返回false,只有y也是Int类型,并且值与x一样的时候,才返回true
那么,为什么C#的设计者不通过让==
也变成虚方法,从而让其与Equals
等价,从而避免了复杂性?
其实它主要考虑了三个原因:
- 如果第一个操作数是null,
Equals
方法失效,会抛出NullReferenceException
;而==
不会。 ==
是静态的调用,所以它执行起来也相当快。- 有时候,
==
与Equals
可能对“相等”有不同的含义。
下面方法比较了任何类型是否相等:
public static bool AreEqual(object obj1,object obj2)
=> obj1==null?obj2==null:obj1.Equals(obj2);
静态方法object.Equals
object
类提供了一个静态方法,其作用与上面的AreEqual
作用一样,即可以比较任何类型是否相等,但需要装箱,包括是否是null,它就是Equals
,接受2个参数:
public static bool Equals(object obj1,object obj2)
这就提供了一个null-safe
的相等比较算法,当类型在编译时未确定,比如:
object x=3,y=3;
Console.WriteLine(object.Equals(x,y));//return true;
x=null;
Console.WriteLine(object.Equals(x,y));//return false;
y=null;
Console.WriteLine(object.Equals(x,y));//return true;
而如果上面Equals
全部用==
代替:
object x = 3, y = 3;
Console.WriteLine(x==y);
x = null;
Console.WriteLine(x == y);
y = null;
Console.WriteLine(x == y);
一个重要的应用就是当在写泛型类型的时候,就不能用==
:
所以必须这样写:
public class Test<T>{
T _value;
public void SetValue(T newValue)
{
if (!object.Equals(newValue,_value))
{
_value=newValue;
OnValueChanged();
}
}
protected virtual void OnValueChanged(){...}
}
==
运算符在这里是不允许的,因为它要在编译的时候就确定是哪个类型。
另一种方法是用EqualityComparer<T>
泛类,这避免了使用object.Equals
而必需的装箱。
静态方法 object.ReferenceEquals
有时候,需要强行执行引用对比,这时候就要用到了object.ReferenceEquals
.
object x = 3, y = 3;
Console.WriteLine(object.ReferenceEquals(x,y));
x = null;
Console.WriteLine(object.ReferenceEquals(x, y));
y = null;
Console.WriteLine(object.ReferenceEquals(x, y));
class Widget{...}
class Test
{
static void Main()
{
Widget w1=new Widget();
Widget w2=new Widget();
Console.WriteLine(object.ReferenceEquals(w1,w2));//return false
}
}
对于Widget
,有可能虚函数Equals
已经被覆盖了,以致w1.Equals(w2)
返回true,也有可能重载了==
运算符,以致w1==w2
返回true
在这种情况下,object.ReferenceEquals
保证了一般的引用相等的语义。
另一种强制引用相等的办法是先把value转换为object类型,然后用==运算符
IEquatable<T>
object.Equals
静态方法必须要装箱,这对于高性能敏感的程序是不利的,因为装箱是相对昂贵的,与实际的比较相比。解决办法就是IEquatable<T>
public interface IEquatable<T>
{
bool Equals(T other);
}
它给出调用object
的Equals
虚方法相同效果的结果,但更快,你也可以用IEquatable<T>
作为泛型的约束:
class Test<T> where T:IEquatable<T>
{
public bool IsEqual(T a,T b)
{
reurn a.Equals(b);//不用装箱
}
}
IsEqual
被调用时,它调用了a.Equals
,给出object的虚函数Equals的相同的效果,如果去掉约束,会发现仍然可以编译,但此时a.Equals
调用的是object.Equals
静态方法,也就是实际进行装箱操作了,所以就相对慢了些。
当Equals和==是不同的含义
有时候对于==
和Equals
赋予不同的“相等”含义是非常有用的,比如:
double x=double.NaN;
Console.WriteLine(x==x);//false
Console.WriteLine(x.Equals(x));//true
double
类型的==
运算符强制任何一个NaN
和任何一个数都不相等,对于另一个NaN
也不相等,从数学的角度,这是非常自然的。而Equals
,需要遵守一些规定,比如:
x.Equals(x) must always return true.
集合和字典就是依赖Equals
的这种行为,否则,就找不到之前储存的Item了。
对于值类型来讲,==
和Equals
有不同的涵义实际上是比较少的,更多的场景是引用类型,用==
来进行引用相等的判断,用Equals
来进行值相等的判断。StringBuilder
就是这样做的:
var sb1 = new StringBuilder("foo");
var sb2 = new StringBuilder("foo");
Console.WriteLine(sb1 == sb2);//false,reference equal
Console.WriteLine(sb1.Equals(sb2));//true
Equality and Custom type
值类型用值相等,引用类型用引用相等,结构的Equals
方法默认用structural value equality
(即比较结构中每个字段的值)。
当写一个类型的时候,有两种情况可能要重写相等:
- 改变相等的涵义
当默认的==
和Equals
对于所写的类型的涵义不是那么自然的时候,这时候,就有必要重新了。
- 加速结构的相等的比较
结构默认的structural equality
比较算法是相对慢的,通过重写Equals
可以提升它的速度,重载==
和IEquatable<T>
允许非装箱相等比较,又可以再提速。
重载引用类型的相等语义,意义不大,因为默认的引用相等已经非常快了。
如果定义的类型重写了Equals
方法,还应该重写GetHashCode
方法,事实上,如果类型重写Equals
的同时,没有重写GetHashCode
,C#编译器就会生成一条警告。
之所以还要定义GetHashCode
,是由于在System.Collections.Hashtable
类型,System.Collections.Generic.Dictionary
类型及其他一些集合的实现中,要求两个对象必须具有相同哈希码才视为相等,所以重写Equals
就必须重写GetHashCode
,确保相等性算法和对象哈希码算法一致,否则就是哈希码就是类型实例默认的地址。
而IEqualityComparer,IEqualityComparer<T>
接口则强行要求要同时实现Equals,GetHashCode方法,EqualityComparer
抽象类则同时继承了这两个接口,只需要重新Equals(T x,T,y),GetHashCode(T obj)
即可(https://www.cnblogs.com/johnyang/p/15417804.html),方便在需要判断是否两个对象相等的场景下,作为参数,或者调用者本身来使用。
重载相等语义的步骤:
- 重载
GetHashCode
()和Equals()
- (可选)重载
!=,==
,应实现这些操作符的用法,在内部调用类型安全的Equals - (可选)运用
IEquatable<T>
,这个泛型接口允许定义类型安全的Equals方法,通常重载的Equals接受一个Object参数,以便于在内部调用类型安全的Equals方法。
为什么在重写Equals()时,必须重写GetHashCode()的例子:
class Foo:IEquatable<Foo>
{ public int x;
public override bool Equals(object obj)//重写Equals算法,注意这里参数是object
{
if (obj == null)
return base.Equals(obj);//base.Equal是
return Equals(obj as Foo);
}
public bool Equals(Foo other) //实现IEquatable接口,注意这里参数是Foo类型
{
if (other == null)
return base.Equals(other);
return this.x == other.x;
}
//public override int GetHashCode()
//{
// return this.x.GetHashCode();
//}
}
public void Main()
{
var f1 = new Foo { x = 5 };
var f2 = new Foo { x = 3 };
var f3 = new Foo { x = 5 };
var flist = new List<Foo>();
flist.Add(f1);
flist.Add(f3);
flist.Add(f2);
Console.WriteLine(f1.Equals(f3));
Console.WriteLine(flist.Contains(f3));
Console.WriteLine(flist.Distinct().Count());
var dic = new Dictionary<Foo, string>();
dic.Add(f1,"f1");
dic.Add(f2, "f2");
Console.WriteLine(dic[f3]);
}
在注释了GetHashCode
重写代码后,我们运行上面的程序,就会发现,虽然Equals
可以正常工作,但对于list的distinct的数量,显然错误,f1既然是和f2相等,那么数量应该是2,还有最后发现字典访问键f3也访问不了了,这当然是没有重写GetHashCode
的后果,因为根据key取值的时候也是把key转换成HashCode而且验证Equals后再取值,也就是说,只要GetHashCode和Equlas中有一个方法没有重写,在验证时没有重写的那个方法会调用基类的默认实现,而这两个方法的默认实现都是根据内存地址判断的,也就是说,其实一个方法的返回值永远会是false。其结果就是,存储的时候你可能任性的存,在取值的时候就找不到北了!
如果一个对象在被作为字典的键后,它的哈希码改变了,那么在字典中,将永远找不到这个值了,为了解决这个问题,所以可以基于不变的字段来进行哈希计算。
现在,我们再去掉注释看看:
正常工作了!
而对GetHashCode
重载的要求如下:
-
对于Equals返回为true的两个值,
GetHashCode
也必须返回一样的值。 -
不能抛出异常
-
对于同一个对象,必须返回同一个值,除非对象改变
对于class的
GetHashCode
默认返回的是internal object token
,这对于每个实例来讲都是唯一的。
假如因为某些原因要实现自己的哈希表集合,或者要在实现的代码中调用
GetHashCode
,记住千万不能对哈希码进行持久化,因为它很容易改变,一个类型的未来版本可能使用不同的算法计算哈希码。有公司不注意,在他们的网站上,用户选择用户名和密码进行注册,然后网站获取密码String,调用GetHashCode,将哈希码持久性存储到数据库,用户重新登陆网站,输入密码,网站再次调用GetHashCode,将哈希码与数据库中存储值对比,匹配就允许访问,不幸的是,升级到新CLR后,String的GetHashCode算法发生改变,结果就是所有用户无法登录
重载Equals
自己定义的重载Equals必须具备如下特征:
(1)自反性,即
x.Equals(x)
是true(2)对称性,即
x.Equals(y)
与y.Equals(x)
返回值相同(3)可传递性,即
x.Equals(y)
返回true,y.Equals(z)
也返回true,那么x.Equals(z)
肯定也应该是true(4)可靠性,不抛出异常
满足这几点,应用程序才会正常工作。