在之前的文章中,我们讲到了使用C#中提供的Object类的虚Equals方法来判断Equality,但实际上它还提供了另外一种判断Equality的方法,那就是使用==运算符。许多童鞋也许会想当然的认为==不过是Equals方法的语法糖而已,然而事实却并非如此。尽管从实现上来说,它给出的判定结果往往和Object.Equals方法一致,但这不是必须的,因为两者的实现机制完全相同。下面就让我们看看两者的区别。
1、==和基元类型
让我们首先从最基础的基元类型,如int、float、double等开始,看看==是如何对他们进行相等性测试的。
class Program { static void Main(String[] args) { int number1 = 9; int number2 = 9; Console.WriteLine(number1.Equals(number2)); Console.WriteLine(number1 == number2); Console.Read(); } }
上面的代码中,我们比较两个整数的相等性,其中用到了两种比较方式,第一种调用Int32类型对Object.Equals方法的重载版本,第二种调用==运算符。两种比较方法的结果都显示true,似乎两者的实现机制都是调用的Equals方法进行比较一样。下面让我们使用ildasm.exe实用程序来验证一下这个猜想是否正确吧。
上面这条IL语句对应于源码中的第一个比较方式,可以看到它是通过调用Object.Equals实现的,该方法的定义位于System.Int32类型中,并且是实现了IEquatable<int>接口。
下面来看==操作符对应的IL语句。
可以看到,==操作符生成的IL并没有调用Object.Equals,而是生成了一条ceq指令,该指令的作用是比较加载到栈上的两个值并且是通过CPU寄存器进行相等性比较的。
总结:对于基元类型的相等性判断而言,C#中==操作符是通过ceq指令实现的,而非Object.Equals方法。
2、==和引用类型
接下来让我们看下==如何对引用类型进行相等性判定。
static void Main(string[] args) { Customer C1 = new Customer(); C1.FirstName = "Si"; C1.LastName = "Li"; Customer C2 = new Customer(); C2.FirstName = "Si"; C2.LastName = "LI"; Console.WriteLine(C1.Equals(C2));
Console.WriteLine(C1==C2); Console.Read(); } public class Customer { public string FirstName { get; set; } public string LastName { get; set; } }
运行上面的代码,结果会显示两个False。这不由得让我们猜测:==运算符和Equals方法一样,也是作引用相等性判定,而不是值相等性判定。接下来让我们通过ildasm.exe查看IL代码确认一下。
可以看到,对于C1.Equals(C2)而言,它生成的IL代码,调用的是Object.Equals方法,由于没有重载,故默认进行引用相等性测试。而对于C1==C2,它对于生成的IL代码,仅是一条ceq指令,这里用来判定引用相等性。
也许有童鞋好奇==是如何作引用相等性判定的。这是因为引用类型的变量持有的是同类型的实例对象的内存地址,而内存地址不过是一个数字,因此可以用ceq判等性测试,就像对整数类型的判等测试一样。
总结:对于引用类型的判等而言,==操作符是通过ceq指令比较内存地址来实现的。
3、==和String类型
同上,还是通过一段简单的代码测试==如何对String类型作相等性测试。
class Program { static void Main(String[] args) { string str1 = "hello"; string str2 = String.Copy(str1); Console.WriteLine(ReferenceEquals(str1, str2)); Console.WriteLine(str1 == str2); Console.WriteLine(str1.Equals(str2)); Console.Read(); } }
测试以上代码,结果显示:False True True。答案已经很明朗了,==操作符对String类型作值相等性测试。下面通过IL代码揭秘一下==的运行机制。
从以上的IL代码片段中,我们并未发现ceq指令,而是调用了一个op_equality(string, string)方法。那么该方法是如何产生的呢?实际上,这是由于String类重载了==运算符导致的。在C#中,如果重载了==运算符,那么编译器就会编译生成一个static方法,名字为op_equality。
若在VS中通过导航到定义来查看String类的源代码,会发现String类实现了两个运算符重载方法,一个是相等性测试,另一个是不等性测试。
当我们为自己的类型重载==操作符的实现时,应该牢记一点:为了通过编译,应为==和!=同时提供重载实现。
总结:
1、通过上面的学习,我们知道对于引用类型,==操作符会以下面两种方式之一进行判等测试。
-
- 若存在==的重载实现,那么编译器将把它编译为一个static方法。
- 若不存在==的重载实现,编译器将它编译为一条ceq指令,比较内存地址。
2、当我们更改一个类型的判等逻辑时,应该同时为Equals方法和==提供实现,并且应保证两者的比较结果一致,否则,使用该类型的开发人员将感到困惑。
4、==和值类型
通过上面的学习,我们已经晓得==如何对基元类型和引用类型作判等测试。但还未提及非基元值类型,下面让我们看看==操作符如何
还是以上面提到的Customer类为例,只是这次我们将它变更为struct。代码如下:
static void Main(string[] args) { Customer C1 = new Customer(); C1.FirstName = "Si"; C1.LastName = "Li"; Customer C2 = new Customer(); C2.FirstName = "Si"; C2.LastName = "LI"; Console.WriteLine(C1.Equals(C2)); Console.WriteLine(C1==C2); Console.Read(); } public struct Customer { public string FirstName { get; set; } public string LastName { get; set; } }
若运行上面的程序,会产生如下的编译错误:
该错误清楚地向我们指明:不存在用于非基元值类型的重载==操作符。若要使用==进行判等,必须由我们提供。现在让我们在Customer结构的定义中添加以下代码:
public static bool operator ==(Customer p1, Customer p2) { }
此时,Main方法中的代码就不会产生编译错误了,但是该程序仍不能编译通过,因为operator ==方法的返回值类型为bool,而我们并没有提供任何返回值。但这并不重要,我们仅仅在这里探讨了应为非基元值类型提供==操作符的重载实现,具体的实现代码根据自己的业务需求填充就行了。
5、总结
下面让我们来总结一下==和Equals方法对各种类型进行判等性测试的逻辑:
- 对于基元类型,比如
int
,float
,long
,bool等,两者均比较值,故比较结果一致。
- 对于大多数引用类型而言,==和Object.Equals方法均默认比较引用,但可以选择重载==和Equals方法,但为了不使该类型的使用者感到困惑,应同时overload或override两者,并使两者的判定结果保持一致。
- 对于非基元值类型而言,Object.Equals方法将通过反射进行值相等性测试,但它的性能低下,实践中最好override该方法以实现快速判等;而==操作符默认情况下不可用,若想用需自己实现。
由于==操作符比较简洁,因此在平常开发中更受喜爱,但它也存在缺陷,我将在下篇博文中进行介绍。