4.3 类型转换
当在C程序中某个地方所需要的数据类型并非你所提供的类型时,通常要进行数据类型转换,这是C语言强制类型检查机制的具体表现。例如,在一个同时出现了不同类型的操作数的复合算术表达式中,一般占用内存较少的类型会隐式地转换为表达式中占用内存最多的操作数类型。C++也是一样,甚至提供了比C更严格的静态类型检查系统。
【提示4-5】: 从本质上来说,C++/C不会直接对两个类型不同的操作数进行运算,如果操作数类型不同,编译器就会试图运用隐式类型转换规则或者按照用户要求进行强制类型转换。类型转换并不是改变原来的类型和值,而是生成了新的临时变元,其类型为目标类型。
4.3.1 隐式转换
所谓隐式转换,就是编译器在背后帮程序员做的类型转换工作,程序员往往察觉不到。既然是编译器自动进行的,那么这种类型转换必须具有足够的安全性,这是编译器的责任。反过来,凡是不安全的类型转换,编译器都应该能够检查出来并给出错误或警告信息,而不会默默地执行。如果程序员确实想做这样的转换,那么需要显式地使用强制类型转换,由此可能造成的安全隐患由程序员负责。
这里的安全性主要包括两个方面:内存单元访问的安全性和转换结果的安全性。主要表现为内存访问范围的扩张、内存的截断、尾数的截断、值的改变和溢出等。
下面我们具体分析一下基本数据类型之间的转换,以及C++基类和派生类之间的转换中可能出现的安全性问题。
基本数据类型之间存在如下的兼容关系:char is-a int、int is-a long、long is-a float、float is-a double,并且is-a关系是传递的。但是存在于基本数据类型之间的is-a关系不同于C++派生类和基类之间的is-a关系,因为一个高级基本数据类型(占据内存字节多的数据类型)并不是由一个或多个低级基本数据类型(占据内存字节少的数据类型)子对象构造而成的,一个低级基本数据类型也不是派生自多个高级基本数据类型的。
一个低级数据类型对象总是优先转换为能够容纳得下它的最大值的、占用内存最少的高级类型对象。例如,100这个字面常量(类型为int)如果转换为long型就能满足编译器的要求,那就不会转换为double型。例如,下面的两个重载函数:
void f(long l); void f(double d);
如果存在调用f(100),则必然调用f(long l)而不是f(double d),除非不存在f(long l)才会连接到f(double d)。
示例4-5中的转换是安全的,并不需要强制。编译器首先隐式地将100提升为double(作为它的整数部分)的一个临时变量,然后才将这个临时变量赋值给d1;同样,i也会首先隐式地提升为double(其值作为它的整数部分)的一个临时变量,然后才赋值给d2。当编译器认为这些临时变量不再需要时就会适时地把它们销毁。
示例4-5
double d1 = 100; int i = 100; double d2 = i;
由于派生类和基类之间的is-a关系,可以直接将派生类对象转换为基类对象,这虽然会发生内存截断,但是无论从内存访问还是从转换结果来说都是安全的。这得益于C++的一个保证:派生类对象必须保证其基类子对象的完整性,即其中的基类子对象的内存映像必须和真正的基类对象的内存映像一致,如图4-3所示。程序见示例4-6。
示例4-6
图4-3 基类和派生类之间的隐式转换
【提示4-6】: 标准C语言允许任何非void类型指针和void类型指针之间进行直接的相互转换。但在C++中,可以把任何类型的指针直接指派给void类型指针,因为void*是一种通用指针,但是不能反过来将void类型指针直接指派给任何非void类型指针,除非进行强制转换。因此,在C语言环境中我们就可以先把一种具体类型的指针,如int*转换为void*类型,然后再把void*类型转换为double*类型,而编译器不会认为这是错误的。然而这样的做法确实存在着不易察觉的安全问题(内存扩张和截断等),这是标准C语言的一个缺陷。
4.3.2 强制转换
我们来看看强制类型转换可能导致的安全问题。首先来看基本数据类型及其指针的转换,见示例4-7。
示例4-7
double d3 = 1.25e+20; double d4 = 10.25; int i2 = (int)d3; int i3 = (int)d4;
按照从浮点数到整型数的转换语义,结果应该是截去浮点数的小数部分而保留其整数部分,因此i3会得到10,而i2会溢出,因为d3的整数部分远远超出了一个int所能表示的范围,结果当然不是我们所期望的。
基本数据类型之间的指针转换一般说来必然会造成内存截断或内存访问范围的扩张,除非两种类型具有相同的字节大小。例如,在32位系统中,int、long、float都具有4字节的空间,虽然不会造成内存截断或内存扩张,但是它们之间的指针转换改变了编译器对指针所指向的内存单元的解释方式,因此结果必然是错误的,见示例4-8。
示例4-8
double d5 = 1000.25; int *pInt = (int*)&d5; int i4 = 100; double *pDbl = (double*)&i4;
从内存访问角度来说,你通过pInt访问它指向的double类型变量d5是安全的(后面的4字节被“截断”了,可访问内存范围缩小),但是其值绝对不会是d5的整数部分1000,而是位于d5开头4字节中的内容,并解释为int类型数,这个数是不可预料的。同样,你通过pDbl访问int类型变量i4,得到的数据不一定就是100,况且造成了可访问内存范围的“扩张”。如果你往里面写数据就会产生运行时错误,如图4-4所示。
图4-4 基本数据类型的指针之间的强制转换
C++基类和派生类之间的指针强制转换同样存在安全隐患,如示例4-9所示。
示例4-9
Base objB2; Derived *pD2 = (Derived *)&objB2;
存在的问题是:通过pD2能够访问的内存范围“扩张”了4字节,如果访问m_c就可能引发运行时错误,因为pD2指向的对象根本就没有成员m_c的空间,如图4-5所示。
图4-5 派生类和基类之间的指针强制转换
对于类层次结构中的转换,C++提供了新的类型转换操作符及能够起类型转换作用的函数,我们会在后面讲到。
【提示4-7】: (1)不可以把基类对象直接转换为派生类对象,无论是直接赋值还是强制转换,因为这不是“自然的”。(2)对于基本类型的强制转换一定要区分值的截断与内存截断的不同。(3)如果你坚持要使用强制转换,必须同时确保内存访问的安全性和转换结果的安全性。(4)如果确信需要数据类型转换,请尽量使用显式的(即强制)数据类型转换,让人们知道发生了什么事,避免让编译器静悄悄地进行隐式的数据类型转换。
【提示4-8】: 尽量避免做违反编译器类型安全原则和数据保护原则的事情,例如,在有符号数和无符号数之间转换,或者把const对象的地址指派给非const对象指针,等等。