本章前面介绍了如何在预定义的数据类型之间转换数值,这是通过数据类型转换过程来完成的。C#允许进行两种不同数据类型的转换:隐式转换和显式转换。
显式转换要在代码中显式标记转换,其方法是在圆括号中写出目标数据类型:
int I = 3;
long l = I; // implicit
short s = (short)I; // explicit
对于预定义的数据类型,当数据类型转换可能失败或丢失某些数据时,需要显式转换。 例如:
把int转换为short时,因为short可能不够大,不能包含转换的数值。
把有符号的数据转换为无符号的数据,如果有符号的变量包含一个负值,会得到不正确的结果
在把浮点数转换为整数数据类型时,数字的小数部分会丢失。
把可空类型转换为非可空类型,null值会导致异常。
此时应在代码中进行显式转换,告诉编译器你知道这会有丢失数据的危险,因此编写代码时要把这种可能性考虑在内。
C#允许定义自己的数据类型(结构和类),这意味着需要某些工具支持在自己的数据类型之间进行类型转换。方法是把数据类型转换定义为相关类的一个成员运算符,数据类型转换必须标记为隐式或显式,以说明如何使用它。我们应遵循与预定义数据类型转换相同的规则,如果知道无论在源变量中存储什么值,数据类型转换总是安全的,就可以把它定义为隐式转换。另一方面,如果某些数值可能会出错,例如丢失数据或抛出异常,就应把数据类型转换定义为显式转换。
提示:
如果源数据值会使数据类型转换失败,或者可能会抛出异常,就应把定制数据类型转换定义为显式转换。
定义数据类型转换的语法类似于本章前面介绍的重载运算符。但它们是不一致的,数据类型转换在某种情况下可以看作是一种运算符,其作用是从源类型转换为目标类型。为了说明这个语法,下面的代码是从本节后面介绍的结构Currency示例中节选的:
public static implicit operator float (Currency value)
{
// processing
}
运算符的返回类型定义了数据类型转换操作的目标类型,它有一个参数,即要转换的源对象。这里定义的数据类型转换可以隐式地把Currency的值转换为float型。注意,如果数据类型转换声明为隐式,编译器可以隐式或显式地使用这个转换。如果数据类型转换声明为显式,编译器就只能显式地使用它。与其他运算符重载一样,数据类型转换必须声明为public和static。
注意:
C++开发人员应注意,这种情况与C++是不同的,在C++中,数据类型转换是类的实例成员。
本节将在示例SimpleCurrency(和往常一样,其代码可以下载)中介绍隐式和显式使用用户定义的数据类型转换。在这个示例中,定义一个结构Currency,它包含一个正的USD($)钱款。C#为此提供了decimal类型,但如果要进行比较复杂的财务处理,仍可以编写自己的结构和类来表示钱款,在这样的类上执行特定的方法。
注意:
数据类型转换的语法对于结构和类是一样的。我们的示例定义了一个结构,但如果把Currency声明为类,也是可以的。
首先,结构Currency的定义如下所示。
struct Currency
{
public uint Dollars;
public ushort Cents;
public Currency(uint dollars, ushort cents)
{
this.Dollars = dollars;
this.Cents = cents;
}
public override string ToString()
{
return string.Format("${0}.{1,–2:00}", Dollars,Cents);
}
Dollars和Cents字段使用无符号的数据类型,可以确保Currency实例只能包含正值。这样限制,是为了在后面说明显式转换的一些要点。可以像这样使用一个类来存储公司员工的薪水信息。人们的薪水不会是负值!为了使类比较简单,我们把字段声明为public,但通常应把它们声明为private,并为Dollars和Cents字段定义相应的属性。
下面先假定要把Currency实例转换为float值,其中float值的整数部分表示美元,换言之,应编写下面的代码:
Currency balance = new Currency(10,50);
float f = balance; // We want f to be set to 10.5
为此,需要定义一个数据类型转换。给Currency定义添加下述代码:
public static implicit operator float (Currency value)
{
return value.Dollars + (value.Cents/100.0f);
}
这个数据类型转换是隐式的。在本例中这是一个合理的选择,因为在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)
{
uint dollars = (uint)value;
ushort cents = (ushort)((value–dollars)*100);
return new Currency(dollars, cents);
}
下面的代码可以成功编译:
float amount = 46.63f;
Currency amount2 = (Currency)amount;
但是,下面的代码会抛出一个编译错误,因为试图隐式地使用一个显式的数据类型转换:
float amount = 46.63f;
Currency amount2 = amount; // wrong
把数据类型转换声明为显式,就是警告开发人员要小心,因为可能会丢失数据。但这不是我们希望的Currency结构的执行方式。下面编写一个测试程序,运行示例。其中有一个Main()方法,它实例化了一个Currency结构,试图进行几个转换。在这段代码的开头,以两种不同的方式计算balance的值(因为要使用它们来说明后面的内容):
static void Main()
{
try
{
Currency balance = new Currency(50,35);
Console.WriteLine(balance);
Console.WriteLine("balance is " + balance);
Console.WriteLine("balance is (using ToString()) " + balance.ToString());
float balance2= balance;
Console.WriteLine("After converting to float, = " + balance2);
balance = (Currency) balance2;
Console.WriteLine("After converting back to Currency, = " + balance);
Console.WriteLine("Now attempt to convert out of range value of " +
"–$100.00 to a Currency:");
checked
{
balance = (Currency) (–50.5);
Console.WriteLine("Result is " + balance.ToString());
}
}
catch(Exception e)
{
Console.WriteLine("Exception occurred: " + e.Message);
}
}
注意,所有的代码都放在一个try块中,来捕获在数据类型转换过程中发生的任何异常。在checked块中还添加了把超出范围的值转换为Currency的测试代码,所以,负值是肯定会被捕获的。运行这段代码,得到如下所示的结果:
SimpleCurrency
50.35
Balance is $50.35
Balance is (using ToString()) $50.35
After converting to float, = 50.35
After converting back to Currency, = $50.34
Now attempt to convert out of range value of–$100.00 to a Currency:
Result is $4294967246.60486
这个结果表示代码并没有像我们希望的那样工作。首先,从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)
{
checked
{
uint dollars = (uint)value;
ushort cents = Convert.ToUInt16((value–dollars)*100);
return new Currency(dollars, cents);
}
}
注意,使用Convert.ToUInt16()计算小数,如上所示,但没有使用它计算数字的美元部分。在计算美元值时不需要使用System.Convert,因为在此我们希望截去float值。
注意:
System.Convert的方法还执行它自己的溢出检查。因此对于本例的情况,不需要把对Convert.ToUInt16()的调用放在checked环境下。但把value显式转换为美元值仍需要checked环境。
这里没有给出这个新checked转换的结果,因为在本节后面还要对SimpleCurrency示例进行一些修改。
注意:
如果定义了一个使用非常频繁的数据类型转换,其性能也非常好,就可以不进行任何错误检查,如果对用户定义的转换和没有检查的错误进行了清晰的说明,这也是一种合法的解决方案。
Currency示例仅涉及到与float(一种预定义的数据类型)来回转换的类。实际上任何简单数据类型的转换都是可以自定义的。定义不同结构或类之间的数据类型转换是允许的,但有两个限制:
如果某个类直接或间接继承了另一个类,就不能定义这两个类之间的数据类型转换(这些类型的类型转换已经存在)。
数据类型转换必须在源或目标数据类型的内部定义。
要说明这些要求,假定有如图6-1所示的类层次结构。

图 6-1
换言之,类C和D间接派生于A。在这种情况下,在A、B、C或D之间唯一合法的类型转换就是类C和D之间的转换,因为这些类并没有互相派生。这段代码如下所示(假定希望数据类型转换是显式的,这是在用户定义的数据类型之间转换的通常情况):
public static explicit operator D(C value)
{
// and so on
}
public static explicit operator C(D value)
{
// and so on
}
对于这些数据类型转换,可以选择放置定义的地方—— 在C的类定义内部,或者在D的类定义内部,但不能在其他地方定义。C#要求把数据类型转换的定义放在源类(或结构)或目标类(或结构)的内部。它的边界效应是不能定义两个类之间的数据类型转换,除非可以编辑它们的源代码。这是因为,这样可以防止第三方把数据类型转换引入类中。
一旦在一个类的内部定义了数据类型转换,就不能在另一个类中定义相同的数据类型转换。显然,只能有一个数据类型转换,否则编译器就不知道该选择哪个数据类型转换了。
要了解这些数据类型转换是如何工作的,首先看看源和目标的数据类型都是引用类型的情况。考虑两个类MyBase 和 MyDerived,其中MyDerived直接或间接派生于MyBase。
首先是从MyDerived 到 MyBase的转换,代码如下(假定可以使用构造函数):
MyDerived derivedObject = new MyDerived();
MyBase baseCopy = derivedObject;
在本例中,是从MyDerived 隐式地转换为 MyBase。这是因为对类MyBase的任何引用都可以引用类MyBase的对象或派生于MyBase的对象。在OO编程中,派生类的实例实际上是基类的实例,但加上了一些额外的信息。在基类上定义的所有函数和字段也都在派生类上定义了。
下面看看另一种方式,编写下面的代码:
MyBase derivedObject = new MyDerived();
MyBase baseObject = new MyBase();
MyDerived derivedCopy1 = (MyDerived) derivedObject; // OK
MyDerived derivedCopy2 = (MyDerived) baseObject; // Throws exception
上面的代码都是合法的C#代码(从句法的角度来看,是合法的),是把基类转换为派生类。但是,最后的一个语句在执行时会抛出一个异常。在进行数据类型转换时,会检查被引用的对象。因为基类引用实际上可以引用一个派生类实例,所以这个对象可能是要转换的派生类的一个实例。如果是这样,转换就会成功,派生的引用被设置为引用这个对象。但如果该对象不是派生类(或者派生于这个类的其他类)的一个实例,转换就会失败,抛出一个异常。
注意,编译器已经提供了基类和派生类之间的转换,这种转换实际上并没有对对象进行任何数据转换。如果要进行的转换是合法的,它们也仅是把新引用设置为对对象的引用。这些转换在本质上与用户定义的转换不同。例如,在前面的SimpleCurrency示例中,我们定义了Currency结构和float之间的转换。在float到Currency的转换中,则实例化了一个新Currency结构,并用要求的值进行初始化。在基类和派生类之间的预定义转换则不是这样。如果要把MyBase实例转换为MyDerived对象,其值根据MyBase实例的内容来确定,就不能使用数据类型转换语法。最合适的选项通常是定义一个派生类的构造函数,它的参数是一个基类实例,让这个构造函数执行相关的初始化:
class DerivedClass : BaseClass
{
public DerivedClass(BaseClass rhs)
{
// initialize object from the Base instance
}
// etc.
前面主要讨论了基类和派生类之间的数据类型转换,其中,基类和派生类都是引用类型。其规则也适用于转换值类型,但在转换值类型时,不是仅仅复制引用,还必须复制一些数据。
当然,不能从结构或基本值类型中派生。所以基本结构和派生结构之间的转换总是基本类型或结构与System.Object之间的转换(理论上可以在结构和System.ValueType之间进行转换,但一般很少这么做)。
从结构(或基本类型)到object的转换总是一种隐式转换,因为这种转换是从派生类型到基本类型的转换,即第2章中简要介绍的装箱过程。例如,Currency结构:
Currency balance = new Currency(40,0);
object baseCopy = balance;
在执行上述隐式转换时,balance的内容被复制到堆上,放在一个装箱的对象上,BaseCopy对象引用设置为该对象。在后台发生的情况是:在最初定义Currency结构时,.NET Framework隐式地提供另一个(隐式)类,即装箱的Currency类,它包含与Currency结构相同的所有字段,但却是一个引用类型,存储在堆上。无论这个值类型是一个结构,还是一个枚举,定义它时都存在类似的装箱引用类型,对应于所有的基本值类型,如int、double和 uint。不能也不必在源代码中直接编程访问这些装箱类型,但在把一个值类型转换为object时,它们是在后台工作的对象。在隐式地把Currency 转换为 object时,会实例化一个装箱的 Currency实例,并用Currency结构中的所有数据进行初始化。在上面的代码中,BaseCopy对象引用的就是这个已装箱的Currency实例。通过这种方式,就可以实现从派生类到基类的转换,并且,值类型的语法与引用类型的语法一样。
转换的另一种方式称为拆箱。与在基本引用类型和派生引用类型之间的转换一样,这是一种显式转换,因为如果要转换的对象不是正确的类型,会抛出一个异常:
object derivedObject = new Currency(40,0);
object baseObject = new object();
Currency derivedCopy1 = (Currency)derivedObject; // OK
Currency derivedCopy2 = (Currency)baseObject; // Exception thrown
上述代码的工作方式与前面的引用类型一样。把derivedObject转换为 Currency会成功进行,因为derivedObject实际上引用的是装箱 Currency实例—— 转换的过程是把已装箱的 Currency对象的字段复制到一个新的Currency结构中。第二个转换会失败,因为baseObject没有引用已装箱的 Currency对象。
在使用装箱和拆箱时,这两个过程都把数据复制到新装箱和拆箱的对象上,理解这一点是非常重要的。这样,对装箱对象的操作就不会影响原来值类型的内容。
在定义数据类型转换时必须考虑的一个问题是,如果在进行要求的数据类型转换时,C#编译器没有可用的直接转换方式,C#编译器就会寻找一种方式,把几种转换合并起来。例如,在Currency结构中,假定编译器遇到下面的代码:
Currency balance = new Currency(10,50);
long amount = (long)balance;
double amountD = balance;
首先初始化一个Currency实例,再把它转换为一个long。问题是不能定义这样的转换。但是,这段代码仍可以编译成功。因为编译器知道我们要定义一个从Currency到float的隐式转换,而且它知道如何显式地从float 转换为long。所以它会把这行代码编译为中间语言代码,首先把balance转换为float,再把结果转换为long。上述代码的最后一行也是这样,把balance转换为double型时,因为从Currency到 float的转换和从float 到double的转换都是隐式的,就可以在代码中把这个转换当作一种隐式转换。如果要显式地指定转换过程,可以编写如下代码:
Currency balance = new Currency(10,50);
long amount = (long)(float)balance;
double amountD = (double)(float)balance;
但是,在大多数情况下,这会使代码变得比较复杂,因此是不必要的。下面的代码会产生一个编译错误:
Currency balance = new Currency(10,50);
long amount = balance;
原因是编译器可以找到的最佳匹配的转换仍是首先转换为flost,再转换为long,但从float到long的转换需要显式指定。
所有这些都不会带来太多的麻烦。转换的规则是非常直观的,主要是为了防止在开发人员不知情的情况下丢失数据。但是,在定义数据类型转换时如果不小心,编译器就有可能指定一条导致不期望的结果的路径。例如,假定编写Currency结构的其他小组成员要把一个uint转换为Currency,而该uint中包含了美分的总数(美分不是美元,因为我们不希望丢掉美元的小数部分),为此应编写如下代码:
public static implicit operator Currency (uint value)
{
return new Currency(value/100u, (ushort)(value%100));
} // Don't do this!
注意,在这段代码中,第一个100后面的u可以确保把value/100u解释为uint。如果写成value/100,编译器就会把它解释为一个int型的值,而不是uint型的值。
在这段代码中清楚地注释了“不要这么做”。下面说明其原因。看看下面的代码段,它把包含350的uint转换为一个Currency,再转换回uint。那么在执行完这段代码后,bal2中又将包含什么?
uint bal = 350;
Currency balance = bal;
uint bal2 = (uint)balance;
答案不是350,而是3!这是符合逻辑的。我们把350隐式地转换为Currency,得到balance.Dollars=3,balance.Cents=50。然后编译器进行通常的操作,为转换回uint指定最佳路径。balance最终会被隐式地转换为float型(其值为3.5),然后显式地转换为uint型,其值为3。
当然,转换为另一个数据类型后,再转换回来有时会丢失数据。例如,把包含6.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)
{
return new Currency(value, 0);
}
偶尔也会觉得这种新的转换方式可能根本不需要。但实际上这种转换方式是非常有用的。没有它,编译器在执行从uint到Currency的转换时,就只能通过float来进行。此时直接转换的效率要高得多,所以进行这种额外转换会提高性能,但需要确保它的结果与通过float进行转换得到的结果相同。在其他情况下,也可以为不同的预定义数据类型分别定义转换,让更多的转换隐式执行,而不是显式地执行,但本例不是这样。
测试这种转换是否成功,应确定无论使用什么转换路径,它都能得到相同的结果(而不是像在从float到int的转换过程中丢失数据那样)。Currency类就是一个很好的示例。下面的代码:
Currency balance = new Currency(50, 35);
ulong bal = (ulong) balance;
目前,编译器只能采用一种方式来执行这个转换:把Currency隐式地转换为float,再显式地转换为ulong。从float到ulong的转换需要显式指定,本例就显式指定了这个转换,所以编译是成功的。
但假定要添加另一个转换,从Currency隐式地转换为uint,就需要修改Currency结构,添加从uint到Currency的转换和从Currency到uint的转换,这段代码可以下载,作为SimpleCurrency2示例:
public static implicit operator Currency (uint value)
{
return new Currency(value, 0);
}
public static implicit operator uint (Currency value)
{
return value.Dollars;
}
现在,编译器从Currency转换到 ulong可以使用另一条路径:先从Currency隐式地转换为uint,再隐式地转换为ulong。该采用哪条路径? C#有一些规则(本书不详细讨论这些规则,读者可参阅MSDN文档说明),告诉编译器如何确定哪条是最佳路径。但最好自己设计转换,让所有的转换都得到相同的结果(但没有精确度的损失),此时编译器选择哪条路径就不重要了(在本例中,编译器会选择Currency→uint→ulong路径,而不是Currency→float→ulong路径)。
为了测试SimpleCurrency2示例,给SimpleCurrency的测试程序添加如下代码:
try
{
Currency balance = new Currency(50,35);
Console.WriteLine(balance);
Console.WriteLine("balance is " + balance);
Console.WriteLine("balance is (using ToString()) " + balance.ToString());
uint balance3 = (uint) balance;
Console.WriteLine("Converting to uint gives " + balance3);
运行这个示例,得到如下所示的结果:
SimpleCurrency2
50
balance is $50.35
balance is (using ToString()) $50.35
Converting to uint gives 50
After converting to float, = 50.35
After converting back to Currency, = $50.34
Now attempt to convert out of range value of–$100.00 to a Currency:
Exception occurred: Arithmetic operation resulted in an overflow.
这个结果显示了到uint的转换是成功的,但丢失了Currency的美分部分(小数部分),把负的float 转换为 Currency也产生了预料中的溢出异常,因为float到Currency的转换本身定义了一个checked环境。
但是,这个输出结果也说明了进行转换时最后一个要注意的潜在问题:结果的第一行没有正确显示结余,显示了50,而不是$50.35。在下面的代码中:
Console.WriteLine(balance);
Console.WriteLine("balance is " + balance);
Console.WriteLine("balance is (using ToString()) " + balance.ToString());
只有最后两行把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,所以编译器会选择后者。
如果方法调用带有多个重载方法,并要给该方法传送参数,而该参数的数据类型不匹配任何重载方法,就可以迫使编译器确定使用哪些转换方式进行数据转换,决定使用哪个重载方法(并进行相应的数据转换)。当然,编译器总是按逻辑和严格的规则来工作,但结果可能并不是我们所期望的。如果可能会出问题,最好显式指定转换路径。