8.7 实现用户定义的类型强制转换
本章前面(见8.3.1节中关于显式转换的部分)介绍了如何在预定义的数据类型之间转换数值,这通过类型强制转换过程来完成。C#允许进行两种不同类型的强制转换:隐式强制转换和显式强制转换。本节将讨论这两种类型的强制转换。
显式强制转换要在代码中显式地标记强制转换,即应该在圆括号中写出目标数据类型:
int i = 3; long l = i; // implicit short s = (short)i; // explicit
对于预定义的数据类型,当类型强制转换可能失败或丢失某些数据时,需要显式强制转换。例如:
● 把int转换为short时,short可能不够大,不能包含对应int的数值。
● 把有符号的数据类型转换为无符号的数据类型时,如果有符号的变量包含一个负值,就会得到不正确的结果。
● 把浮点数转换为整数数据类型时,数字的小数部分会丢失。
● 把可空类型转换为非可空类型时,null值会导致异常。
此时应在代码中进行显式强制转换,告诉编译器你知道存在丢失数据的危险,因此编写代码时要把这种可能性考虑在内。
C#允许定义自己的数据类型(结构和类),这意味着需要某些工具支持在自定义的数据类型之间进行类型强制转换。方法是把类型强制转换运算符定义为相关类的一个成员运算符。类型强制转换运算符必须标记为隐式或显式,以说明希望如何使用它。我们应遵循与预定义的类型强制转换相同的指导原则;如果知道无论在源变量中存储什么值,类型强制转换总是安全的,就可以把它定义为隐式强制转换。然而,如果某些数值可能会出错,如丢失数据或抛出异常,就应把数据类型转换定义为显式强制转换。
注意:如果源数据值会使类型强制转换失败,或者可能会抛出异常,就应把任何自定义类型强制转换定义为显式强制转换。
定义类型强制转换的语法类似于本章前面介绍的重载运算符。这并不是偶然现象,类型强制转换在某种情况下可以看作是一种运算符,其作用是从源类型转换为目标类型。为了说明这种语法,下面的代码从本节后面介绍的结构Currency示例中节选而来:
public static implicit operator float (Currency value) { // processing }
运算符的返回类型定义了类型强制转换操作的目标类型,它有一个参数,即要转换的源对象。这里定义的类型强制转换可以隐式地把Currency型的值转换为float型。注意,如果数据类型转换声明为隐式,编译器就可以隐式或显式地使用这个转换。如果数据类型转换声明为显式,编译器就只能显式地使用它。与其他运算符重载一样,类型强制转换必须同时声明为public和static。
注意:C++开发人员应注意,这种情况与C++中的用法不同,在C++中,类型强制转换用于类的实例成员。
8.7.1 实现用户定义的类型强制转换
本节将在示例SimpleCurrency中介绍隐式和显式的用户定义类型强制转换用法。在这个示例中,定义一个结构Currency,它包含一个正的USD($)金额。C#为此提供了decimal类型,但如果要进行比较复杂的财务处理,仍可以编写自己的结构和类来表示相应的金额,在这样的类上实现特定的方法。
注意:类型强制转换的语法对于结构和类是一样的。本示例定义了一个结构,但把Currency声明为类也是可行的。
首先,Currency结构的定义如下所示(代码文件CastingSample/Currency.cs):
public struct Currency { public uint Dollars { get; } public ushort Cents { get; } public Currency(uint dollars, ushort cents) { Dollars = dollars; Cents = cents; } public override string ToString() => $"${Dollars}.{Cents, -2:00}"; }
Dollars和Cents属性使用无符号的数据类型,可以确保Currency实例只能包含正值。采用这样的限制是为了在后面说明显式强制转换的一些要点。可以像这样使用一个类来存储公司员工的薪水信息。员工的薪水不会是负值!
下面先假定要把Currency实例转换为float值,其中float值的整数部分表示美元。换言之,应编写下面的代码:
var 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) => 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 = 45.63f; Currency amount2 = (Currency)amount;
但是,下面的代码会抛出一个编译错误,因为它试图隐式地使用一个显式的类型强制转换:
float amount = 45.63f; Currency amount2 = amount; // wrong
把类型强制转换声明为显式,就是警告开发人员要小心,因为可能会丢失数据。但这不是我们希望的Currency结构的行为方式。下面编写一个测试程序,并运行该示例。其中有一个Main()方法,它实例化一个Currency结构,并试图进行几次转换。在这段代码的开头,以两种不同的方式计算balance的值,因为要使用它们来说明后面的内容(代码文件CastingSample/Program.cs):
static void Main() { try { var balance = new Currency(50,35); WriteLine(balance); WriteLine($"balance is {balance}"); // implicitly invokes ToString float balance2= balance; WriteLine($"After converting to float, = {balance2}"); balance = (Currency) balance2; WriteLine($"After converting back to Currency, = {balance}"); WriteLine("Now attempt to convert out of range value of " + "-$50.50 to a Currency:"); checked { balance = (Currency) (-50.50); WriteLine($"Result is {balance}"); } } catch(Exception e) { WriteLine($"Exception occurred: {e.Message}"); } }
注意,所有的代码都放在一个try块中,以捕获在类型强制转换过程中发生的任何异常。在checked块中还添加了把超出范围的值转换为Currency的测试代码,以试图捕获负值。运行这段代码,得到如下所示的结果:
50.35 Balance is $50.35 After converting to float, = 50.35 After converting back to Currency, = $50.34 Now attempt to convert out of range value of -$50.50 to a Currency: Result is $4294967246.00
这个结果表示代码并没有像我们希望的那样工作。首先,从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示例进行一些修改。
注意:如果定义了一种使用非常频繁的类型强制转换,其性能也非常好,就可以不进行任何错误检查。如果对用户定义的类型强制转换和没有检查的错误进行了清晰的说明,这也是一种合理的解决方案。
1.类之间的类型强制转换
Currency示例仅涉及与float(一种预定义的数据类型)来回转换的类。但类型转换不一定会涉及任何简单的数据类型。定义不同结构或类的实例之间的类型强制转换是完全合法的,但有两点限制:
● 如果某个类派生自另一个类,就不能定义这两个类之间的类型强制转换(这些类型的强制转换已经存在)。
● 类型强制转换必须在源数据类型或目标数据类型的内部定义。
为说明这些要求,假定有如图8-1所示的类层次结构。
图8-1
换言之,类C和D间接派生于A。在这种情况下,在A、B、C或D之间唯一合法的自定义类型强制转换就是类C和D之间的转换,因为这些类并没有互相派生。对应的代码如下所示(假定希望类型强制转换是显式的,这是在用户定义的类之间定义类型强制转换的通常情况):
public static explicit operator D(C value) { //... } public static explicit operator C(D value) { //... }
对于这些类型强制转换,可以选择放置定义的地方——在C的类定义内部,或者在D的类定义内部,但不能在其他地方定义。C#要求把类型强制转换的定义放在源类(或结构)或目标类(或结构)的内部。这一要求的副作用是不能定义两个类之间的类型强制转换,除非至少可以编辑其中一个类的源代码。这是因为,这样可以防止第三方把类型强制转换引入类中。
一旦在一个类的内部定义了类型强制转换,就不能在另一个类中定义相同的类型强制转换。显然,对于每一种转换只能有一种类型强制转换,否则编译器就不知道该选择哪个类型强制转换了。
2.基类和派生类之间的类型强制转换
要了解这些类型强制转换是如何工作的,首先看看源和目标数据类型都是引用类型的情况。考虑两个类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 base) { // initialize object from the Base instance } // etc.
3.装箱和拆箱类型强制转换
前面主要讨论了基类和派生类之间的类型强制转换,其中,基类和派生类都是引用类型。类似的原则也适用于强制转换值类型,尽管在转换值类型时,不可能仅仅复制引用,还必须复制一些数据。
当然,不能从结构或基本值类型中派生。所以基本结构和派生结构之间的强制转换总是基本类型或结构与System.Object之间的转换(理论上可以在结构和System.ValueType之间进行强制转换,但一般很少这么做)。
从结构(或基本类型)到object的强制转换总是一种隐式的强制转换,因为这种强制转换是从派生类型到基本类型的转换,即第2章简要介绍的装箱过程。例如,使用Currency结构:
var 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对象。
在使用装箱和拆箱时,这两个过程都把数据复制到新装箱或拆箱的对象上,理解这一点非常重要。这样,对装箱对象的操作就不会影响原始值类型的内容。
8.7.2 多重类型强制转换
在定义类型强制转换时必须考虑的一个问题是,如果在进行要求的数据类型转换时没有可用的直接强制转换方式,C#编译器就会寻找一种转换方式,把几种强制转换合并起来。例如,在Currency结构中,假定编译器遇到下面几行代码:
var balance = new Currency(10,50); long amount = (long)balance; double amountD = balance;
首先初始化一个Currency实例,再把它转换为long型。问题是没有定义这样的强制转换。但是,这段代码仍可以编译成功。因为编译器知道我们已经定义一个从Currency到float的隐式强制转换,而且它知道如何显式地从float强制转换为long。所以它会把这行代码编译为中间语言(IL)代码,IL代码首先把balance转换为float型,再把结果转换为long型。把balance转换为double型时,在上述代码的最后一行中也执行了同样的操作。因为从Currency到float的强制转换和从float到double的预定义强制转换都是隐式的,所以可以在编写代码时把这种转换当作一种隐式转换。如果要显式地指定强制转换过程,则可以编写如下代码:
var balance = new Currency(10,50); long amount = (long)(float)balance; double amountD = (double)(float)balance;
但是在大多数情况下,这会使代码变得比较复杂,因此是不必要的。相比之下,下面的代码会产生一个编译错误:
var balance = new Currency(10,50); long amount = balance;
原因是编译器可以找到的最佳匹配转换仍是首先转换为float型,再转换为long型。但需要显式地指定从float型到long型的转换。
并非所有这些转换都会带来太多的麻烦。毕竟转换的规则非常直观,主要是为了防止在开发人员不知情的情况下丢失数据。但是,在定义类型强制转换时如果不小心,编译器就有可能指定一条导致不期望结果的路径。例如,假定编写Currency结构的其他小组成员要把一个uint数据转换为Currency型,其中该uint数据中包含了美分的总数(是美分而非美元,因为我们不希望丢失美元的小数部分)。为此应编写如下代码来实现强制转换:
// Do not do this! public static implicit operator Currency (uint value) => new Currency(value/100u, (ushort)(value%100));
注意,在这段代码中,第一个100后面的u可以确保把value/100u解释为一个uint值。如果写成value/100,编译器就会把它解释为一个int型的值,而不是uint型的值。
在这段代码中清楚地标注了“Do not do it(不要这么做)”。下面说明其原因。看看下面的代码段,它把包含值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。
当然,在其他示例中,转换为另一种数据类型后,再转换回来有时会丢失数据。例如,把包含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) => new Currency(value, 0);
偶尔你也会觉得这种新的转换方式可能根本不必要。但实际上,这种转换方式可能非常有用。没有这种强制转换,编译器在执行从uint型到Currency型的转换时,就只能通过float型来进行。此时直接转换的效率要高得多,所以进行这种额外的强制转换会提高性能,但需要确保它的结果与通过float型进行转换得到的结果相同。在其他情况下,也可以为不同的预定义数据类型分别定义强制转换,让更多的转换隐式地执行,而不是显式地执行,但本例不是这样。
测试这种强制转换是否兼容,应确定无论使用什么转换路径,它是否都能得到相同的结果(而不是像在从float型到int型的转换过程中那样丢失数据)。Currency类就是一个很好的示例。看看下面的代码:
var balance = new Currency(50, 35); ulong bal = (ulong) balance;
目前,编译器只能采用一种方式来完成这个转换:把Currency型隐式地转换为float型,再显式地转换为ulong型。从float型到ulong型的转换需要显式转换,本例就显式指定了这个转换,所以编译是成功的。
但假定要添加另一种强制转换,从Currency型隐式地转换为uint型,就需要修改Currency结构,添加从uint型到Currency型的强制转换和从Currency型到uint型的强制转换。这段代码可以用作SimpleCurrency2示例(代码文件CastingSample/Currency.cs):
public static implicit operator Currency (uint value) => new Currency(value, 0); public static implicit operator uint (Currency value) => value.Dollars;
现在,编译器从Currency型转换到ulong型可以使用另一条路径:先从Currency型隐式地转换为uint型,再隐式地转换为ulong型。该采用哪条路径? C#有一些严格的规则(本书不详细讨论这些规则,有兴趣的读者可参阅MSDN文档),告诉编译器如何确定哪条是最佳路径。但最好自己设计类型强制转换,让所有的转换路径都得到相同的结果(但没有精确度的损失),此时编译器选择哪条路径就不重要了(在本例中,编译器会选择Currency→uint→ulong路径,而不是Currency→float→ulong路径)。
为了测试SimpleCurrency2示例,给SimpleCurrency测试程序中的Main()方法添加如下代码(代码文件CastingSample/Program.cs):
static void Main() { try { var balance = new Currency(50,35); WriteLine(balance); WriteLine($"balance is {balance}"); uint balance3 = (uint) balance; WriteLine($"Converting to uint gives {balance3}"); } catch (Exception ex) { WriteLine($"Exception occurred: {e.Message}"); } }
运行这个示例,得到如下所示的结果:
50 balance is $50.35 Converting to uint gives 50
这个结果显示了到uint型的转换是成功的,但在转换过程中丢失了Currency的美分部分(小数部分)。把负的float类型强制转换为Currency型也产生了预料中的溢出异常,因为float型到Currency型的强制转换本身定义了一个checked环境。
但是,这个输出结果也说明了进行强制转换时最后一个要注意的潜在问题:结果的第一行没有正确显示余额,显示了50,而不是$50.35。
这是为什么?问题是在把类型强制转换和方法重载合并起来时,会出现另一个不希望的错误源。
WriteLine()语句使用格式字符串隐式地调用Currency.ToString()方法,以确保Currency显示为一个字符串。
但是,第1行的Console.WriteLine()方法只把原始Currency结构传递给Console.WriteLine()。目前Console.WriteLine()有许多重载版本,但它们的参数都不是Currency结构。所以编译器会到处搜索,看看它能把Currency强制转换为什么类型,以便与Console.WriteLine()的一个重载方法匹配。如上所示,Console.WriteLine()的一个重载方法可以快速而高效地显示uint型,且其参数是一个uint值。因此应把Currency隐式地强制转换为uint型。
实际上,Console.WriteLine()有另一个重载方法,它的参数是一个double值,结果显示该double的值。如果仔细看看第一个SimpleCurrency示例的结果,就会发现该结果的第1行就是使用这个重载方法把Currency显示为double型。在这个示例中,没有直接把Currency强制转换为uint型,所以编译器选择Currency→float→double作为可用于Console.WriteLine()重载方法的首选强制转换方式。但在SimpleCurrency2中可以直接强制转换为uint型,所以编译器会选择该路径。
结论是:如果方法调用带有多个重载方法,并要给该方法传送参数,而该参数的数据类型不匹配任何重载方法,就可以迫使编译器确定使用哪些强制转换方式进行数据转换,从而决定使用哪个重载方法(并进行相应的数据转换)。当然,编译器总是按逻辑和严格的规则来工作,但结果可能并不是我们所期望的。如果存在任何疑问,最好指定显式地使用哪种强制转换。