.NET 对象判等
— 四种判等方法解析
— 实现自定义Equals方法
— 判等规则
了解.NET的对象判等,有必要从了解几个相关的基本概念开始:
l 值相等。表示比较的两个对象的数据成员按内存位分别相等,即两个对象类型相同,并且具有相等和相同的字段。
l 引用相等。表示两个引用指向同一对象实例,也就是同一内存地址。因此,可以由引用相等推出其值相等,反之则不然。
关于对象的判等,涉及了对相等这一概念的理解。其实这是一个典型的数学论题,所以数学上的等价原则也同样适用于对象判等时的规则,主要是:
l 自反性,就是a==a总是为true。
l 对称性,就是如果a==b成立,则b==a也成立。
l 传递性,就是如果a==b,b==c成立,则a==c也成立。
了解了对象判断的类型和原则,接下来就认识一下System.Object类中实现的几个对象判等方法,它们是:
l public virtual bool Equals(object obj)虚方法,比较对象实例是否相等。
l public static bool Equals(object objA,object objB)静态方法,比较对象实例是否相等。
l public static bool ReferenceEquals(object objA,object objB)静态方法,比较两个引用是否指向同一个对象。
同时在.NET中,还有一个“==”操作符提供了更简洁的语义来表达对象的判等,所以.NET的对象判等方法就包括了这四种类型,下面一一展开介绍。
本质分析
1.Equals静态方法 Equals静态方法实现了对两个对象的相等性判别,其在System.Object类型中实现过程可以表示为:
public static bool Equals(object objA, object objB)
{
if (objA == objB)
{
return true;
}
if ((objA != null) && (objB != null))
{
return objA.Equals(objB);
}
return false;
}
对以上过程,可以小结为:首先比较两个类型是否为同一实例,如果是则返回true;否则将进一步判断两个对象是否都为null,如果是则返回true;如果不是则返回objA对象的Equals虚方法的执行结果。所以,Equals静态方法的执行结果,依次取决于三个条件:
l 是否为同一实例。
l 是否都为null。
l 第一个参数的Equals实现。
因此,通常情况下Equals静态方法的执行结果常常受到判等对象的影响,例如有下面的测试过程:
class MyClassA
{
public override bool Equals(object obj)
{
return true;
}
}
class MyClassB
{
public override bool Equals(object obj)
{
return false;
}
}
class Test_Equals
{
public static void Main()
{
MyClassA objA = new MyClassA();
MyClassB objB = new MyClassB();
Console.WriteLine(Equals(objA, objB));
Console.WriteLine(Equals(objB, objA));
}
}
//执行结果
True
False
由执行结果可知,静态Equals的执行取决于==操作符和Equals虚方法这两个因素。因此,决议静态Equals方法的执行,就要在自定义类型中覆写Equals方法和重载==操作符。
还应注意到,.NET提供了Equals静态方法可以解决两个值为null对象的判等问题,而使用objA.Equals(object objB)来判断两个null对象会抛出NullReferenceException异常,例如:
public static void Main()
{
object o = null; o.Equals(null);
}
2.ReferenceEquals静态方法
ReferenceEquals方法为静态方法,因此不能在继承类中重写该方法,所以只能使用System.Object的实现代码,具体为:
public static bool ReferenceEquals(object objA, object objB)
{
return (objA == objB);
}
可见,ReferenceEquals方法用于判断两个引用是否指向同一个对象,也就是前文强调的引用相等。因此以ReferenceEquals方法比较同一个类型的两个对象实例将返回fasle,而.NET认为null等于null,因此下面的实例就能很容易理解得出的结果:
public static void Main()
{
MyClass mc1 = new MyClass();
MyClass mc2 = new MyClass(); //mc1和mc3指向同一对象实例
MyClass mc3 = mc1; //显示:False
Console.WriteLine(ReferenceEquals(mc1, mc2)); //显示:True
Console.WriteLine(ReferenceEquals(mc1, mc3)); //显示:True
Console.WriteLine(ReferenceEquals(null, null)); //显示:False
Console.WriteLine(ReferenceEquals(mc1, null));
}
因此,ReferenceEquals方法,只能用于比较两个引用类型,而以ReferenceEquals方法比较值类型,必然伴随着装箱操作的执行,分配在不同地址的两个装箱的实例对象,肯定返回false结果,例如:
public static void Main()
{
Console.WriteLine(ReferenceEquals(1, 1));
} //执行结果:False
另外,应该关注.NET某些特殊类型的“意外”规则,例如下面的实现将突破常规,除了深刻地了解ReferenceEquals的实现规则,也应理解某些特殊情况背后的秘密:
public static void Main()
{
string strA = "ABCDEF";
string strB = "ABCDEF";
Console.WriteLine(ReferenceEquals(strA, strB));
} //执行结果:True
从结果分析可知两次创建的string类型实例不仅内容相同,而且分享共同的内存空间,事实上的确如此,这缘于System.String类型的字符串驻留机制,在此我们必须明确ReferenceEquals判断引用相等的实质是不容置疑的。
3.Equals虚方法
Equals虚方法用于比较两个类型实例是否相等,也就是判断两个对象是否具有相同的“值”,在System.Object中其实现代码,可以表示为:
public virtual bool Equals(object obj)
{
return InternalEquals(this, obj);
}
其中InternalEquals为一个静态外部引用方法,其实现的操作可以表示成:
if (this == obj) return true;
else return false;
可见,默认情况下,Equals方法和ReferenceEquals方法是一样的,Object类中的Equals虚方法仅仅提供了最简单的比较策略:如果两个引用指向同一个对象,则返回true;否则将返回false,也就是判断是否引用相等。然而这种方法并未达到Equals比较两个对象值相等的目标,因此System.Object将这个任务交给其派生类型去重新实现,可以说Equals的比较结果取决于类的创建者是如何实现的,而非统一性约定。 事实上,.NET框架类库中有很多的引用类型实现了Equals方法用于比较值相等,例如比较两个System.String类型对象是否相等,肯定关注其内容是否相等,判断的是值相等语义:
public static void Main()
{
string str1 = "acb";
string str2 = "acb";
Console.WriteLine(str1 == str2);
}
4.==操作符
在.NET中,默认情况下,操作符“==”在值类型情况下表示是否值相等,由值类型的根类System.ValueType提供了实现;而在引用类型情况下表示是否引用相等,而“!=”操作符与“==”语义类似。当然也有例外,System.String类型则以“==”来处理值相等。因此,对于自定义值类型,如果重载Equals方法,则应该保持和“==”在语义上的一致,以返回值相等结果;而对于引用类型,如果以覆写来处理值相等规则时,则不应该再重载“==”运行符号,因为保持其缺省语义为判断引用相等才是恰当的处理规则。 Equals虚方法与==操作符的主要区别在于多态表现:Equals通过虚方法覆写来实现,而==操作符则是通过运算符重载来实现,。
覆写Equals方法
经过对四种不同类型判等方法的讨论,我们不难发现不管是Equals静态方法、Equals虚方法抑或==操作符的执行结果,都可能受到覆写Equals方法的影响。因此研究对象判等就必须将注意力集中在自定义类型中如何实现Equals方法,以及实现怎样的Equals方法。因为,不同的类型,对于“相等”的理解会有所偏差,你甚至可以在自定义类型中实现一个总是相等的类型,例如:
class AlwaysEquals
{
public override bool Equals(object obj)
{
return true;
}
}
因此,Euqls方法的执行结果取决于自定义类型的具体实现规则,而.NET又为什么提供这种机制来实现对象判等策略呢?首先,对象判等决定于需求,没有必要为所有.NET类型完成逻辑判等,
System.Object基类也无法提供满足各种需求的判等方法;其次,对象判等包括值判等和引用判等两个方面,不同的类型对判等的处理又有所不同,通过多态机制在派生类中处理各自的判等实现显然是更加明智与可取的选择。 接下来,我们开始研究如何通过覆写Equals方法实现对象的判等。覆写Equals往往并非易事,要综合考虑到对值类型字段和引用类型字段的分别判等处理,同时还要兼顾父类覆写所带来的影响。不适当的覆写会引发意想不到的问题,所以必须遵循三个等价原则:自反、传递和对称,这是实现Equals的通用契约。那么又如何为自定义类型实现Equals方法呢? 最好的参考资源当然来自于.NET框架类库的实现,事实上,关于Equals的覆写在.NET中已经有很多的基本类型完成了这一实现。从值类型和引用类型两个角度来看:
l 对于值类型,基类System.ValueType通过反射机制覆写了Equals方法来比较两个对象的值相等,但是这种方式并不高效,更明智的办法是在自定义值类型时有针对性的覆写Equals方法,来提供更灵活、高效的处理机制。
l 对于引用类型,覆写Equals方法意味着要改变System.Object类型提供的引用相等语义。那么,覆写Equals要根据类型本身的特点来实现,在.NET框架类库中就有很多典型的引用类型实现了值相等语义。例如System.String类型的两个变量相等意味着其包含了相等的内容,System.Version类型的两个变量相等也意味着其Version信息的各个指标分别相等。 因此对Equals方法的覆写主要包括对值类型的覆写和对引用类型的覆写,同时也要区别基类是否已经有过覆写和不曾覆写两种情况,并以等价原则为前提,进行判断。在此,我们仅提供较为标准的实现方法,具体的实现取决于不同的类型定义和语义需求。
class EqualsEx
{
//定义值类型成员ms
private MyStruct ms;
//定义引用类型成员mc
private MyClass mc;
public override bool Equals(object obj)
{
//为null,则必不相等
if (obj == null)
return false;
//引用判等为真,则二者必定相等
if (ReferenceEquals(this, obj))
return true;
//类型判断
EqualsEx objEx = obj as EqualsEx;
if (objEx == null)
return false;
//最后是成员判断,分值类型成员和引用类型成员
//通常可以提供强类型的判等方法来单独处理对各个成员的判等
return EqualsHelper(this, objEx);
}
private static bool EqualsHelper(EqualsEx objA, EqualsEx objB)
{
//值类型成员判断
if (!objA.ms.Equals(objA.ms))
return false;
//引用类型成员判断
if (!Equals(objA.mc, objB.mc))
return false;
//最后,才可以判定两个对象是相等的
return true;
}
}
上述示例只是从标准化的角度来阐释Equals覆写的简单实现,而实际应用时又会有所不同,然而总结起来实现Equals方法我们应该着力于以下几点:首先,检测obj是否为null,如果是则必然不相等;然后,以ReferenceEquals来判等是否引用相等,这种办法比较高效,因为引用相等即可以推出值相等;然后,再进行类型判断,不同类型的对象一定不相等;最后,也是最复杂的一个过程,即对对象的各个成员进行比较,引用类型进行恒定性判断,值类型进行恒等性判断。在本例中我们将成员判断封装为一个专门的处理方法EqualsHelper,以隔离对类成员的判断实现,主要有以下几个好处:
l 符合Extract Method原则,以隔离相对变化的操作。
l 提供了强类型版本的Equals实现,对于值类型成员来说还可以避免不必要的装箱操作。
l 为==操作符提供了重载实现的安全版本。 在.NET框架中,System.String类型的Equals覆写方法就提供了EqualsHelper方法来实现。
与GetHashCode方法同步
GetHashCode方法,用于获取对象的哈希值,以应用于哈希算法、加密和校验等操作中。相同的对象必然具有相同的哈希值,因此GetHashCode的行为依赖于Equals方法进行判断,在覆写Equals方法时,也必须覆写GetHashCode,以同步二者在语义上的统一。例如:
public class Person
{
//每个人有唯一的身份证号,因此可以作为Person的标识码
private string id = null;
private string name = null;
//以id作为哈希码是可靠的, 而name则有可能相同
public override int GetHashCode()
{
return id.GetHashCode();
}
public override bool Equals(object obj)
{
if(ReferenceEquals(this, obj))
return true;
Person person = obj as Person;
if(person == null)
return false;
//Equals也以用户身份证号作为判等依据
if(this.id == person.id) return true; return false;
}
}
二者的关系可以表达为:
如果x.Equals(y)为true成立,则必有x.GetHashCode() == y.GetHashCode()成立。
如果覆写了Equals而没有实现GetHashCode,C#编译器会给出没有覆写GetHashCode的警告。
规则
l 值相等还是引用相等决定于具体的需求,Equals方法的覆写实现也决定于类型想要实现的判等逻辑。
l 几个判等方法相互引用,所以对某个方法的覆写可能会影响其他方法的执行结果。
l 如果覆写了Equals虚方法,则必须重新实现GetHashCode方法,使二者保持同步。
l 禁止从Equals方法或者“==”操作符抛出异常,应该在Equals内部首先避免null引用异常,要么相等要么不等。
l ReferenceEquals方法主要用于判别两个对象的唯一性,比较两个值类型则一定返回false。
l ReferenceEquals方法比较两个System.String类型的唯一性时,要注意String类型的特殊性:字符串驻留。
l 实现ICompare接口的类型必须重新实现Equals方法。
l 值类型最好重新实现Equals方法和重载==操作符,因为默认情况下实现的是引用相等。
四种判等方法,各有用途又相互关联。这是CLR提供给我们关于对象等值性和唯一性的执行机制。分,我们以不同角度来了解其本质;合,我们以规则来阐释其关联。在本质和关联之上,充分体会.NET这种抽象而又灵活的判等机制,留下更多的思考来认识这种精妙的设计。