在 C/C++ 中,不同的数据类型之间可以相互转换:无需用户指明如何转换的称为自动类型转换(隐式类型转换),需要用户显式地指明如何转换的称为强制类型转换(显式类型转换),这点已在《C++转换构造函数》中进行了说明。
隐式类型转换利用的是编译器内置的转换规则,或者用户自定义的转换构造函数以及类型转换函数(这些都可以认为是已知的转换规则),例如从 int 到 double、从派生类到基类、从type *
到void *
、从 double 到 Complex 等。
type *
是一个具体类型的指针,例如int *
、double *
、Student *
等,它们都可以直接赋值给void *
指针。而反过来是不行的,必须使用强制类型转换才能将void *
转换为type *
,例如,malloc() 分配内存后返回的就是一个void *
指针,我们必须进行强制类型转换后才能赋值给指针变量。
当隐式转换不能完成类型转换工作时,我们就必须使用强制类型转换了。强制类型转换的语法也很简单,只需要在表达式的前面增加新类型的名称,格式为:
(new_type) expression
类型转换的本质
我们知道,数据是放在内存中的,变量(以及指针、引用)是给这块内存起的名字,有了变量就可以找到并使用这份数据。但问题是,该如何使用呢?
诸如数字、文字、符号、图形、音频、视频等数据都是以二进制形式存储在内存中的,它们并没有本质上的区别,那么,00010000 该理解为数字 16 呢,还是图像中某个像素的颜色呢,还是要发出某个声音呢?如果没有特别指明,我们并不知道。也就是说,内存中的数据有多种解释方式,使用之前必须要确定。这种「确定数据的解释方式」的工作就是由数据类型(Data Type)来完成的。例如int a;
表明,a 这份数据是整数,不能理解为像素、声音、视频等。
顾名思义,数据类型用来说明数据的类型,确定了数据的解释方式,让计算机和程序员不会产生歧义。C/C++ 支持多种数据类型,包括内置类型(例如 int、double、bool 等)和自定义类型(结构体类型和类类型)。
所谓数据类型转换,就是对数据所占用的二进制位做出重新解释。如果有必要,在重新解释的同时还会修改数据,改变它的二进制位。对于隐式类型转换,编译器可以根据已知的转换规则来决定是否需要修改数据的二进制位;而对于强制类型转换,由于没有对应的转换规则,所以能做的事情仅仅是重新解释数据的二进制位,但无法对数据的二进制位做出修正。这就是隐式类型转换和强制类型转换最根本的区别。
这里说的修改数据并不是修改原有的数据,而是修改它的副本(先将原有数据拷贝到另外一个地方再修改)。
修改数据的二进制位非常重要,它能把转换后的数据调整到正确的值,所以这种修改时常会发生,例如:
1) 整数和浮点数在内存中的存储形式大相径庭,将浮点数 f 赋值给整数 i 时,不能原样拷贝 f 的二进制位,也不能截取部分二进制位,必须先将 f 的二进制位读取出来,以浮点数的形式呈现,然后直接截掉小数部分,把剩下的整数部分再转换成二进制形式,拷贝到 i 所在的内存中。
2) short 一般占用两个字节,int 一般占用四个字节,将 short 类型的 s 赋值给 int 类型的 i 时,如果仅仅是将 s 的二进制位拷贝给 i,那么 i 最后的两个字节会原样保留,这样会导致赋值结束后 i 的值并不等于 s 的值,所以这样做是错误的。正确的做法是,先给 s 添加 16 个二进制位(两个字节)并全部置为 0,然后再拷贝给 i 所在的内存。
3) 当存在多重继承时,如果把派生类指针 pd 赋值给基类指针 pb,就必须考虑基类子对象在派生类对象中的偏移,偏移不为 0 时就要调整 pd 的值,让它加上或减去偏移量,这样赋值后才能让 pb 恰好指向基类子对象。更多细节请猛击《将派生类指针赋值给基类指针时到底发生了什么》。
4) Complex 类型占用 16 个字节,double 类型占用 8 个字节,将 double 类型的数据赋值给 Complex 类型的变量(对象)时,必须调用转换构造函数,否则剩下的 8 个字节就不知道如何填充了。
以上这些都是隐式类型转换,它对数据的调整都是有益的,能够让程序更加安全稳健地运行。
隐式类型转换必须使用已知的转换规则,虽然灵活性受到了限制,但是由于能够对数据进行恰当地调整,所以更加安全(几乎没有风险)。强制类型转换能够在更大范围的数据类型之间进行转换,例如不同类型指针(引用)之间的转换、从 const 到非 const 的转换、从 int 到指针的转换(有些编译器也允许反过来)等,这虽然增加了灵活性,但是由于不能恰当地调整数据,所以也充满了风险,程序员要小心使用。
下面的代码演示了不同类型指针之间的转换所带来的风险:
#include<iostream>
using namespace std;
class Base
{
public:
Base(int a = 0, int b = 0): m_a(a), m_b(b){ }
private:
int m_a;
int m_b;
};
int main()
{
//风险①:破坏类的封装性
Base *pb = new Base(10, 20);
int n = *((int*)pb + 1);
cout<<n<<endl;
//风险②:进行无意义的操作
float f = 56.2;
int *pi = (int*)&f;
*pi = -23;
cout<<f<<endl;
return 0;
}
运行结果:
20
NaN
NaN 是“not a number”的缩写,意思是“不是一个数字”。
Base 类有两个 private 属性的成员变量,原则上讲它们不能在类的外部访问,但是当把对象指针进行强制类型转换后,就突破了这种限制,破坏了类的封装性。更多内容请猛击《借助指针突破访问权限的限制》一文。
f 是 float 类型的变量,用来存储浮点数,但是我们通过指针将一个整数直接放到了 f 所在的内存,由于整数和浮点数的存储格式不一样,所以直接放入一个整数毫无意义。关于整数和浮点数在内存中的存储请猛击《整数在内存中是如何存储的》和《小数在内存中是如何存储的》。
为什么会有隐式类型转换和强制类型转换之分?
隐式类型转换和显式类型转换最根本的区别是:隐式类型转换除了会重新解释数据的二进制位,还会利用已知的转换规则对数据进行恰当地调整;而显式类型转换只能简单粗暴地重新解释二进制位,不能对数据进行任何调整。
其实,能不能对数据进行调整是显而易见地事情,有转换规则就可以调整,没有转换规则就不能调整,当进行数据类型转换时,编译器明摆着是知道有没有转换规则的。站在这个角度考虑,强制类型转换的语法就是多此一举,编译器完全可以自行判断是否需要调整数据。例如从int *
转换到float *
,加不加强制类型转换的语法都不能对数据进行调整。
C/C++ 之所以增加强制类型转换的语法,是为了提醒程序员这样做存在风险,一定要谨慎小心。说得通俗一点,你现在的类型转换存在风险,你自己一定要知道。
强制类型转换也不是万能的
类型转换只能发生在相关类型或者相近类型之间,两个毫不相干的类型不能相互转换,即使使用强制类型转换也不行。例如,两个没有继承关系的类不能相互转换,基类不能向派生类转换(向下转型),类类型不能向基本类型转换,指针和类类型之间不能相互转换。
下面的代码演示了不相干类型之间的转换:
#include<iostream>
using namespace std;
class A{};
class B{};
class Base{ };
class Derived: public Base{ };
int main()
{
A a;
B b;
Base obj1;
Derived obj2;
a = (A)b; //Error: 两个没有继承关系的类不能相互转换
int n = (int)a; //Error: 类类型不能向基本类型转换
int *p = (int*)b; //Error: 指针和类类型之间不能相互转换
obj2 = (Derived)obj1; //Error: 向下转型
obj1 = obj2; //Correct: 向上转型
return 0;
}