C# 相等比较

C# 相等比较

有两种类型的相等:

  • 值相等:即两个值是一样的
  • 引用相等:即引用是一样的,也就是同一个对象

默认地,对于值类型来讲,相等指的就是值相等;对于引用类型,相等就是指的引用相等。

int a = 5;
int b = 5;
Console.WriteLine(a == b);

image-20211004211745162

class Foo { public int x; }
Foo f1 = new Foo { x = 5 };
Foo f2 = new Foo { x = 5 };
Console.WriteLine(f1 == f2);

image-20211004212014041

标准相等协议

有三种标准相等协议:

  • ==!=运算符
  • 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));

image-20211004223833617

虚方法默认为这样的:

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

image-20211004234125015

虚函数,如果调用者本身就是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);

image-20211004230542725

一个重要的应用就是当在写泛型类型的时候,就不能用==

image-20211004231115508

所以必须这样写:

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

image-20211004232027085

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

image-20211005092038236

Equality and Custom type

值类型用值相等,引用类型用引用相等,结构的Equals方法默认用structural value equality(即比较结构中每个字段的值)。

当写一个类型的时候,有两种情况可能要重写相等:

  • 改变相等的涵义

当默认的==Equals对于所写的类型的涵义不是那么自然的时候,这时候,就有必要重新了。

  • 加速结构的相等的比较

结构默认的structural equality比较算法是相对慢的,通过重写Equals可以提升它的速度,重载==IEquatable<T>允许非装箱相等比较,又可以再提速。

重载引用类型的相等语义,意义不大,因为默认的引用相等已经非常快了。

如果定义的类型重写了Equals方法,还应该重写GetHashCode方法,事实上,如果类型重写Equals的同时,没有重写GetHashCode,C#编译器就会生成一条警告。

image-20211005130255089

之所以还要定义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]);
}

image-20211005143919152

在注释了GetHashCode重写代码后,我们运行上面的程序,就会发现,虽然Equals可以正常工作,但对于list的distinct的数量,显然错误,f1既然是和f2相等,那么数量应该是2,还有最后发现字典访问键f3也访问不了了,这当然是没有重写GetHashCode的后果,因为根据key取值的时候也是把key转换成HashCode而且验证Equals后再取值,也就是说,只要GetHashCode和Equlas中有一个方法没有重写,在验证时没有重写的那个方法会调用基类的默认实现,而这两个方法的默认实现都是根据内存地址判断的,也就是说,其实一个方法的返回值永远会是false。其结果就是,存储的时候你可能任性的存,在取值的时候就找不到北了!

如果一个对象在被作为字典的键后,它的哈希码改变了,那么在字典中,将永远找不到这个值了,为了解决这个问题,所以可以基于不变的字段来进行哈希计算。

现在,我们再去掉注释看看:

image-20211005145005591

正常工作了!

而对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)可靠性,不抛出异常

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

##### 愿你一寸一寸地攻城略地,一点一点地焕然一新 #####
原文地址:https://www.cnblogs.com/johnyang/p/15368737.html