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);
}

它给出调用objectEquals虚方法相同效果的结果,但更快,你也可以用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)可靠性,不抛出异常

    满足这几点,应用程序才会正常工作。

随机推荐

  1. Flink学习(十) Sink到Redis

    添加依赖 <dependency> <groupId>org.apache.bahir</groupId> <artifactId>flink-conn ...

  2. 李沐动手学深度学习V2-chapter_convolutional-modern

    李沐动手学深度学习V2 文章内容说明 本文主要是自己学习过程中的随手笔记,需要自取 课程参考B站:https://space.bilibili.com/1567748478?spm_id_from=3 ...

  3. HarmonyOS Next 鸿蒙开发-如何使用服务端下发的RSA公钥(字符串)对明文数据进行加密

    如何使用服务端下发的RSA公钥(字符串)对明文数据进行加密 将服务器下发的RSA公钥字符串替换掉pubKeyStr即可实现,具体可参考如下代码: import { buffer, util } fro ...

  4. go kratos protobuf 接收动态JSON数据

    前言 google.protobuf.Struct 是 Google Protocol Buffers 中的一种特殊类型,用于表示动态的键值对数据.它可以存储任意类型的数据,并提供了方便的方法来访问和 ...

  5. nginx 简单实践:负载均衡【nginx 实践系列之四】

    〇.前言 本文为 nginx 简单实践系列文章之三,主要简单实践了负载均衡,仅供参考. 关于 Nginx 基础,以及安装和配置详解,可以参考博主过往文章: https://www.cnblogs.co ...

  6. 编写你的第一个 Django 应用程序,第7部分

    本教程从教程 6 停止的地方开始.我们将继续使用网络投票应用程序,并将专注于自定义 Django 自动生成的管理站点,这是我们在教程 2 中首次探索的. 一.自定义管理表单 通过用 admin.sit ...

  7. VRRP+BFD实验

    VRRP(Virtual Router Redundancy Protocol,虚拟路由器冗余协议)的工作原理主要涉及多个路由器(或具备路由功能的设备)协同工作,通过VRRP报文和优先级机制来选举出一 ...

  8. nginx + lua脚本

    Nginx配合Lua 案例 今天实现一个非常简单的例子. 云服务器上部署的了一个很通用的应用程序(它没有保护策略),其端口是a,但是我想使用他,就要通过公网ip:端口去访问它.暴露在外面很不安全. 那 ...

  9. 一个专业DBA应具备的技能

    本文可以作为MySQL DBA面试官,以及候选人的双向参考 面试流程 接下来先说下我以往在做MySQL DBA面试时的过程(套路): 1.先自我介绍后,再让候选人花2-5分钟做下自我简介有不少人可能对 ...

  10. 《视觉SLAM十四讲》第13讲 设计SLAM系统 回环检测线程的实现

    <视觉SLAM十四讲>第13讲 设计SLAM系统 回环检测线程的实现 这个学期看完了高翔老师的<视觉SLAM十四讲>,学到了很多,首先是对计算机视觉的基本知识有了一个更加全面系 ...