8.5 运算符重载
本节将介绍为类或结构定义的另一种类型的成员:运算符重载。C++开发人员应很熟悉运算符重载。但是,因为这对于Java和Visual Basic开发人员来说是全新的概念,所以这里要解释一下。C++开发人员可以直接跳到主要的运算符重载示例上。
运算符重载的关键是在对象上不能总是只调用方法或属性,有时还需要做一些其他工作,例如对数值进行相加、相乘或逻辑操作(如比较对象)等。假定已经定义了一个表示数学矩阵的类。在数学领域中,矩阵可以相加和相乘,就像数字一样。所以可以编写下面的代码:
Matrix a, b, c; // assume a, b and c have been initialized Matrix d = c * (a + b);
通过重载运算符,就可以告诉编译器,“+”和“*”对Matrix对象执行什么操作,以便编写类似于上面的代码。如果用不支持运算符重载的语言编写代码,就必须定义一个方法,以执行这些操作。结果肯定不太直观,可能如下所示:
Matrix d = c.Multiply(a.Add(b));
学习到现在可以知道,像“+”和“*”这样的运算符只能用于预定义的数据类型,原因很简单:编译器知道所有常见的运算符对于这些数据类型的含义。例如,它知道如何把两个long数据加起来,或者如何对两个double数据执行相除操作,并且可以生成合适的中间语言代码。但在定义自己的类或结构时,必须告诉编译器:什么方法可以调用,每个实例存储了什么字段等所有信息。同样,如果要对自定义的类使用运算符,就必须告诉编译器相关的运算符在这个类的上下文中的含义。此时就要定义运算符的重载。
要强调的另一个问题是重载不仅仅限于算术运算符。还需要考虑比较运算符 ==、<、>、! =、>=和<=。例如,考虑语句if(a==b)。对于类,这条语句在默认状态下会比较引用a和b。检测这两个引用是否指向内存中的同一个地址,而不是检测两个实例实际上是否包含相同的数据。对于string类,这种行为就会重写,于是比较字符串实际上就是比较每个字符串的内容。可以对自己的类进行这样的操作。对于结构,“==”运算符在默认状态下不做任何工作。试图比较两个结构,看看它们是否相等,就会产生一个编译错误,除非显式地重载了“==”,告诉编译器如何进行比较。
在许多情况下,重载运算符用于生成可读性更高、更直观的代码,包括:
● 在数学领域中,几乎包括所有的数学对象:坐标、矢量、矩阵、张量和函数等。如果编写一个程序执行某些数学或物理建模,就几乎肯定会用类表示这些对象。
● 图形程序在计算屏幕上的位置时,也使用与数学或坐标相关的对象。
● 表示大量金钱的类(例如,在财务程序中)。
● 字处理或文本分析程序也有表示语句、子句等方面的类,可以使用运算符合并语句(这是字符串连接的一种比较复杂的版本)。
但是,也有许多类型与运算符重载并不相关。不恰当地使用运算符重载,会使使用类型的代码更难理解。例如,把两个DateTime对象相乘,在概念上没有任何意义。
8.5.1 运算符的工作方式
为了理解运算符是如何重载的,考虑一下在编译器遇到运算符时会发生什么情况就很有用。用加法运算符(+)作为例子,假定编译器处理下面的代码:
int myInteger = 3; uint myUnsignedInt = 2; double myDouble = 4.0; long myLong = myInteger + myUnsignedInt; double myOtherDouble = myDouble + myInteger;
考虑当编译器遇到下面这行代码时会发生什么情况:
long myLong = myInteger + myUnsignedInt;
编译器知道它需要把两个整数加起来,并把结果赋予一个long型变量。调用一个方法把数字加在一起时,表达式myInteger + myUnsignedInt是一种非常直观和方便的语法。该方法接受两个参数myInteger和myUnsignedInt,并返回它们的和。所以编译器完成的任务与任何方法调用一样——它会根据参数类型查找最匹配的“+”运算符重载,这里是带两个整数参数的“+”运算符重载。与一般的重载方法一样,预定义的返回类型不会因为编译器调用方法的哪个版本而影响其选择。在本例中调用的重载方法接受两个int参数,返回一个int值,这个返回值随后会转换为long类型。
下一行代码让编译器使用“+”运算符的另一个重载版本:
double myOtherDouble = myDouble + myInteger;
在这个实例中,参数是一个double类型的数据和一个int类型的数据,但“+”运算符没有这种复合参数的重载形式,所以编译器认为,最匹配的“+”运算符重载是把两个double数据作为其参数的版本,并隐式地把int强制转换为double。把两个double数据加在一起与把两个整数加在一起完全不同,浮点数存储为一个尾数和一个指数。把它们加在一起要按位移动一个double数据的尾数,从而使两个指数有相同的值,然后把尾数加起来,移动所得到尾数的位,调整其指数,保证答案有尽可能高的精度。
现在,看看如果编译器遇到下面的代码会发生什么:
Vector vect1, vect2, vect3; // initialize vect1 and vect2 vect3 = vect1 + vect2; vect1 = vect1*2;
其中,Vector是结构,稍后再定义它。编译器知道它需要把两个Vector实例加起来,即vect1和vect2。它会查找“+”运算符的重载,该重载版本把两个Vector实例作为参数。
如果编译器找到这样的重载版本,它就调用该运算符的实现代码。如果找不到,它就要看看有没有可以用作最佳匹配的其他“+”运算符重载,例如,某个运算符重载对应的两个参数是其他数据类型,但可以隐式地转换为Vector实例。如果编译器找不到合适的运算符重载,就会产生一个编译错误,就像找不到其他方法调用的合适重载版本一样。
8.5.2 运算符重载的示例:Vector结构
本章的示例使用如下依赖项和名称空间(除非特别注明):
依赖项:
NETStandard.Library
名称空间:
System static System.Console
本小节将开发一个结构Vector来说明运算符重载,这个Vector结构表示一个三维数学矢量。如果数学不是你的强项,不必担心,我们会使这个例子尽可能简单。就此处而言,三维矢量只是3个(double)数字的集合,说明物体的移动速度。表示数字的变量是_x、_y和_z, _x表示物体向东移动的速度,_y表示物体向北移动的速度,_z表示物体向上移动的速度(高度)。把这3个数字组合起来,就得到总移动量。例如,如果_x=3.0、_y=3.0、_z=1.0,一般可以写作(3.0, 3.0, 1.0),表示物体向东移动3个单位,向北移动3个单位,向上移动1个单位。
矢量可以与其他矢量或数字相加或相乘。在这里我们还使用术语“标量”,它是简单数字的数学用语——在C#中就是一个double数据。相加的作用很明显。如果先移动(3.0, 3.0, 1.0)矢量对应的距离,再移动(2.0, -4.0, -4.0)矢量对应的距离,总移动量就是把这两个矢量加起来。矢量的相加指把每个对应的组成元素分别相加,因此得到(5.0, -1.0, -3.0)。此时,数学表达式总是写成c=a+b,其中a和b是矢量,c是结果矢量。这与Vector结构的使用方式一样。
注意:这个例子将作为一个结构而不是类来开发,但这并不重要。运算符重载用于结构和类时,其工作方式是一样的。
下面是Vector的定义——包含只读属性、构造函数和重写的ToString()方法,以便轻松地查看Vector的内容,最后是运算符重载(代码文件OperatorOverloadingSample/Vector.cs):
struct Vector { public Vector(double x, double y, double z) { X = x; Y = y; Z = z; } public Vector(Vector v) { X = v.X; Y = v.Y; Z = v.Z; } public double X { get; } public double Y { get; } public double Z { get; } public override string ToString() => $"( {X}, {Y}, {Z} )"; }
这里提供了两个构造函数,通过传递每个元素的值或者提供另一个复制其值的Vector来指定矢量的初始值。第二个构造函数带一个Vector参数,通常称为复制构造函数,因为它们允许通过复制另一个实例来初始化一个类或结构实例。
下面是Vector结构的有趣部分——为“+”运算符提供支持的运算符重载:
public static Vector operator +(Vector left, Vector right) =>
new Vector(left.X + right.X, left.Y + right.Y, left.Z + right.Z);
运算符重载的声明方式与静态方法基本相同,但operator关键字告诉编译器,它实际上是一个自定义的运算符重载,后面是相关运算符的实际符号,在本例中就是“+”。返回类型是在使用这个运算符时获得的类型。在本例中,把两个矢量加起来会得到另一个矢量,所以返回类型也是Vector。对于这个特定的“+”运算符重载,返回类型与包含的类一样,但并不一定是这种情况,在本示例中稍后将看到。两个参数就是要操作的对象。对于二元运算符(带两个参数),如“+”和“-”运算符,第一个参数是运算符左边的值,第二个参数是运算符右边的值。
这个实现代码返回一个新的矢量,该矢量用left和right变量的x、y和z属性初始化。
C#要求所有的运算符重载都声明为public和static,这表示它们与其类或结构相关联,而不是与某个特定实例相关联,所以运算符重载的代码体不能访问非静态类成员,也不能访问this标识符;这是可行的,因为参数提供了运算符执行其任务所需要知道的所有输入数据。
下面需要编写一些简单的代码来测试Vector结构(代码文件OperatorOverloadingSample/Program.cs):
static void Main() { Vector vect1, vect2, vect3; vect1 = new Vector(3.0, 3.0, 1.0); vect2 = new Vector(2.0, -4.0, -4.0); vect3 = vect1 + vect2; WriteLine($"vect1 = {vect1}"); WriteLine($"vect2 = {vect2}"); WriteLine($"vect3 = {vect3}"); }
把这些代码另存为Vectors.cs,编译并运行它,结果如下:
vect1 = ( 3, 3, 1 ) vect2 = ( 2, -4, -4 ) vect3 = ( 5, -1, -3 )
矢量除了可以相加之外,还可以相乘、相减和比较它们的值。本节通过添加几个运算符重载,扩展了这个Vector例子。这并不是一个功能齐全的真实的Vector类型,但足以说明运算符重载的其他方面了。首先要重载乘法运算符,以支持标量和矢量的相乘以及矢量和矢量的相乘。
矢量乘以标量只意味着矢量的每个组成元素分别与标量相乘,例如,2×(1.0, 2.5, 2.0)就等于(2.0, 5.0, 4.0)。相关的运算符重载如下所示(代码文件OperatorOverloadingSample2/Vector.cs):
public static Vector operator *(double left, Vector right) => new Vector(left * right.X, left * right.Y, left * right.Z);
但这还不够,如果a和b声明为Vector类型,就可以编写下面的代码:
b = 2 * a;
编译器会隐式地把整数2转换为double类型,以匹配运算符重载的签名。但不能编译下面的代码:
b = a * 2;
编译器处理运算符重载的方式与方法重载是一样的。它会查看给定运算符的所有可用重载,找到与之最匹配的重载方式。上面的语句要求第一个参数是Vector,第二个参数是整数,或者可以隐式转换为整数的其他数据类型。我们没有提供这样一个重载。有一个运算符重载,其参数依次是一个double和一个Vector,但编译器不能交换参数的顺序,所以这是不可行的。需要显式地定义一个运算符重载,其参数依次是一个Vector和一个double,有两种方式可以实现这样的运算符重载。第一种方式是对矢量乘法进行分解,和处理所有运算符的方式一样,显式执行矢量相乘操作:
public static Vector operator *(Vector left, double right) => new Vector(right * left.X, right * left.Y, right * left.Z);
前面已经编写了实现基本相乘操作的代码,最好重用该代码:
public static Vector operator *(Vector left, double right) => right * left;
这段代码会有效地告诉编译器,如果有Vector和double数据的相乘操作,编译器就颠倒参数的顺序,调用另一个运算符重载。本章的示例代码使用第二个版本,因为它看起来比较简洁,同时阐述了该行为的思想。利用这个版本可以编写出可维护性更好的代码,因为不需要复制代码,就可在两个独立的重载中执行相乘操作。
下一个要重载的乘法运算符支持矢量相乘。在数学领域,矢量相乘有两种方式,但这里我们感兴趣的是点积或内积,其结果实际上是一个标量。这就是我们介绍这个例子的原因:算术运算符不必返回与定义它们的类相同的类型。
在数学术语中,如果有两个矢量(x, y, z)和(X, Y, Z),其内积就定义为x * X + y * Y + z * Z的值。两个矢量这样相乘很奇怪,但这实际上很有用,因为它可以用于计算各种其他的数。当然,如果要使用Direct3D或DirectDraw编写代码来显示复杂的3D图形,那么在计算对象放在屏幕上的什么位置时,中间常常需要编写代码来计算矢量的内积。这里我们关心的是使用Vector编写出double X =a * b,其中a和b是两个Vector对象,并计算出它们的点积。相关的运算符重载如下所示:
public static double operator *(Vector left, Vector right) => left.X * right.X + left.Y * right.Y + left.Z * right.Z;
理解了算术运算符后,就可以用一个简单的测试方法来检验它们是否能正常运行:
static void Main() { // stuff to demonstrate arithmetic operations Vector vect1, vect2, vect3; vect1 = new Vector(1.0, 1.5, 2.0); vect2 = new Vector(0.0, 0.0, -10.0); vect3 = vect1 + vect2; WriteLine($"vect1 = {vect1}"); WriteLine($"vect2 = {vect2}"); WriteLine($"vect3 = vect1 + vect2 = {vect3}"); WriteLine($"2 * vect3 = {2 * vect3}"); WriteLine($"vect3 += vect2 gives {vect3 += vect2}"); WriteLine($"vect3 = vect1 * 2 gives {vect3 = vect1 * 2}"); WriteLine($"vect1 * vect3 = {vect1 * vect3}"); }
运行此代码,得到如下所示的结果:
vect1 = ( 1, 1.5, 2 ) vect2 = ( 0, 0, -10 ) vect3 = vect1 + vect2 = ( 1, 1.5, -8 ) 2 * vect3 = ( 2, 3, -16 ) vect3 += vect2 gives ( 1, 1.5, -18 ) vect3 = vect1 * 2 gives ( 2, 3, 4 ) vect1 * vect3 = 14.5
这说明,运算符重载会给出正确的结果,但如果仔细看看测试代码,就会惊奇地注意到,实际上它使用的是没有重载的运算符——相加赋值运算符(+=):
WriteLine($"vect3 += vect2 gives {vect3 += vect2}");
虽然“+=”一般计为单个运算符,但实际上它对应的操作分为两步:相加和赋值。与C++语言不同,C#不允许重载“=”运算符;但如果重载“+”运算符,编译器就会自动使用“+”运算符的重载来执行“+=”运算符的操作。-=、*=、/=和&=等所有赋值运算符也遵循此原则。
8.5.3 比较运算符的重载
本章前面介绍过,C#中有6个比较运算符,它们分为3对:
● ==和!=
● >和<
● >=和<=
注意:.NET指南指定,在比较两个对象时,如果==运算符返回true,就应总是返回true。所以应在不可改变的类型上只重载==运算符。
C#语言要求成对重载比较运算符。即,如果重载了“==”,也就必须重载“! =”;否则会产生编译器错误。另外,比较运算符必须返回布尔类型的值。这是它们与算术运算符的根本区别。例如,两个数相加或相减的结果理论上取决于这些数值的类型。前面提到,两个Vector对象的相乘会得到一个标量。另一个例子是.NET基类System.DateTime。两个DateTime实例相减,得到的结果不是一个DateTime,而是一个System.TimeSpan实例。相比之下,如果比较运算得到的不是布尔类型的值,就没有任何意义。
除了这些区别外,重载比较运算符所遵循的原则与重载算术运算符相同。但比较两个数并不如想象得那么简单。例如,如果只比较两个对象引用,就是比较存储对象的内存地址。比较运算符很少进行这样的比较,所以必须编写代码重载运算符,比较对象的值,并返回相应的布尔结果。下面对Vector结构重载“==”和“! =”运算符。首先是实现“==”重载的代码(代码文件OverloadingComparisonSample/Vector.cs):
public static bool operator ==(Vector left, Vector right) { if (object.ReferenceEquals(left, right)) return true; return left.X == right.X && left.Y == right.Y && left.Z == right.Z; }
这种方式仅根据Vector组成元素的值来对它们进行相等性比较。对于大多数结构,这就是我们希望的方式,但在某些情况下,可能需要仔细考虑相等性的含义。例如,如果有嵌入的类,那么是应比较引用是否指向同一个对象(浅度比较),还是应比较对象的值是否相等(深度比较)?
浅度比较是比较对象是否指向内存中的同一个位置,而深度比较是比较对象的值和属性是否相等。应根据具体情况进行相等性检查,从而有助于确定要验证的结果。
注意:不要通过调用从System.Object中继承的Equals()方法的实例版本来重载比较运算符。如果这么做,在objA是null时判断(objA==objB),就会产生一个异常,因为.NET运行库会试图判断null.Equals(objB)。采用其他方法(重写Equals()方法以调用比较运算符)比较安全。
还需要重载运算符“! =”,采用的方式如下:
public static bool operator ! =(Vector left, Vector right) => ! (left == right);
现在重写Equals和GetHashCode方法。这些方法应该总是在重写==运算符时进行重写,否则编译器会报错。
public override bool Equals(object obj) { if (obj == null) return false; return this == (Vector)obj; } public override int GetHashCode() => X.GetHashCode() + (Y.GetHashCode() << 4) + (Z.GetHashCode() << 8);
Equals方法可以转而调用==运算符。散列代码的实现应比较快速,且总是对相同的对象返回相同的值。这个方法在使用字典时很重要。在字典中,它用来建立对象的树,所以最好把返回值分布到整数范围内。double类型的GetHashCode方法返回double值的整数表示。对于Vector类型,只是添加底层类型的散列值。如果散列代码有不同的值,例如,值(5.0, 2.0, 0.0)和(2.0, 5.0, 0.0)——所返回散列值的Y和Z值就按位移动4和8位,再添加数字。
对于值类型,也应该实现接口IEquatable < T >。这个接口是Equals方法的一个强类型化版本,由基类Object定义。有了所有其他代码,就很容易实现该方法:
public bool Equals(Vector other) => this == other;
像往常一样,应该用一些测试代码快速检查重写方法的工作情况。这次定义3个Vector对象,并进行比较(代码文件OverloadingComparisonSample/Program.cs):
static void Main() { var vect1 = new Vector(3.0, 3.0, -10.0); var vect2 = new Vector(3.0, 3.0, -10.0); var vect3 = new Vector(2.0, 3.0, 6.0); WriteLine($"vect1 == vect2 returns {(vect1 == vect2)}"); WriteLine($"vect1 == vect3 returns {(vect1 == vect3)}"); WriteLine($"vect2 == vect3 returns {(vect2 == vect3)}"); WriteLine(); WriteLine($"vect1 ! = vect2 returns {(vect1 ! = vect2)}"); WriteLine($"vect1 ! = vect3 returns {(vect1 ! = vect3)}"); WriteLine($"vect2 ! = vect3 returns {(vect2 ! = vect3)}"); }
在命令行上运行该示例,生成如下结果:
vect1 == vect2 returns True vect1 == vect3 returns False vect2 == vect3 returns False vect1 ! = vect2 returns False vect1 ! = vect3 returns True vect2 ! = vect3 returns True
8.5.4 可以重载的运算符
并不是所有的运算符都可以重载。可以重载的运算符如表8-5所示。
表8-5
注意:为什么要重载true和false操作符?有一个很好的原因:根据所使用的技术或框架,哪些整数值代表true或false是不同的。在许多技术中,0是false,1是true;其他技术把非0值定义为true,还有一些技术把 -1定义为false。