.NET C#基础(1):相等性与同一性 - 似乎有点小缺陷的设计
0. 文章目的
本文面向有一定.NET C#基础知识的学习者,介绍在C#中的常用的对象比较手段,并提供一些编码上的建议。
1. 阅读基础
1:理解C#基本语法与基本概念(如类、方法、字段与变量声明,知道class引用类型与struct值类型之间存在差异)
2:理解OOP基本概念(如类、对象、方法的概念)
2. 概念:相等性与同一性
在开始前,我们需要先来明确两个概念
相等性:或者称为值相等性,指示两个物品在某个比较规则下存在值上的相等。相等性只考虑值是否相等,譬如若两个整型变量a和b的值均为1,虽然是两个变量但它们具有相等性。
同一性:两个物品是实质上就是同一个物品。譬如假设你给你家的猫分别在卧室和客厅拍了两张照片,两张照片中的猫虽然形态可能不同,所处位置不同,但它们是同一只猫,也就是说具有同一性。
相等性的实际判定逻辑依赖于实际需求,因此一般来说我们对相等性判定的操作空间较大。但相等性的判定应当遵顼以下原则(=号表示相等性判定):
1、自反性:自己=自己
2、对称性:A=B与B=A返回值相同
3、可传递性:若A=B,B=C,则A=C
4、一致性:若A不变,B不变,则A=B不变
而同一性的判定原则被明确为用于判定两个物品是否为同一物品,在大多数的编程语言中,这一判定体现为指示两个引用是否指向同一对象。基于这个原因,我们在同一性判定方面基本没有什么操作空间(当然,这是合理的)。
另外需要注意的是,具有相等性的两个对象不一定能够具有同一性,但在同一时间具有同一性的两个对象一定具有相等性。
3. C#中的相等性与同一性
尽管通常我们应该只需要一个方法判定相等性,一个方法判定同一性,这样不仅可以减少类设计者的工作量,也可以减少编码失误。然而有趣的是,C#却为此这类比较判定提供了多种常用的比较方式:
- ==与!=运算符
- object类的Equals方法
- object类的Equals静态方法
- object类的ReferenceEquals静态方法
- IEquatable<>泛型接口的Equals方法
- object类的GetHashCode方法
- is运算符
对于C#来说,相等性和同一性的比较方式在很多时候是设计上的选择。这里的意思是,一种比较行为究竟被实现为比较相等性还是同一性,很多时候取决于类自身的设计。譬如,即便在通常来说,某些语言的爱好者可能倾向于认为==运算符比较的是同一性,而Equals方法比较的是相等性。但由于C#允许运算符重载,配合方法重写,如果类的设计者愿意,那么完全可以把==操作符重载为比较相等性的实现(例如C#的string类型便重载了==让其实行相等性比较,因此在C#中可以使用==符号来判定两个string是否具有相等性),或者把Equals方法作为比较同一性的实现(答应我,别这么干)。
C#中提供了多种常用的比较判定方式,给开发者提供了相当的自由,但自由的同时也意味着如果不遵循一些共同的规范,那么类的设计将会变得混乱。本文将会逐一对上述列出的比较方式进行介绍,并提供一些个人的使用建议。
4. 从示例入手,如何实现判定相等性和同一性
在开始之前,先对上面提到的判断方法进行一些归类,这里归为4类:
相等性比较 | 同一性比较 | 相等或同一性比较 | 特殊比较 |
object的Equals方法 | object的ReferenceEquals静态方法 | ==运算符 | is运算符 |
object的Equals静态方法 | !=运算符 | object的GetHashCode方法 | |
IEquatable<>泛型接口的Equals方法 |
4.1 相等性比较
4.1.1 object的Equals方法
(1)基本信息
Equals方法被定义在object类中,其方法声明如下:
public virtual bool Equals(Object? obj);
从其方法名可以看出,Equals方法应当被定义为用于比较相等性,该方法接受一个Object类型的参数,返回相等性的比较结果。然而,尽管Equals方法在概念上被用于比较相等性,但Equals方法的默认实现方式却是比较两者的同一性,也就是说,默认情况下,它只会判定两个引用是否指向同一对象,就像下图所示
class Cat
{
public string? CatID { get; set; }
}
Cat cat1 = new Cat();
Cat cat2 = new Cat();
Console.WriteLine(cat1.Equals(cat2)); // 输出为False
因此,如果要正确实现相等性比较,应当重写Equals方法。
(2)基本使用
现在假设我们期望只要两个Cat对象的CatID相等,那么这两个Cat对象就具有相等性。那么显然默认的Equals方法是无法满足我们的需求的。所幸的是,Equals是一个被virtual修饰的虚方法,这意味着它可以简单地被其子类重写。并且不要忘了,由于object是所有类型的基类,因此所有的自定义类型都可以重写该方法。就像下图所示。
class Cat
{
public string? CatID { get; set; }
public override bool Equals(object? obj)
{
return this.CatID == ((Cat)obj).CatID;
}
}
Cat cat1 = new Cat();
Cat cat2 = new Cat();
Console.WriteLine(cat1.Equals(cat2)); // 输出为True
当然,上面这样的实现是缺乏稳健性的,譬如,如果传入的是一个null参数呢?或者参数无法转化为Cat类型呢?显然这个时候上面的实现就会抛出异常。然而,从实践角度出发,几乎没有任何理由让一个判定相等性的方法抛出异常-两个对象要么相等,要么不相等,抛出异常对于程序流程来说几乎没有意义。因此,Equals方法的实现可能比你想的要复杂一些,但也不会太复杂:
- 如果参数obj为null,直接返回false
- 如果参数obj和调用方具有同一性,直接返回true
- 如果参数obj的类型和目标类型不一致,直接返回false
- 其他根据业务要求需要进行的相等性比较,可能需要调用基类的Equals方法
根据上述流程,一个更好的重写方式应该如下:
class Cat
{
public string? CatID { get; set; }
public override bool Equals(object? obj)
{
// 如果参数obj为null,直接返回false
if (obj == null)
{
return false;
}
// 如果参数obj和调用方具有同一性,直接返回true
if (ReferenceEquals(this, obj))
{
return true;
}
// 如果参数obj的类型和目标类型不一致,直接返回false
if (this.GetType() != obj.GetType())
{
return false;
}
// 只要两个对象的CatID相等,那么就视为具有相等性
return this.CatID == ((Cat)obj).CatID;
}
}
尽管这种实现看起来比原来复杂,但实际上前三个步骤与类型本身无关,因此是可以通用的。此外,你可能注意到上面示例中使用了ReferenceEquals来进行同一性判定,这在后面会提到。
(3)其他问题
需要特别说明,ValueType类重写了Equals方法,其比较方式为通过比较各个字段的值是否相等而判断两个ValueType是否相等,也就是说,继承自ValueType的类型的Equals方法实际进行的就是相等性判断。比如struct类型:
struct Point
{
public float X;
public float Y;
}
Point p1 = new Point();
Point p2 = new Point();
p1.Equals(p2); // True,p1和p2具有相等性
然而这不意味着定义为struct就不需要考虑重写Equals方法来保证相等性判定,实际上,由于值类型往往是在有性能要求的地方使用,而ValueType的默认实现需要考虑普遍情况,但这意味着它对特定类型的实现来说实现往往也是低效的,因此依然有必要手动重写Equals方法来避免不必要的反射操作。
(4)缺陷
实际上,在《CLR via C#》中有提到过,如果Equals能使用下面这种默认实现:
public virtual bool Equals(object? obj)
{
if (obj == null) return false;
if (ReferenceEquals(this, obj)) return true;
if (this.GetType() != obj.GetType()) return false;
return true;
}
那么在子类对Equals进行重写时将会方便地多。例如几乎所有的Equals重写都可以按如下结构定义:
public override bool Equals(object? obj)
{
if (base.Equals(obj))
{
// 根据业务要求需要进行的相等性比较
}
return false;
}
从这个角度来说,现在的Equals的默认实现确实是有缺陷的。
4.1.2 object的Equals静态方法
(1)基本信息
在object基类中,除了有用于实例的Equals方法外,还有一个静态版的Equals方法,其方法声明如下:
public static bool Equals(object? objA, object? objB);
和实例版的Equals方法一样,Equals静态方法也是用来进行相等性判定。该方法实际依赖于实例版的Equals方法的实现,但优点在于由于不需要实例调用,因此可以避免不必要的null异常。实际上,Equals静态方法的实现类似如下:
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时引发的异常,同时该方法最终的判定依赖于实例版Equals的实现
(2)基本使用
在实例Equals方法中,若调用成功,则调用方一定不为null,因此我们不需要在实例Equals方法中考虑调用方为null的情况。但在Equals方法外,我们有时确实需要考虑调用方为null的情况,一种常见的做法就是在调用前对调用方进行null检查。例如如下写法:
if (a != null && a.Equals(b))
{
// do something
}
但使用静态Equals方法则可以减少不必要的判空操作来简化编码,如下:
if (Equals(a, b))
{
// do something
}
Equals静态方法的适用场合较少,通常用于需要对调用方判空时简化编码。另外需要说明的时,若传给Equals静态方法的两个参数均为null,Equals也会返回true。
4.1.3 IEquatable<>泛型接口的Equals方法
(1)基本信息
IEquatable<>泛型接口用于表明实现类型可以进行类型特化的相等性比较,该接口的定义非常简单,只约定了一个接受一个类型为其泛型参数的Equals方法。其接口定义如下:
public interface IEquatable<T>
{
bool Equals(T? other);
}
相对于object的Equals方法而言,该接口更明确地指出其实现类型可以使用接口的Equals方法进行相等性比较,同时不同于object的Equals使用了object类型的参数,IEquatable<>接口的Equals方法的参数类型为特化类型,因此可以减少类型转换,从而获得更好的性能。
(2)基本使用
IEquatable<>接口的Equals方法的表现应该类似于object的Equals方法,但现在不再需要考虑与类型相关的问题,因此可以按如下方式书写。同样的,这里以在object的Equals中使用的Cat类为例:
class Cat : IEquatable<Cat>
{
public string? CatID { get; set; }
public bool Equals(Cat? other)
{
// 如果参数other为null,直接返回false
if (other == null)
{
return false;
}
// 如果参数other和调用方具有同一性,直接返回true
if (ReferenceEquals(this, other))
{
return true;
}
// 只要两个对象的CatID相等,那么就视为具有相等性
return this.CatID == other.CatID;
}
}
(3)建议
重写object的Equals方法与实现IEquatable<>接口应当同时进行,这一工作并不难,实现一方后另一方可以通过简单调用来实现,但是可以创造出更泛用的类型。一个可能的示例如下:
class Cat : IEquatable<Cat>
{
public string? CatID { get; set; }
public bool Equals(Cat? other)
{
if (other == null)
{
return false;
}
if (ReferenceEquals(this, other))
{
return true;
}
return this.CatID == other.CatID;
}
public override bool Equals(object? obj)
{
return Equals(obj as Cat);
}
}
4.2 同一性比较
4.2.1 object的ReferenceEquals静态方法
(1)基本信息
尽管Equals方法的默认实现为进行同一性比较,但是由于Equals方法可被重写且从语义上来说应当用于相等性比较,因此不应当依赖Equals方法进行同一性比较(同样的还有==运算符)。要进行可靠的同一性比较应该使用其他方式,所幸的是,C#中常用进行同一性比较的方式只有一种,即ReferenceEquals静态方法(尽管从事实上来说,其实现依赖于==运算符),该方法原型如下:
public static bool ReferenceEquals(object? objA, object? objB);
若objA与objB引用同一对象,则返回true。
(2)基本使用
该方法使用非常简单,只需要将需要进行同一性判定的两个参数传入即可,示例如下:
object a = new object();
object b = new object();
Console.WriteLine(ReferenceEquals(a, b)); // False
a = b; // 现在让a和b指向同一对象
Console.WriteLine(ReferenceEquals(a, b)); // True
(3)原理
实际上,ReferenceEquals方法的实现非常简单,其实现类似如下:
public static bool ReferenceEquals(object? objA, object? objB)
{
return objA == objB;
}
该方法只是简单地返回对参数使用==运算符的结果,之所以有效,是由于该方法的两个参数类型均为object,而object对==运算符的默认实现就是进行同一性比较。基于这个原理,也可以像下面这样进行同一性判定:
if ((object)a == (object)b)
{
// do something
}
当然不推荐这样做,因为使用ReferenceEquals的语义显然更清晰。
4.3 相等或同一性比较
4.3.1 ==运算符
(1) 基本信息
==运算符是常用的二元逻辑运算符之一,但相对于Equals方法和ReferenceEquals静态方法这两者有清晰的语义而言,==运算符无法简单明确其到底是进行相等性比较还是同一性比较。虽然从实际来说,很多时候我们更倾向于用将其用于相等性比较,譬如:
1 == 1; // True
2 == 3; // False
"Cat" == "Cat" // True
实际上,对于int,double之类的数值类基元类型,==运算符的表现为相等性判定;对于class引用类型,表现为同一性判定;对于struct值类型,则依赖于定义(实际上,只能是相等性,只是如何比较相等性而已)。
不仅如此,由于C#允许进行运算符重载,因此==运算符的实际行为是可以修改,譬如下面的定义修改了==运算符用于Cat类比较时的表现,让其进行相等性比较(比较CatID的值)而非默认的同一性比较:
public static bool operator ==(Cat left, Cat right)
{
return left.CatID == right.CatID;
}
基于上述理由,依赖==进行相等性或者同一性判定不是完全可靠的。但它是可控的,也就是只要能确定定义,那么==运算符的判定结果就是可预测的。==运算符可以让程序有更良好的可读性,规范地使用它是值得的。
(2)基本使用
前面说过,==运算符的实际表现依赖于类型性质和运算符重载,实际上它的表现如下:
- 对于int、double等数值类基元类型:相等性判定
- 对于string基元类型:相等性判定(string是被特殊对待的引用类型)
- 对于object基元类型:同一性判定
- 对于自定义class:同一性判定
- 对于自定义struct:依赖于定义
由于基元类型的定义不可修改,故可以认为==运算符对其相等性与同一性的判定是可靠稳定的,这里不做讨论。下面主要说明自定义class与自定义struct类型中的==运算符。
1. 在自定义的class中
对于自定的class来说,==运算符默认表现为同一性判定,即表现如下:
class Cat
{
public string? CatID { get; set; }
}
Cat cat1 = new Cat();
Cat cat2 = new Cat();
cat1 == cat2; // False,因为==运算符默认比较同一性
cat1 = cat2; // 现在让cat1和cat2指向同一对象
Cat2 == cat2; // True
只要在本类型定义中没有重载==运算符,那么该类型使用==的比较结果都将有以上表现。但有时候我们可能希望==运算符可以提供相等性判定,那么可以通过对其进行运算符重载来修改比较行为,例如我们希望只要两个Cat对象的CatID相同就具有相等性,则可以:
class Cat
{
public string? CatID { get; set; }
public bool operator ==(Cat left, Cat right)
{
return left.CatID == right.CatID;
}
}
Cat cat1 = new Cat();
Cat cat2 = new Cat();
Cat2 == cat2; // True,此时==运算符的结果只依赖于比较CatID的值了
2. 在自定义struct中
若没有手动对==进行运算符重载,则编译器会显示无法找到运算符定义,struct将无法使用==运算符,例如下面的代码会报错:
struct Cat
{
public string? CatID { get; set; }
}
Cat cat1 = new Cat();
Cat cat2 = new Cat();
Cat2 == cat2; // 报错,没有定义==运算符
因此若希望Cat类型可以使用==运算符进行比较操作,请重载==运算符:
struct Cat
{
public string? CatID { get; set; }
public bool operator ==(Cat left, Cat right)
{
return left.CatID == right.CatID;
}
}
(4):建议
个人建议,除非有有足够说服力的理由(一个例子便是string类型),否则如果要对class类型进行相等性判定,应首选使用Equals方法(包括IEquatable<>的Equals方法)。不应当重载class类型的==运算符,应该让==保持默认行为,即同一性判定。
而对于值类型,应当重载==运算符,同时实现IEquatable<>接口,以为其提供更好的相等性判定支持。(同样建议重写object的Equals方法,但请尽可能避免手动对值类型使用object的Equals方法进行相等性判定,否则会产生额外的装箱拆箱成本。重写它的主要目的,是尽可能避开其基类ValueType中重写的Equals中的反射操作。)
4.3.2 !=运算符
(1)基本信息
!=是==运算符的逆运算,故可以参考==运算符一栏进行理解,此处不再赘述。这里只说一点,就是==运算符必须和!=运算符成对重载,即重载了两者之一就必须同时重载另一方。所幸的是,通常只要重载了==运算符,就可以方便地重载!=运算符了,如下:
class Cat
{
public string? CatID { get; set; }
public bool operator ==(Cat left, Cat right)
{
return left.CatID == right.CatID;
}
public bool operator !=(Cat left, Cat right)
{
return !(left == right);
}
}
4.4 特殊比较
4.4.1 is运算符 - 判空
(1)基本信息
is运算符最早的作用是用于类型判定,即判定类型是否为目标类型或存在继承关系,如下:
class A {}
class B : A {}
object a = new A();
object b = new B();
a is A; // True
b is A; // True
但现在,is运算符也可用于判空处理,如下:
if (a is null) { ... } // 类似于 a == null
你可能会好奇为何不直接使用==进行判空,就像以下:
if (a == null) { ... }
这是因为,你无法在不确定类型定义的情况下说出上述代码的判空结果。这是由于==运算符可以被重载。例如现在考虑如下代码:
class Cat
{
public static bool operator ==(Cat? left, Cat? right)
{
return false;
}
}
Cat? cat = null;
if (cat == null)
{
// do something
}
稍加思索你就能意识到,由于==被重载,上式中a == null的值永远为false。而如果将上式的==修改为is运算符则不会出现此问题。
(2)原理
is是语法糖,上述中a is null的实际行为等效于:
(object)a == null
此外,除了可以使用is进行判空,也可以使用is not进行非空判定
if (a is not null) { } // 等效于 (object)a != null
4.4.2 GetHashCode - 不相等比较
(1)基本信息
GetHashCode是object类中定义的虚方法,其方法声明如下:
public virtual int GetHashCode();
该方法实际作用在于获取对象的散列值。
(2)基本使用
尽管GetHashCode方法是用与获取对象散列值而非进行相等性或同一性的判断,但请考虑一般散列值的要求:
- 若两个对象具有相等性,则其散列值有应当相同
- 反之,散列值相同的两个对象不一定具有相等性
基于上述得出:如果可以两个对象的散列值不同,则至少可以确定他们不具有相等性。因此在某些时候可以通过判定散列值是否不同来快速判定两个对象是否具有相等性,例如:
if (a.GetHashCode() != b.GetHashCode())
{
// a 和 b 不具有相等性
}
当然,这一判断方法的可靠性取决于散列函数的实现,仅在可以确定后果且有必要的情况下才推荐使用。
5. 总结
由于C#提供了多种比较判定方法,因此要正确实现可靠的比较判断需要付出一定的努力。这里简单结合编码规范和实践来给出一些总结性的建议。
1. 若要进行相等性比较,请使用Equals方法(与其静态版本)
a.Equals(b);
Equals(a, b);
2. 若要进行同一性比较,请使用ReferenceEquals静态方法
ReferenceEquals(a, b);
3. 若要进行判空,请使用is运算符
a is null; // 等效(object)a == null
a is not null; // 等效(object)a != null
4. 若可以确定==与!=运算符的行为,则可以加以使用以增强可读性
1 == 1;
"Cat" == "Cat";
5. 如果重写了object的Equals方法,则应当同时重写GetHashCode方法
class Cat
{
public override bool Equals(object? obj) { ... }
public override int GetHashCode() { ... }
}
6. 如果重载了==运算符,则应当重载!=运算符,并重写Equals方法和GetHashCode方法
class Cat
{
public static bool operator ==(Cat left, Cat right) { ... }
public static bool operator !=(Cat left, Cat right) { ... }
public override bool Equals(object? obj) { ... }
public override int GetHashCode() { ... }
}
7. 如果类型可以进行相等性比较,则重写Equals方法的同时,实现IEquatable<>接口
class Cat : IEquatable<Cat>
{
public bool Equals(Cat? other) { ... }
public override bool Equals(object? obj) { ... }
public override int GetHashCode() { ... }
}
8. 不要对class类型重载==与!=运算符,让其保持默认行为进行同一性判断
9. 对于struct类型,确保重载==与!=运算符并实现IEquatable<>接口。换句话说,struct应该完备地实现相等性比较
struct Cat : IEquatable<Cat>
{
public static bool operator ==(Cat left, Cat right) { ... }
public static bool operator !=(Cat left, Cat right) { ... }
public bool Equals(Cat? other) { ... }
public override bool Equals(object? obj) { ... }
public override int GetHashCode() { ... }
}
(如果你认为你的struct类型不需要进行相等性比较,请考虑是否真的需要使用struct类型)