第6章 运算符和类型强制转换
前几章介绍了使用C#编写程序所需要的大部分知识。本章将首先讨论基本语言元素,接着论述C#语言的扩展功能。本章的主要内容如下:
● C#中的可用运算符
● 处理引用类型和值类型时相等的含义
● 基本数据类型之间的数据转换
● 使用装箱技术把值类型转换为引用类型
● 通过强制转换技术在引用类型之间转换
● 重载标准的运算符,以支持对定制类型的操作
● 给定制类型添加强制转换运算符,以支持无缝的数据类型转换
6.1 运算符
C和C++开发人员应很熟悉大多数C#运算符,这里为新程序员和Visual Basic开发人员介绍最重要的运算符,并介绍C#中的一些新变化。
C#支持表6-1所示的运算符。
表 6-1
类 别 | 运 算 符 |
算术运算符 | + – * / % |
逻辑运算符 | & | ^ ~ && || ! |
字符串连接运算符 | + |
增量和减量运算符 | ++ – – |
移位运算符 | << >> |
比较运算符 | == != < > <= >= |
赋值运算符 | = += –= *= /= %= &= |= ^= <<= >>= |
成员访问运算符(用于对象和结构) | . |
索引运算符(用于数组和索引器) | [] |
数据类型转换运算符 | () |
条件运算符 (三元运算符) | ?: |
委托连接和删除运算符(见第7章) | + – |
对象创建运算符 | new |
类型信息运算符 | sizeof (只用于不安全的代码) is typeof as |
溢出异常控制运算符 | checked unchecked |
间接寻址运算符 | * –> & (只用于不安全代码) [] |
命名空间别名限定符(见第2章) | :: |
空接合运算符 | ?? |
有4个运算符(sizeof、*、->、&)只能用于不安全的代码(这些代码绕过了C#类型安全性的检查),这些不安全的代码见第12章的讨论。还要注意,sizeof运算符在.NET Framework 1.0和1.1中使用,它需要不安全模式。自从.NET Framework 2.0以来,就没有这个运算符了。
类 别 | 运 算 符 |
运算符关键字 | sizeof(仅用于.NET Framework 1.0和1.1) |
运算符 | *、–>、& |
使用C#运算符的一个最大缺点是,与C风格的语言一样,赋值(=)和比较(==)运算使用不同的运算符。例如,下述语句表示"x等于3":
x = 3; |
如果要比较x和另一个值,就需要使用两个等号(==):
if (x == 3) } |
C#非常严格的类型安全规则防止出现常见的C#错误,也就是在逻辑语句中使用赋值运算符代替比较运算符。在C#中,下述语句会产生一个编译错误:
if (x = 3) } |
习惯使用宏字符&来连接字符串的Visual Basic程序员必须改变这个习惯。在C#中,使用加号+连接字符串,而&表示两个不同整数值的按位AND运算。| 则在两个整数之间执行按位OR运算。Visual Basic程序员可能还没有使用过%(取模)运算符,它返回除运算的余数,例如,如果x等于7,则x % 5会返回2。
在C#中很少会用到指针,因此也很少用到间接寻址运算符(->)。使用它们的唯一场合是在不安全的代码块中,因为只有在此C#才允许使用指针。指针和不安全的代码见第12章。
6.1.1 运算符的简化操作
表6-2列出了C#中的全部简化赋值运算符。
表 6-2
运算符的简化操作 | 等 价 于 |
x++, ++x | x = x + 1 |
x– –,– –x | x = x – 1 |
x+= y | x = x + y |
x–= y | x = x – y |
x *= y | x = x * y |
x /= y | x = x / y |
x %= y | x = x % y |
x >>= y | x = x >> y |
x <<= y | x = x << y |
x &= y | x = x & y |
x |= y | x = x | y |
x ^= y | x = x ^ y |
为什么用两个例子来说明++增量和- -减量运算符?把运算符放在表达式的前面称为前置,把运算符放在表达式的后面称为后置。它们的执行方式有所不同。
增量或减量运算符可以作用于整个表达式,也可以作用于表达式的内部。当x++和++x单独占一行时,它们的作用是相同的,对应于语句x = x + 1。但当它们用于表达式内部时,把运算符放在前面(++x)会在计算表达式之前递增x,换言之,递增了x后,在表达式中使用新值进行计算。而把运算符放在后面(x++)会在计算表达式之后递增x-- 使用x的原值计算表达式。下面的例子使用++增量运算符说明了它们的区别:
int x = 5; if (++x == 6) // true - x isincremented to 6 before the evaluation |
第一个if条件得到true,因为在计算表达式之前,x从5递增为6。第二个if语句中的条件为false,因为在计算完整个表达式(x=6)后,x才递增为7。
前置运算符- -x和后置运算符x- -与此类似,但它们是递减,而不是递增。
其他简化运算符,如+= 和-=需要两个操作数,用于执行算术、逻辑和按位运算,改变第一个操作数的值。例如,下面两行代码是等价的:
x += 5; |
下面介绍在C#代码中频繁使用的基本运算符和类型转换运算符。
6.1.2 条件运算符
条件运算符(?:)也称为三元运算符,是if...else结构的简化形式。其名称的出处是它带有三个操作数。它可以计算一个条件,如果条件为真,就返回一个值;如果条件为假,则返回另一个值。其语法如下:
condition ? true_value : false_value |
其中condition是要计算的Boolean型表达式,true_value是condition为true时返回的值,false_value是condition为false时返回的值。
恰当地使用三元运算符,可以使程序非常简洁。它特别适合于给被调用的函数提供两个参数中的一个。使用它可以把Boolean值转换为字符串值true或false。它也很适合于显示正确的单数形式或复数形式,例如:
int x = 1; |
如果x等于1,这段代码就显示1 man,如果x等于其他数,就显示其正确的复数形式。但要注意,如果结果需要用在不同的语言中,就必须编写更复杂的例程,以考虑到不同语言的不同语法。
6.1.3 checked和unchecked运算符
考虑下面的代码:
byte b = 255; |
byte数据类型只能包含0~255的数,所以递增b的值会导致溢出。CLR如何处理这个溢出取决于许多方面,包括编译器选项,所以只要有未预料到的溢出风险,就需要用某种方式确保得到我们希望的结果。
为此,C#提供了checked和unchecked运算符。如果把一个代码块标记为checked,CLR就会执行溢出检查,如果发生溢出,就抛出异常。下面修改代码,使之包含checked运算符:
byte b = 255; |
运行这段代码,就会得到一个错误信息:
Unhandled Exception: System.OverflowException: Arithmetic |
注意:
用/checked编译器选项进行编译,就可以检查程序中所有未标记代码中的溢出。
如果要禁止溢出检查,可以把代码标记为unchecked:
byte b = 255; |
在本例中,不会抛出异常,但会丢失数据--因为byte数据类型不能包含256,溢出的位会被丢掉,所以b变量得到的值是0。
注意,unchecked是默认值。只有在需要把几个未检查的代码行放在一个明确标记为checked的大代码块中,才需要显式使用unchecked关键字。
6.1.4 is运算符
is运算符可以检查对象是否与特定的类型兼容。"兼容"表示对象是该类型,或者派生于该类型。例如,要检查变量是否与object类型兼容,可以使用下面的代码:
int i = 10; |
int和其他C#数据类型一样,也从object继承而来;表达式i is object将得到true,并显示相应的信息。
6.1.5 as运算符
as运算符用于执行引用类型的显式类型转换。如果要转换的类型与指定的类型兼容,转换就会成功进行;如果类型不兼容,as运算符就会返回值null。如下面的代码所示,如果object引用不指向string实例,把object引用转换为string就会返回null:
object o1 = "Some String"; string s1 = o1 as string; //s1 = "Some String" |
as运算符允许在一步中进行安全的类型转换,不需要先使用is运算符测试类型,再执行转换。
6.1.6 sizeof运算符
使用sizeof运算符可以确定堆栈中值类型需要的长度(单位是字节):
unsafe |
其结果是显示数字4,因为int有4个字节。
注意,只能在不安全的代码中使用sizeof运算符。第12章将详细论述不安全的代码。
6.1.7 typeof运算符
typeof运算符返回一个表示特定类型的System.Type对象。例如,typeof(string)返回表示System.String类型的Type对象。在使用反射技术动态查找对象的信息时,这个运算符是很有效的。第13章将介绍反射。
6.1.8 可空类型和运算符
对于布尔类型,可以给它指定true或false值。但是,要把该类型的值定义为undefined,该怎么办?此时使用可空类型可以给应用程序提供一个独特的值。如果在程序中使用可空类型,就必须考虑null值在与各种运算符一起使用时的影响。通常可空类型与一元或二元运算符一起使用时,如果其中一个操作数或两个操作数都是null,其结果就是null。例如:
int? a = null; int? b = a + 4; // b = null |
但是在比较可空类型时,只要有一个操作数是null,比较的结果就是false。即不能因为一个条件是false,就认为该条件的对立面是true,这在使用非可空类型的程序中很常见。例如:
int? a = null; if (a >= b) |
注意:
null值的可能性表示,不能随意合并表达式中的可空类型和非可空类型,详见本章后面的内容。
6.1.9 空接合运算符
空接合运算符(??)提供了一种快捷方式,可以在处理可空类型和引用类型时表示null值。这个运算符放在两个操作数之间,第一个操作数必须是一个可空类型或引用类型,第二个操作数必须与第一个操作数的类型相同,或者可以隐含地转换为第一个操作数的类型。空接合运算符的计算如下:如果第一个操作数不是null,则整个表达式就等于第一个操作数的值。但如果第一个操作数是null,则整个表达式就等于第二个操作数的值。例如:
int? a = null; b = a ?? 10; // b has the value 10 |
如果第二个操作数不能隐含地转换为第一个操作数的类型,就生成一个编译错误。
6.1.10 运算符的优先级
表6-3显示了C#运算符的优先级。表顶部的运算符有最高的优先级(即在包含多个运算符的表达式中,最先计算该运算符):
表 6-3
组 | 运 算 符 | |
初级运算符 | () . [] x++ x–– new typeof sizeof checked unchecked | |
一元运算符 | + – ! ~ ++x ––x和数据类型转换 | |
乘/除运算符 | * / % | |
加/减运算符 | + – | |
移位运算符 | << >> | |
关系运算符 | < > <= >= is as | |
比较运算符 | = = != | |
按位AND运算符 | & | |
按位XOR运算符 | ^ | |
按位OR运算符 | | | |
| 布尔 AND运算符 | && |
| 布尔OR运算符 | || |
| 条件运算符 | ?: |
| 赋值运算符 | = += –= *= /= %= &= |= ^= <<= >>= >>>= |
注意:
在复杂的表达式中,应避免利用运算符优先级来生成正确的结果。使用括号指定运算符的执行顺序,可以使代码更整洁,避免出现潜在的冲突。
6.2 类型的安全性
第1章提到中间语言(IL)可以对其代码强制加上强类型安全性。强类型支持.NET提供的许多服务,包括安全性和语言的交互性。因为C#这种语言会编译为IL,所以C#也是强类型的。这说明数据类型并不总是可互换的。本节将介绍基本类型之间的转换。
注意:
C#还支持在不同引用类型之间的转换,允许指定自己创建的数据类型如何与其他类型进行相互转换。这些论题将在本章后面讨论。
泛型是C#中的一个特性,它可以避免对一些常见的情形进行类型转换,泛型详见第9章。
6.2.1 类型转换
我们常常需要把数据从一种类型转换为另一种类型。考虑下面的代码:
byte value1 = 10; |
在编译这些代码时,会产生一个错误:
Cannot implicitly convert type 'int' to 'byte' (不能把int类型隐式地转换为byte类型)。
问题是,我们把两个byte型数据加在一起时,应返回int型结果,而不是另一个byte。这是因为byte包含的数据只能为8位,所以把两个byte型数据加在一起,很容易得到不能存储在byte变量中的值。如果要把结果存储在一个byte变量中,就必须把它转换回byte。C#有两种转换方式:隐式转换方式和显式转换方式。
1. 隐式转换方式
只要能保证值不会发生任何变化,类型转换就可以自动进行。这就是前面代码失败的原因:试图从int转换为byte,而潜在地丢失了3个字节的数据。编译器不会告诉我们该怎么做,除非我们明确告诉它这就是我们希望的!如果在long型变量中存储结果,而不是byte型变量中,就不会有问题了:
byte value1 = 10; |
这是因为long类型变量包含的数据字节比byte类型多,所以数据没有丢失的危险。在这些情况下,编译器会很顺利地转换,我们也不需要显式提出要求。
表6-4介绍了C#支持的隐式类型转换。
表 6-4
源 类 型 | 目 的 类 型 |
sbyte | short、int、long、float、double、decimal |
byte | short、ushort、int、uint、long、ulong、float、double、decimal |
short | int、long、float、double、decimal |
ushort | int、uint、long、ulong、float、double、decimal |
int | long、float、double、decimal |
uint | long、ulong、float、double、decimal |
long、ulong | float、double、decimal |
float | double |
char | ushort、int、uint、long、ulong、float、double、decimal |
注意,只能从较小的整数类型隐式地转换为较大的整数类型,不能从较大的整数类型隐式地转换为较小的整数类型。也可以在整数和浮点数之间转换,其规则略有不同,可以在相同大小的类型之间转换,例如int/uint转换为 float,long/ulong转换为double,也可以从long/ulong转换回float。这样做可能会丢失4个字节的数据,但这仅表示得到的float值比使用double得到的值精度低,编译器认为这是一种可以接受的错误,而其值的大小是不会受到影响的。无符号的变量可以转换为有符号的变量,只要无符号的变量值的大小在有符号的变量的范围之内即可。
在隐式转换值类型时,可空类型需要额外考虑:
● 可空类型隐式转换为其他可空类型,应遵循表6-4中非可空类型的转换规则。即int? 隐式转换为long?、float?、double?和decimal?。
● 非可空类型隐式转换为可空类型也遵循表6-4中的转换规则,即int隐式转换为long?、float?、double?和decimal?。
● 可空类型不能隐式转换为非可空类型,此时必须进行显式转换,如下一节所述。这是因为可空类型的值可以是null,但非可空类型不能表示这个值。
2. 显式转换方式
有许多场合不能隐式地转换类型,否则编译器会报告错误。下面是不能进行隐式转换的一些场合:
● int转换为short-- 会丢失数据
● int转换为uint-- 会丢失数据
● uint转换为int-- 会丢失数据
● float转换为int-- 会丢失小数点后面的所有数据
● 任何数字类型转换为char -- 会丢失数据
● decimal转换为任何数字类型-- 因为decimal 类型的内部结构不同于整数和浮点数
● int? 转换为int-- 可空类型的值可以是null
但是,可以使用cast显式执行这些转换。在把一种类型强制转换为另一种类型时,要迫使编译器进行转换。类型转换的一般语法如下:
long val = 30000; |
这表示,把转换的目标类型名放在要转换的值之前的圆括号中。对于熟悉C的程序员来说,这是数据类型转换的典型语法。对于熟悉C++数据类型转换关键字(如static_cast)的程序员来说,这些关键字在C#中不存在,必须使用C风格的旧语法。
这种类型转换是一种比较危险的操作,即使在从long转换为int这样简单的转换过程中,如果原来long的值比int的最大值还大,就会出问题:
long val = 3000000000; |
在本例中,不会报告错误,也得不到期望的结果。如果运行上面的代码,结果存储在i中,则其值为:
-1294967296 |
最好假定显式数据转换不会给出希望的结果。如前所述,C#提供了一个checked运算符,使用它可以测试操作是否会产生算术溢出。使用这个运算符可以检查数据类型转换是否安全,如果不安全,就会让运行库抛出一个溢出异常:
long val = 3000000000; |
记住,所有的显式数据类型转换都可能不安全,在应用程序中应包含这样的代码,处理可能失败的数据类型转换。第14章将使用try和 catch语句引入结构化异常处理。
使用数据类型转换可以把大多数数据从一种基本类型转换为另一种基本类型。例如:给price加上0.5,再把结果转换为int:
double price = 25.30; |
这么做的代价是把价格四舍五入为最接近的美元数。但在这个转换过程中,小数点后面的所有数据都会丢失。因此,如果要使用这个修改过的价格进行更多的计算,最好不要使用这种转换;如果要输出全部计算完或部分计算完的近似值,且不希望用小数点后面的数据去麻烦用户,这种转换是很好的。
下面的例子说明了把一个无符号的整数转换为char型时,会发生的情况:
ushort c = 43; |
结果是ASCII编码为43的字符,即+号。可以尝试数字类型之间的任何转换(包括char),这种转换是成功的,例如把decimal转换为char,或把char转换为decimal。
值类型之间的转换并不仅限于孤立的变量。还可以把类型为double的数组元素转换为类型为int的结构成员变量:
struct ItemDetails //... double[] Prices = { 25.30, 26.20, 27.40, 30.00 }; ItemDetails id; |
要把一个可空类型转换为非可空类型,或转换为另一个可空类型,但可能会丢失数据,就必须使用显式转换。重要的是,在底层基本类型相同的元素之间进行转换时,就一定要使用显式转换,例如int?转换为int,或float?转换为float。这是因为可空类型的值可以是null,非可空类型不能表示这个值。只要可以在两个非可空类型之间进行显式转换,对应可空类型之间的显式转换就可以进行。但如果从可空类型转换为非可空类型,且变量的值是null,就会抛出InvalidOperationException。例如:
int? a = null; |
使用显式的数据类型转换方式,并小心使用这种技术,就可以把简单值类型的任何实例转换为另一种类型。但在进行显式的类型转换时有一些限制,例如值类型,只能在数字、char类型和enum类型之间转换。不能直接把Boolean数据类型转换为其他类型,也不能把其他类型转换为Boolean数据类型。
如果需要在数字和字符串之间转换,.NET类库提供了一些方法。Object类有一个ToString()方法,该方法在所有的.NET预定义类型中都进行了重写,返回对象的字符串表示:
int i = 10; |
同样,如果需要分析一个字符串,获得一个数字或Boolean值,就可以使用所有预定义值类型都支持的Parse方法:
string s = "100"; |
注意,如果不能转换字符串(例如要把字符串Hello转换为一个整数),Parse方法就会注册一个错误,抛出一个异常。第14章将介绍异常。
6.2.2 装箱和拆箱
第2章介绍了所有类型,包括简单的预定义类型,例如int和char,以及复杂类型,例如从Object类型中派生的类和结构。下面可以像处理对象那样处理字面值:
string s = 10.ToString(); |
但是,C#数据类型可以分为在堆栈上分配内存的值类型和在堆上分配内存的引用类型。如果int不过是堆栈上一个4字节的值,该如何在它上面调用方法?
C#的实现方式是通过一个有点魔术性的方式,即装箱(boxing)。装箱和拆箱(unboxing)可以把值类型转换为引用类型,或把引用类型转换为值类型。这已经在数据类型转换一节中介绍过了,即把值转换为object类型。装箱用于描述把一个值类型转换为引用类型。运行库会为堆上的对象创建一个临时的引用类型"box"。
该转换是隐式进行的,如上面的例子所述。还可以进行显式转换:
int myIntNumber = 20; |
拆箱用于描述相反的过程,即以前装箱的值类型转换回值类型。这里使用术语"cast",是因为这种数据类型转换是显式进行的。其语法类似于前面的显式类型转换:
int myIntNumber = 20; |
只能把以前装箱的变量再转换为值类型。当o不是装箱后的int型时,如果执行上面的代码,就会在运行期间抛出一个异常。
这里有一个警告。在拆箱时,必须非常小心,确保得到的值变量有足够的空间存储拆箱的值中的所有字节。例如,C#的int只有32位,所以把long值(64位)拆箱为int时,会产生一个InvalidCastException异常:
long myLongNumber = 333333423; |
6.3 对象的相等比较
在讨论了运算符,并简要介绍了相等运算符后,就应考虑在处理类和结构的实例时,"相等"意味着什么。理解对象相等比较的机制对编写逻辑表达式非常重要,另外,对实现运算符重载和数据类型转换也非常重要,本章的后面将讨论运算符重载。
对象相等比较的机制对于引用类型(类的实例)的比较和值类型(基本数据类型,结构或枚举的实例)的比较来说是不同的。下面分别介绍引用类型和值类型的相等比较。
6.3.1 引用类型的相等比较
System.Object定义了3个不同的方法,来比较对象的相等性:ReferenceEquals()和Equals()的两个版本。再加上比较运算符==,实际上有4种进行相等比较的方式。这些方法有一些微妙的区别,下面就介绍这些方法。
1. ReferenceEquals()方法
ReferenceEquals()是一个静态方法,测试两个引用是否指向类的同一个实例,即两个引用是否包含内存中的相同地址。作为静态方法,它不能重写,所以只能使用System.Object的实现代码。如果提供的两个引用指向同一个对象实例,ReferenceEquals()总是返回true,否则就返回false。但是它认为null等于null:
SomeClass x, y; |
2. 虚拟的Equals()方法
Equals()虚拟版本的System.Object实现代码也可以比较引用。但因为这个方法是虚拟的,所以可以在自己的类中重写它,按值来比较对象。特别是如果希望类的实例用作字典中的键,就需要重写这个方法,以比较值。否则,根据重写Object.GetHashCode()的方式,包含对象的字典类要么不工作,要么工作的效率非常低。在重写Equals()方法时要注意,重写的代码不会抛出异常。这是因为如果抛出异常,字典类就会出问题,一些在内部调用这个方法的.NET基类也可能出问题。
3. 静态的Equals()方法
Equals()的静态版本与其虚拟实例版本的作用相同,其区别是静态版本带有两个参数,并对它们进行相等比较。这个方法可以处理两个对象中有一个是null的情况,因此,如果一个对象可能是null,这个方法就可以抛出异常,提供额外的保护。静态重载版本首先要检查它传送的引用是否为null。如果它们都是null,就返回true(因为null与null相等)。如果只有一个引用是null,就返回false。如果两个引用都指向某个对象,它就调用Equals()的虚拟实例版本。这表示在重写Equals()的实例版本时,其效果相当于也重写了静态版本。
4. 比较运算符==
最好将比较运算符看作是严格的值比较和严格的引用比较之间的中间选项。在大多数情况下,下面的代码表示比较引用:
bool b = (x == y); //x, y object references |
但是,如果把一些类看作值,其含义就会比较直观。在这些情况下,最好重写比较运算符,以执行值的比较。后面将讨论运算符的重载,但显然它的一个例子是System.String类,Microsoft重写了这个运算符,比较字符串的内容,而不是它们的引用。
6.3.2 值类型的相等比较
在进行值类型的相等比较时,采用与引用类型相同的规则:ReferenceEquals()用于比较引用,Equals()用于比较值,比较运算符可以看作是一个中间项。但最大的区别是值类型需要装箱,才能把它们转换为引用,才能对它们执行方法。另外,Microsoft已经在System.ValueType类中重载了实例方法Equals(),以便对值类型进行合适的相等测试。如果调用sA.Equals(sB),其中sA和sB是某个结构的实例,则根据sA和sB是否在其所有的字段中包含相同的值,而返回true或false。另一方面,在默认情况下,不能对自己的结构重载==运算符。在表达式中使用(sA==sB)会导致一个编译错误,除非在代码中为结构提供了==的重载版本。
另外,ReferenceEquals()在应用于值类型时,总是返回false,因为为了调用这个方法,值类型需要装箱到对象中。即使使用下面的代码:
bool b = ReferenceEquals(v, v); //v is a variable of some value type |
也会返回false,因为在转换每个参数时,v都会被单独装箱,这意味着会得到不同的引用。调用ReferenceEquals()来比较值类型实际上没有什么意义。
尽管System.ValueType提供的Equals()的默认重写代码肯定足以应付绝大多数自定义的结构,但仍可以为自己的结构重写它,以提高性能。另外,如果值类型包含作为字段的引用类型,就需要重写Equals(),以便为这些字段提供合适的语义,因为Equals()的默认重写版本仅比较它们的地址。
6.4 运算符重载
本节将介绍为类或结构定义的另一种类型的成员:运算符重载。
C++开发人员应很熟悉运算符重载。但是,因为这个概念对Java和Visual Basic开发人员来说是全新的,所以这里要解释一下。C++开发人员可以直接跳到主要示例上。
运算符重载的关键是在类实例上不能总是调用方法或属性,有时还需要做一些其他的工作,例如对数值进行相加、相乘或逻辑操作,如比较对象等。假定要定义一个类,表示一个数学矩阵,在数学中,矩阵可以相加和相乘,就像数字一样。所以可以编写下面的代码:
Matrix a, b, c; |
通过重载运算符,就可以告诉编译器,+和*对Matrix对象进行什么操作,以编写上面的代码。如果用不支持运算符重载的语言编写代码,就必须定义一个方法,以执行这些操作,结果肯定不太直观,如下所示。
Matrix d = c.Multiply(a.Add(b)); |
学习到现在,像+和*这样的运算符只能用于预定义的数据类型,原因很简单:编译器认为所有常见的运算符都是用于这些数据类型的,例如,它知道如何把两个long加起来,或者如何从一个double中减去另一个double,并生成合适的中间语言代码。但在定义自己的类或结构时,必须告诉编译器:什么方法可以调用,每个实例存储了什么字段等所有的信息。同样,如果要在自己的类上使用运算符,就必须告诉编译器相关的运算符在这个类中的含义。此时就要定义运算符重载。
要强调的另一个问题是重载不仅仅限于算术运算符。还需要考虑比较运算符 ==、<、>、!=、>=和<=。例如,语句if(a==b)。对于类,这个语句在默认状态下会比较引用a和b,检测这两个引用是否指向内存中的同一个地址,而不是检测两个实例是否包含相同的数据。对于string类,这种操作就会重写,比较字符串实际上就是比较每个字符串的内容。可以对自己的类进行这样的操作。对于结构,==运算符在默认状态下不做任何工作。试图比较两个结构,看看它们是否相等,就会产生一个编译错误,除非显式重载了==,告诉编译器如何进行比较。
在许多情况下,重载运算符允许生成可读性更高、更直观的代码,包括:
● 在数学领域中,几乎包括所有的数学对象:坐标、矢量、矩阵、张量和函数等。如果编写一个程序执行某些数学或物理建模,肯定会用类表示这些对象。
● 图形程序在计算屏幕上的位置时,也使用数学或相关的坐标对象。
● 表示大量金钱的类(例如,在财务程序中)。
● 字处理或文本分析程序也有表示语句、子句等的类,可以使用运算符把语句连接在一起(这是字符串连接的一种比较复杂的版本)。
另外,有许多类与运算符重载并不相关。不恰当地使用运算符重载,会使使用类型的代码很难理解。例如,把两个DateTime对象相乘,在概念上没有任何意义。
6.4.1 运算符的工作方式
为了理解运算符是如何重载的,考虑一下在编译器遇到运算符时会发生什么样的情况是很有用的--我们用相加运算符+作为例子来讲解。假定编译器遇到下面的代码:
int myInteger = 3; |
会发生什么情况:
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; |
其中,Vector是结构,稍后再定义它。编译器知道它需要把两个Vector实例加起来,即vect1 和 vect2。它会查找+运算符的重载,把两个Vector实例作为参数。
如果编译器找到这样的重载版本,就调用它的实现代码。如果找不到,就要看看有没有可以用作最佳匹配的其他+运算符重载,例如某个运算符重载的参数是其他数据类型,但可以隐式地转换为Vector实例。如果编译器找不到合适的运算符重载,就会产生一个编译错误,就像找不到其他方法调用的合适重载版本一样。
6.4.2 运算符重载的示例:Vector结构
本节将开发一个结构Vector,来演示运算符重载,这个Vector结构表示一个三维矢量。如果数学不是你的强项,不必担心,我们会使这个例子尽可能简单。三维矢量只是三个(double)数字的一个集合,说明物体和原点之间的距离,表示数字的变量是x、y和z,x表示物体与原点在x方向上的距离,y表示它与原点在y方向上的距离,z表示高度。把这3个数字组合起来,就得到总距离。例如,如果x=3.0, y=3.0, z=1.0,一般可以写作(3.0, 3.0, 1.0),表示物体与原点在x方向上的距离是3,与原点在y方向上的距离是3,高度为1。
矢量可以与矢量或数字相加或相乘。在这里我们使用术语"标量"(scalar),它是数字的数学用语-- 在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的内容,最后是运算符重载:
namespace Wrox.ProCSharp.OOCSharp public Vector(double x, double y, double z) public Vector(Vector rhs) public override string ToString() |
这里提供了两个构造函数,通过传递每个元素的值,或者提供另一个复制其值的Vector,来指定矢量的初始值。第二个构造函数带一个Vector参数,通常称为复制构造函数,因为它们允许通过复制另一个实例来初始化一个类或结构实例。注意,为了简单起见,把字段设置为public。也可以把它们设置为private,编写相应的属性来访问它们,这样做不会改变这个程序的功能,只是代码会复杂一些。
下面是Vector结构的有趣部分-- 为+运算符提供支持的运算符重载:
public static Vector operator + (Vector lhs, Vector rhs) |
运算符重载的声明方式与方法的声明方式相同,但operator关键字告诉编译器,它实际上是一个运算符重载,后面是相关运算符的符号,在本例中就是+。返回类型是在使用这个运算符时获得的类型。在本例中,把两个矢量加起来会得到另一个矢量,所以返回类型就是Vector。对于这个+运算符重载,返回类型与包含类一样,但这种情况并不是必需的。两个参数就是要操作的对象。对于二元运算符(带两个参数),如+和-运算符,第一个参数是放在运算符左边的值,第二个参数是放在运算符右边的值。
注意:
一般把运算符左边的参数命名为lhs,运算符右边的的参数命名为rhs。
C#要求所有的运算符重载都声明为public和static,这表示它们与它们的类或结构相关联,而不是与实例相关联,所以运算符重载的代码体不能访问非静态类成员,也不能访问this标识符;这是可以的,因为参数提供了运算符执行任务所需要知道的所有数据。
前面介绍了声明运算符+的语法,下面看看运算符内部的情况:
{ |
这部分代码与声明方法的代码是完全相同的,显然,它返回一个矢量,其中包含前面定义的lhs和rhs的和,即把x、y和z分别相加。
下面需要编写一些简单的代码,测试Vector结构:
static void Main() vect1 = new Vector(3.0, 3.0, 1.0); Console.WriteLine("vect1 = " + vect1.ToString()); |
把这些代码保存为Vectors.cs,编译并运行它,结果如下:
Vectors |
1. 添加更多的重载
矢量除了可以相加之外,还可以相乘、相减,比较它们的值。本节通过添加几个运算符重载,扩展了这个例子。这并不是一个功能全面的真实的Vector类型,但足以说明运算符重载的其他方面了。首先要重载乘法运算符,以支持标量和矢量的相乘以及矢量和矢量的相乘。
矢量乘以标量只是矢量的元素分别与标量相乘,例如,2 * (1.0, 2.5, 2.0)就等于(2.0, 5.0, 4.0)。相关的运算符重载如下所示。
public static Vector operator * (double lhs, Vector rhs) |
但这还不够,如果a和b声明为Vector 类型,就可以编写下面的代码:
b = 2 * a; |
编译器会隐式地把整数2转换为double类型,以匹配运算符重载的签名。但不能编译下面的代码:
b = a * 2; |
编译器处理运算符重载的方式和处理方法重载的方式是一样的。它会查看给定运算符的所有可用重载,找到与之最匹配的那个运算符重载。上面的语句要求第一个参数是Vector,第二个参数是整数,或者可以隐式转换为整数的其他数据类型。我们没有提供这样一个重载。有一个运算符重载,其参数是一个double和一个Vector,但编译器不能改变参数的顺序,所以这是不行的。还需要显式定义一个运算符重载,其参数是一个Vector和一个double,有两种方式可以定义这样一个运算符重载,第一种方式和处理所有运算符的方式一样,显式执行矢量相乘操作:
public static Vector operator * (Vector lhs, double rhs) |
假定已经编写了执行相乘操作的代码,最好重复使用该代码:
public static Vector operator * (Vector lhs, double rhs) |
这段代码会告诉编译器,如果有Vector和double的相乘操作,编译器就使参数的顺序反序,调用另一个运算符重载。在本章的示例代码中,我们使用第二个版本,它看起来比较简洁。利用这个版本可以编写出维护性更好的代码,因为不需要复制代码,就可在两个独立的重载中执行相乘操作。
下一个要重载的运算符是矢量相乘。在数学上,矢量相乘有两种方式,但这里我们感兴趣的是点积或内积,其结果实际上是一个标量。这就是我们介绍这个例子的原因,所以算术运算符不必返回与定义它们的类相同的类型。
在数学上,如果有两个矢量(x, y, z)和(X, Y, Z),其内积就是x*X + y*Y + z*Z的值。两个矢量这样相乘是很奇怪的,但这是很有效的,因为它可以用于计算各种其他的数。当然,如果要使用Direct3D 或DirectDraw编写代码来显示复杂的3D图形,在计算对象放在屏幕上的什么位置时,常常需要编写代码来计算矢量的内积,作为中间步骤。这里我们关心的是使用Vector编写出double X = a*b,其中a和b是矢量,并计算出它们的点积。相关的运算符重载如下所示:
public static double operator * (Vector lhs, Vector rhs) |
定义了算术运算符后,就可以用一个简单的测试方法来看看它们是否能正常运行:
static void Main() vect3 = vect1 + vect2; Console.WriteLine("vect1 = " + vect1); vect3 += vect2; Console.WriteLine("vect3+=vect2 gives " + vect3); vect3 = vect1*2; Console.WriteLine("Setting vect3=vect1*2 gives " + vect3); double dot = vect1*vect3; Console.WriteLine("vect1*vect3 = " + dot); |
运行代码(Vectors2.cs),得到如下所示的结果:
Vectors2 |
这说明,运算符重载会给出正确的结果,但如果仔细看看测试代码,就会惊奇地注意到,实际上我们使用的是没有重载的运算符-- 相加赋值运算符+=:
vect3 += vect2; |
虽然+=一般用作单个运算符,但实际上其操作分为两部分:相加和赋值。与C++不同,C#不允许重载=运算符,但如果重载+运算符,编译器就会自动使用+运算符的重载来执行+=运算符的操作。-=、&=、*=和/=赋值运算符也遵循此规则。
2. 比较运算符的重载
C#中有6个比较运算符,它们分为3对:
● == 和 !=
● > 和 <
● >= 和 <=
C#要求成对重载比较运算符。如果重载了==,也必须重载!=,否则会产生编译错误。另外,比较运算符必须返回bool类型的值。这是它们与算术运算符的根本区别。两个数相加或相减的结果,理论上取决于数的类型。而两个Vector的相乘会得到一个标量。另一个例子是.NET基类System.DateTime,两个DateTime实例相减,得到的结果不是DateTime,而是一个System.TimeSpan实例,但比较运算得到的如果不是bool类型的值,就没有任何意义。
注意:
在重载==和!=时,还应重载从System.Object中继承的Equals()和GetHashCode()方法,否则会产生一个编译警告。原因是Equals()方法应执行与==运算符相同的相等逻辑。
除了这些区别外,重载比较运算符所遵循的规则与算术运算符相同。但比较两个数并不像想象的那么简单,例如,如果比较两个对象引用,就是比较存储对象的内存地址。比较运算符很少进行这样的比较,所以必须编写运算符,比较对象的值,返回相应的布尔结果。下面给Vector结构重载==和!=运算符。首先是==的执行代码:
public static bool operator = = (Vector lhs, Vector rhs) |
这种方式仅根据矢量元素的值,来对它们进行相等比较。对于大多数结构,这就是我们希望的,但在某些情况下,可能需要仔细考虑相等的含义,例如,如果有嵌入的类,是应比较引用是否指向同一个对象(浅度比较),还是应比较对象的值是否相等(深度比较)?
浅度比较是比较对象是否指向内存中的同一个位置,而深度比较是比较对象的值和属性是否相等。应根据具体情况进行相等检查,确定要进行什么比较。
注意:
不要通过调用从System.Object中继承的Equals()方法的实例版本,来重载比较运算符,如果这么做,在objA是null时计算(objA==objB),就会产生一个异常,因为.NET运行库会试图计算null.Equals(objB)。采用其他方法(重写Equals()方法,调用比较运算符)比较安全。
还需要重载运算符!=,采用的方式如下:
public static bool operator != (Vector lhs, Vector rhs) |
像往常一样,用一些测试代码检查重写方法的工作情况,这次定义3个Vector对象,并进行比较:
static void Main() vect1 = new Vector(3.0, 3.0,-10.0); Console.WriteLine("vect1= =vect2 returns " + (vect1= =vect2)); Console.WriteLine(); Console.WriteLine("vect1!=vect2 returns " + (vect1!=vect2)); |
编译这些代码(下载代码中的Vectors3.cs),会得到一个编译器警告,因为我们没有为Vector重写Equals(),对于本例,这是不重要的,所以忽略它。
csc Vectors3.cs Vectors3.cs(5,11): warning CS0660: 'Wrox.ProCSharp.OOCSharp.Vector' defines |
在命令行上运行该示例,生成如下结果:
Vectors3 vect1!=vect2 returns False |
3. 可以重载的运算符
并不是所有的运算符都可以重载。可以重载的运算符如表6-5所示。
表 6-5
类 别 | 运 算 符 | 限 制 |
算术二元运算符 | +, *, /, –, % | 无 |
算术一元运算符 | +, –, ++, –– | 无 |
按位二元运算符 | &, |, ^, <<, >> | 无 |
按位一元运算符 | !, ~, true, false | true和false运算符必须成对重载 |
比较运算符 | ==, !=, >=, <, <=, > | 必须成对重载 |
赋值运算符 | +=,–=,*=,/=,>>=,<<=,%=, &=,|=,^= | 不能显式重载这些运算符,在重写单个运算符如+,–,%等时,它们会被隐式重写 |
索引运算符 | [] | 不能直接重载索引运算符。第2章介绍的索引器成员类型允许在类和结构上支持索引运算符 |
数据类型转换运算符 | () | 不能直接重载数据类型转换运算符。用户定义的数据类型转换(本章的后面介绍)允许定义定制的数据类型转换 |
6.5 用户定义的数据类型转换
本章前面介绍了如何在预定义的数据类型之间转换数值,这是通过数据类型转换过程来完成的。C#允许进行两种不同数据类型的转换:隐式转换和显式转换。
显式转换要在代码中显式标记转换,其方法是在圆括号中写出目标数据类型:
int I = 3; |
对于预定义的数据类型,当数据类型转换可能失败或丢失某些数据时,需要显式转换。例如:
● 把int转换为short时,因为short可能不够大,不能包含转换的数值。
● 把有符号的数据转换为无符号的数据,如果有符号的变量包含一个负值,会得到不正确的结果
● 在把浮点数转换为整数数据类型时,数字的小数部分会丢失。
● 把可空类型转换为非可空类型,null值会导致异常。
此时应在代码中进行显式转换,告诉编译器你知道这会有丢失数据的危险,因此编写代码时要把这种可能性考虑在内。
C#允许定义自己的数据类型(结构和类),这意味着需要某些工具支持在自己的数据类型之间进行类型转换。方法是把数据类型转换定义为相关类的一个成员运算符,数据类型转换必须标记为隐式或显式,以说明如何使用它。我们应遵循与预定义数据类型转换相同的规则,如果知道无论在源变量中存储什么值,数据类型转换总是安全的,就可以把它定义为隐式转换。另一方面,如果某些数值可能会出错,例如丢失数据或抛出异常,就应把数据类型转换定义为显式转换。
提示:
如果源数据值会使数据类型转换失败,或者可能会抛出异常,就应把定制数据类型转换定义为显式转换。
定义数据类型转换的语法类似于本章前面介绍的重载运算符。但它们是不一致的,数据类型转换在某种情况下可以看作是一种运算符,其作用是从源类型转换为目标类型。为了说明这个语法,下面的代码是从本节后面介绍的结构Currency示例中节选的:
public static implicit operator float (Currency value) |
运算符的返回类型定义了数据类型转换操作的目标类型,它有一个参数,即要转换的源对象。这里定义的数据类型转换可以隐式地把Currency的值转换为float型。注意,如果数据类型转换声明为隐式,编译器可以隐式或显式地使用这个转换。如果数据类型转换声明为显式,编译器就只能显式地使用它。与其他运算符重载一样,数据类型转换必须声明为public和static。
注意:
C++开发人员应注意,这种情况与C++是不同的,在C++中,数据类型转换是类的实例成员。
6.5.1 执行用户定义的类型转换
本节将在示例SimpleCurrency(和往常一样,其代码可以下载)中介绍隐式和显式使用用户定义的数据类型转换。在这个示例中,定义一个结构Currency,它包含一个正的USD($)钱款。C#为此提供了decimal类型,但如果要进行比较复杂的财务处理,仍可以编写自己的结构和类来表示钱款,在这样的类上执行特定的方法。
注意:
数据类型转换的语法对于结构和类是一样的。我们的示例定义了一个结构,但如果把Currency声明为类,也是可以的。
首先,结构Currency的定义如下所示。
struct Currency public Currency(uint dollars, ushort cents) public override string ToString() |
Dollars和Cents字段使用无符号的数据类型,可以确保Currency实例只能包含正值。这样限制,是为了在后面说明显式转换的一些要点。可以像这样使用一个类来存储公司员工的薪水信息。人们的薪水不会是负值!为了使类比较简单,我们把字段声明为public,但通常应把它们声明为private,并为Dollars和Cents字段定义相应的属性。
下面先假定要把Currency实例转换为float值,其中float值的整数部分表示美元,换言之,应编写下面的代码:
Currency balance = new Currency(10,50); |
为此,需要定义一个数据类型转换。给Currency定义添加下述代码:
public static implicit operator float (Currency value) |
这个数据类型转换是隐式的。在本例中这是一个合理的选择,因为在Currency定义中,可以存储在Currency中的值也都可以存储在float中。在这个转换中,不应出现任何错误。
注意:
这里有一点欺骗性:实际上,当把uint转换为float时,会有精确度的损失,但Microsoft认为这种错误并不重要,因此把从uint到float的转换都当做隐式转换。
但是,如果把float转换为Currency,就不能保证转换肯定成功了;float可以存储负值,而Currency实例不能,float存储的数值的量级要比Currency的(uint) Dollars字段大得多。所以,如果float包含一个不合适的值,把它转换为Currency就会得到意想不到的结果。因此,从float转换到Currency就应定义为显式转换。下面是我们的第一次尝试,这次不会得到正确的结果,但对解释原因是有帮助的:
public static explicit operator Currency (float value) |
下面的代码可以成功编译:
float amount = 45.63f; |
但是,下面的代码会抛出一个编译错误,因为试图隐式地使用一个显式的数据类型转换:
float amount = 45.63f; |
把数据类型转换声明为显式,就是警告开发人员要小心,因为可能会丢失数据。但这不是我们希望的Currency结构的执行方式。下面编写一个测试程序,运行示例。其中有一个Main()方法,它实例化了一个Currency结构,试图进行几个转换。在这段代码的开头,以两种不同的方式计算balance的值(因为要使用它们来说明后面的内容):
static void Main() Console.WriteLine(balance); Console.WriteLine("After converting to float, = " + balance2); balance = (Currency) balance2; Console.WriteLine("After converting back to Currency, = " + balance); |
注意,所有的代码都放在一个try块中,来捕获在数据类型转换过程中发生的任何异常。在checked块中还添加了把超出范围的值转换为Currency的测试代码,所以,负值是肯定会被捕获的。运行这段代码,得到如下所示的结果:
SimpleCurrency |
这个结果表示代码并没有像我们希望的那样工作。首先,从float转换回Currency得到一个错误的结果$50.34,而不是$50.35。其次,在试图转换明显超出范围的值时,没有生成异常。
第一个问题是由圆整错误引起的。如果类型转换用于把float转换为uint,计算机就会截去多余的数字,而不是圆整它。计算机以二进制方式存储数字,而不是十进制,小数部分0.35不能用二进制小数来精确表示(像1/3这样的分数不能精确表示为小数,它应等于循环小数0.3333)。所以,计算机最后存储了一个略小于0.35的值,它可以用二进制格式精确表示。把该数字乘以100,就会得到一个小于35的数字,截去了34美分。显然在本例中,这种由截去引起的错误是很严重的,避免该错误的方式是确保在数字转换过程中执行智能圆整操作。Microsoft编写了一个类System.Convert来完成该任务。System.Convert包含大量的静态方法来执行各种数字转换,我们需要使用的是Convert.ToUInt16()。注意,在使用System.Convert方法时会产生额外的性能损失,所以只应在需要时才使用它们。
下面看看为什么没有抛出期望的溢出异常。此处的问题是溢出异常实际发生的位置根本不在Main()例程中--它是在转换运算符的代码中发生的,该代码在Main()方法中调用,而且没有标记为checked。
其解决方法是确保类型转换本身也在checked环境下进行。进行了这两个修改后,修订后的转换代码如下所示。
public static explicit operator Currency (float value) |
注意,使用Convert.ToUInt16()计算数字的美分部分,如上所示,但没有使用它计算数字的美元部分。在计算美元值时不需要使用System.Convert,因为在此我们希望截去float值。
注意:
System.Convert的方法还执行它自己的溢出检查。因此对于本例的情况,不需要把对Convert.ToUInt16()的调用放在checked环境下。但把value显式转换为美元值仍需要checked环境。
这里没有给出这个新checked转换的结果,因为在本节后面还要对SimpleCurrency示例进行一些修改。
注意:
如果定义了一个使用非常频繁的数据类型转换,其性能也非常好,就可以不进行任何错误检查,如果对用户定义的转换和没有检查的错误进行了清晰的说明,这也是一种合法的解决方案。
1. 类之间的数据类型转换
Currency示例仅涉及到与float(一种预定义的数据类型)来回转换的类。实际上任何简单数据类型的转换都是可以自定义的。定义不同结构或类之间的数据类型转换是允许的,但有两个限制:
● 如果某个类直接或间接继承了另一个类,就不能定义这两个类之间的数据类型转换(这些类型的类型转换已经存在)。
● 数据类型转换必须在源或目标数据类型的内部定义。
要说明这些要求,假定有如图6-1所示的类层次结构。
换言之,类C和D间接派生于A。在这种情况下,在A、B、C或D之间唯一合法的类型转换就是类C和D之间的转换,因为这些类并没有互相派生。这段代码如下所示(假定希望数据类型转换是显式的,这是在用户定义的数据类型之间转换的通常情况):
public static explicit operator D(C value) |
对于这些数据类型转换,可以选择放置定义的地方-- 在C的类定义内部,或者在D的类定义内部,但不能在其他地方定义。C#要求把数据类型转换的定义放在源类(或结构)或目标类(或结构)的内部。它的边界效应是不能定义两个类之间的数据类型转换,除非可以编辑它们的源代码。这是因为,这样可以防止第三方把数据类型转换引入类中。
一旦在一个类的内部定义了数据类型转换,就不能在另一个类中定义相同的数据类型转换。显然,只能有一个数据类型转换,否则编译器就不知道该选择哪个数据类型转换了。
2. 基类和派生类之间的数据类型转换
要了解这些数据类型转换是如何工作的,首先看看源和目标的数据类型都是引用类型的情况。考虑两个类MyBase 和 MyDerived,其中MyDerived直接或间接派生于MyBase。
首先是从MyDerived 到 MyBase的转换,代码如下(假定可以使用构造函数):
MyDerived derivedObject = new MyDerived(); |
在本例中,是从MyDerived 隐式地转换为 MyBase。这是可行的,因为对类MyBase的任何引用都可以引用类MyBase的对象或派生于MyBase的对象。在OO编程中,派生类的实例实际上是基类的实例,但加上了一些额外的信息。在基类上定义的所有函数和字段也都在派生类上定义了。
下面看看另一种方式,编写下面的代码:
MyBase derivedObject = new MyDerived(); |
上面的代码都是合法的C#代码(从句法的角度来看,是合法的),是把基类转换为派生类。但是,最后的一个语句在执行时会抛出一个异常。在进行数据类型转换时,会检查被引用的对象。因为基类引用实际上可以引用一个派生类实例,所以这个对象可能是要转换的派生类的一个实例。如果是这样,转换就会成功,派生的引用被设置为引用这个对象。但如果该对象不是派生类(或者派生于这个类的其他类)的一个实例,转换就会失败,抛出一个异常。
注意,编译器已经提供了基类和派生类之间的转换,这种转换实际上并没有对对象进行任何数据转换。如果要进行的转换是合法的,它们也仅是把新引用设置为对对象的引用。这些转换在本质上与用户定义的转换不同。例如,在前面的SimpleCurrency示例中,我们定义了Currency结构和float之间的转换。在float到Currency的转换中,则实例化了一个新Currency结构,并用要求的值进行初始化。在基类和派生类之间的预定义转换则不是这样。如果要把MyBase实例转换为MyDerived对象,其值根据MyBase实例的内容来确定,就不能使用数据类型转换语法。最合适的选项通常是定义一个派生类的构造函数,它的参数是一个基类实例,让这个构造函数执行相关的初始化:
class DerivedClass : BaseClass |
3. 装箱和拆箱数据类型转换
前面主要讨论了基类和派生类之间的数据类型转换,其中,基类和派生类都是引用类型。其规则也适用于转换值类型,但在转换值类型时,不是仅仅复制引用,还必须复制一些数据。
当然,不能从结构或基本值类型中派生。所以基本结构和派生结构之间的转换总是基本类型或结构与System.Object之间的转换(理论上可以在结构和System.ValueType之间进行转换,但一般很少这么做)。
从结构(或基本类型)到object的转换总是一种隐式转换,因为这种转换是从派生类型到基本类型的转换,即第2章中简要介绍的装箱过程。例如,Currency结构:
Currency balance = new Currency(40,0); |
在执行上述隐式转换时,balance的内容被复制到堆上,放在一个装箱的对象上,BaseCopy对象引用设置为该对象。在后台发生的情况是:在最初定义Currency结构时,.NET Framework隐式地提供另一个(隐式)类,即装箱的Currency类,它包含与Currency结构相同的所有字段,但却是一个引用类型,存储在堆上。无论这个值类型是一个结构,还是一个枚举,定义它时都存在类似的装箱引用类型,对应于所有的基本值类型,如int、double和 uint。不能也不必在源代码中直接编程访问这些装箱类型,但在把一个值类型转换为object时,它们是在后台工作的对象。在隐式地把Currency 转换为 object时,会实例化一个装箱的 Currency实例,并用Currency结构中的所有数据进行初始化。在上面的代码中,BaseCopy对象引用的就是这个已装箱的Currency实例。通过这种方式,就可以实现从派生类到基类的转换,并且,值类型的语法与引用类型的语法一样。
转换的另一种方式称为拆箱。与在基本引用类型和派生引用类型之间的转换一样,这是一种显式转换,因为如果要转换的对象不是正确的类型,会抛出一个异常:
object derivedObject = new Currency(40,0); |
上述代码的工作方式与前面的引用类型一样。把derivedObject转换为 Currency会成功进行,因为derivedObject实际上引用的是装箱 Currency实例-- 转换的过程是把已装箱的 Currency对象的字段复制到一个新的Currency结构中。第二个转换会失败,因为baseObject没有引用已装箱的 Currency对象。
在使用装箱和拆箱时,这两个过程都把数据复制到新装箱和拆箱的对象上,理解这一点是非常重要的。这样,对装箱对象的操作就不会影响原来值类型的内容。
6.5.2 多重数据类型转换
在定义数据类型转换时必须考虑的一个问题是,如果在进行要求的数据类型转换时,C#编译器没有可用的直接转换方式,C#编译器就会寻找一种方式,把几种转换合并起来。例如,在Currency结构中,假定编译器遇到下面的代码:
Currency balance = new Currency(10,50); |
首先初始化一个Currency实例,再把它转换为一个long。问题是不能定义这样的转换。但是,这段代码仍可以编译成功。因为编译器知道我们要定义一个从Currency到float的隐式转换,而且它知道如何显式地从float 转换为long。所以它会把这行代码编译为中间语言代码,首先把balance转换为float,再把结果转换为long。上述代码的最后一行也是这样,把balance转换为double型时,因为从Currency到 float的转换和从float 到double的转换都是隐式的,就可以在代码中把这个转换当作一种隐式转换。如果要显式地指定转换过程,可以编写如下代码:
Currency balance = new Currency(10,50); |
但是,在大多数情况下,这会使代码变得比较复杂,因此是不必要的。下面的代码会产生一个编译错误:
Currency balance = new Currency(10,50); |
原因是编译器可以找到的最佳匹配的转换仍是首先转换为flost,再转换为long,但从float到long的转换需要显式指定。
所有这些都不会带来太多的麻烦。转换的规则是非常直观的,主要是为了防止在开发人员不知情的情况下丢失数据。但是,在定义数据类型转换时如果不小心,编译器就有可能指定一条导致不期望的结果的路径。例如,假定编写Currency结构的其他小组成员要把一个uint转换为Currency,而该uint中包含了美分的总数(美分不是美元,因为我们不希望丢掉美元的小数部分),为此应编写如下代码:
public static implicit operator Currency (uint value) |
注意,在这段代码中,第一个100后面的u可以确保把value/100u解释为uint。如果写成value/100,编译器就会把它解释为一个int型的值,而不是uint型的值。
在这段代码中清楚地注释了"不要这么做"。下面说明其原因。看看下面的代码段,它把包含350的uint转换为一个Currency,再转换回uint。那么在执行完这段代码后,bal2中又将包含什么?
uint bal = 350; |
答案不是350,而是3!这是符合逻辑的。我们把350隐式地转换为Currency,得到balance.Dollars=3,balance.Cents=50。然后编译器进行通常的操作,为转换回uint指定最佳路径。balance最终会被隐式地转换为float型(其值为3.5),然后显式地转换为uint型,其值为3。
当然,转换为另一个数据类型后,再转换回来有时会丢失数据。例如,把包含5.8的float转换为int,再转换回float,会丢失数字中的小数部分,得到5,但丢失数字中的小数部分和一个整数被100整除的情况略有区别。Currency现在成了一种相当危险的类,它会对整数进行一些奇怪的操作。
问题是,在转换过程中如何解释整数是有矛盾的。从Currency到float的转换会把整数1解释为1美元,但从uint到Currency的转换会把这个整数解释为1美分,这是很糟糕的。如果希望类易于使用,就应确保所有的转换都按一种互相兼容的方式执行,即这些转换应得到相同的结果。在本例中,显然要重新编写从uint到Currency的转换,把整数值1解释为1美元:
public static implicit operator Currency (uint value) |
偶尔也会觉得这种新的转换方式可能根本不需要。但实际上这种转换方式是非常有用的。没有它,编译器在执行从uint到Currency的转换时,就只能通过float来进行。此时直接转换的效率要高得多,所以进行这种额外转换会提高性能,但需要确保它的结果与通过float进行转换得到的结果相同。在其他情况下,也可以为不同的预定义数据类型分别定义转换,让更多的转换隐式执行,而不是显式地执行,但本例不是这样。
测试这种转换是否成功,应确定无论使用什么转换路径,它都能得到相同的结果(而不是像在从float到int的转换过程中丢失数据那样)。Currency类就是一个很好的示例。看看下面的代码:
Currency balance = new Currency(50, 35); |
目前,编译器只能采用一种方式来执行这个转换:把Currency隐式地转换为float,再显式地转换为ulong。从float到ulong的转换需要显式指定,本例就显式指定了这个转换,所以编译是成功的。
但假定要添加另一个转换,从Currency隐式地转换为uint,就需要修改Currency结构,添加从uint到Currency的转换和从Currency到uint的转换,这段代码可以下载,作为SimpleCurrency2示例:
public static implicit operator Currency (uint value) public static implicit operator uint (Currency value) |
现在,编译器从Currency转换到 ulong可以使用另一条路径:先从Currency隐式地转换为uint,再隐式地转换为ulong。该采用哪条路径? C#有一些规则(本书不详细讨论这些规则,读者可参阅MSDN文档说明),告诉编译器如何确定哪条是最佳路径。但最好自己设计转换,让所有的转换都得到相同的结果(但没有精确度的损失),此时编译器选择哪条路径就不重要了(在本例中,编译器会选择Currency→uint→ulong路径,而不是Currency→float→ulong路径)。
为了测试SimpleCurrency2示例,给SimpleCurrency的测试程序添加如下代码:
try Console.WriteLine(balance); uint balance3 = (uint) balance; |
运行这个示例,得到如下所示的结果:
SimpleCurrency2 |
这个结果显示了到uint的转换是成功的,但丢失了Currency的美分部分(小数部分),把负的float 转换为 Currency也产生了预料中的溢出异常,因为float到Currency的转换本身定义了一个checked环境。
但是,这个输出结果也说明了进行转换时最后一个要注意的潜在问题:结果的第一行没有正确显示结余,显示了50,而不是$50.35。在下面的代码中:
Console.WriteLine(balance); |
只有最后两行把Currency正确显示为一个字符串。这是为什么?问题是在把转换和方法重载合并起来时,会出现另一个不希望的错误源。下面用倒序的方式解释这段代码。
第三行的Console.WriteLine()语句显式调用Currency.ToString()方法,以确保Currency显示为一个字符串。第二行代码没有这么做。字符串"balance is "传送给Console.WriteLine(),告诉编译器这个参数应解释为字符串,因此要隐式地调用Currency.ToString()方法。
但第一行的Console.WriteLine()方法只是把原来的Currency结构传送给Console.Write Line()。目前Console.WriteLine()有许多重载,但它们的参数都不是Currency结构。所以编译器会到处搜索,看看它能把Currency转换为什么,以与Console.WriteLine()的一个重载方法匹配。如上所示,Console.WriteLine()的一个重载方法可以快速而高效地显示uint,且其参数是一个uint。因此应把Currency隐式地转换为uint。
实际上,Console.WriteLine()有另一个重载方法,它的参数是一个double,结果是显示该double的值。如果仔细看看第一个SimpleCurrency示例的结果,就会发现该结果的第一行就是使用这个重载方法把Currency显示为一个double。在这个示例中,没有直接把Currency转换为uint,所以编译器选择Currency→float→double作为可用于Console.WriteLine()重载方法的首选转换方式。但在SimpleCurrency2中可以直接转换为uint,所以编译器会选择后者。
如果方法调用带有多个重载方法,并要给该方法传送参数,而该参数的数据类型不匹配任何重载方法,就可以迫使编译器确定使用哪些转换方式进行数据转换,决定使用哪个重载方法(并进行相应的数据转换)。当然,编译器总是按逻辑和严格的规则来工作,但结果可能并不是我们所期望的。如果可能会出问题,最好显式指定转换路径。
6.6 小结
本章介绍了C#提供的标准运算符,描述了对象的相等机制,讨论了编译器如何把一种标准数据类型转换为另一种标准数据类型。还阐述了如何使用运算符重载在自己的数据类型上执行定制的运算符。最后,学习了运算符重载的一种特殊类型,即数据类型转换运算符,它允许用户指定如何将定制类型的实例转换为其他数据类型。
第7章将介绍两个密切相关的成员类型:委托和事件,在自己的类型上也可以实现这两个成员类型,以支持基于事件的对象模型。