当定义一个类时,我们显式地或隐式地指定了此类型的对象在拷贝、赋值和销毁时做什么。一个类通过定义三种特殊的成员函数来控制这些操作,分别是拷贝构造函数、赋值运算符和析构函数。
拷贝构造函数定义了当用同类型的另一个对象初始化新对象时做什么,赋值运算符定义了将一个对象赋予同类型的另一个对象时做什么,析构函数定义了此类型的对象销毁时做什么。我们将这些操作称为拷贝控制操作。
拷贝控制操作
由于拷贝控制操作是由三个特殊的成员函数来完成的,所以我们称此为“C++三法则”。在较新的 C++11 标准中,为了支持移动语义,又增加了移动构造函数和移动赋值运算符,这样共有五个特殊的成员函数,所以又称为“C++五法则”。也就是说,“三法则”是针对较旧的 C++89 标准说的,“五法则”是针对较新的 C++11 标准说的。为了统一称呼,后来人们干把它叫做“C++ 三/五法则”。
如果一个类没有定义所有这些拷贝控制成员,编译器会自动为它定义默认的操作,因此很多类会忽略这些拷贝控制操作。但是,对于一些持有其他资源(例如动态分配的内存、打开的文件、指向其他数据的指针、网络连接等)的类来说,依赖这些默认的操作会导致灾难,我们必须显式的定义这些操作。
C++ 并不要求我们定义所有的这些操作,你可以只定义其中的一个或两个。但是,这些操作通常应该被看做一个整体,只需要定义其中一个操作,而不需要定义其他操作的情况很少见。
需要析构函数的类也需要拷贝和赋值操作
当我们决定是否要为一个类显式地定义拷贝构造函数和赋值运算符时,一个基本原则是首先确定这个类是否需要一个析构函数。通常,对析构函数的需求要比拷贝构造函数和赋值运算符的需求更加明显。如果一个类需要定义析构函数,那么几乎可以肯定这个类也需要一个拷贝构造函数和一个赋值运算符。
我们在前面几节中使用过的 Array 类就是一个典型的例子。这个类在构造函数中动态地分配了一块内存,并用一个成员变量(指针变量)指向它,默认的析构函数不会释放这块内存,所以我们需要显式地定义一个析构函数来释放内存。
「应该怎么做」可能还是有点不清晰,但基本原则告诉我们,Array 类也需要一个拷贝构造函数和一个赋值运算符。
如果我们为 Array 定义了一个析构函数,但却使用默认的拷贝构造函数和赋值运算符,那么将导致不同对象之间相互干扰,修改一个对象的数据会影响另外的对象。此外还可能会导致内存操作错误,请看下面的代码:
1 |
Array (Array arr){ |
当 func()
返回时,arr
和 ret `
都会被销毁,在两个对象上都会调用析构函数,此析构函数会 free()
掉 m_p 成员所指向的动态内存。但是,这两个对象的 m_p 成员指向的是同一块内存,所以该内存会被
free()` 两次,这显然是一个错误,将要发生什么是未知的。
此外,func()
的调用者还会继续使用传递给func()
的对象:
1 |
Array arr1(10); |
arr2
(以及 arr1
)指向的内存不再有效,在arr
(以及 ret
)被销毁时系统已经归还给操作系统了。
总之,如果一个类需要定义析构函数,那么几乎可以肯定它也需要定义拷贝构造函数和赋值运算符。
需要拷贝操作的类也需要赋值操作,反之亦然
虽然很多类需要定义所有(或是不需要定义任何)拷贝控制成员,但某些类所要完成的工作,只需要拷贝或者赋值操作,不需要析构操作。
作为一个例子,考虑一个类为每个对象分配一个独有的、唯一的编号。这个类除了需要一个拷贝构造函数为每个新创建的对象生成一个新的编号,还需要一个赋值运算符来避免将一个对象的编号赋值给另外一个对象。但是,这个类并不需要析构函数。
这个例子引出了第二个基本原则:如果一个类需要一个拷贝构造函数,几乎可以肯定它也需要一个赋值运算符;反之亦然。然而,无论需要拷贝构造函数还是需要复制运算符,都不必然意味着也需要析构函数。
在 C/C++ 中,不同的数据类型之间可以相互转换。无需用户指明如何转换的称为自动类型转换(隐式类型转换),需要用户显式地指明如何转换的称为强制类型转换。
自动类型转换示例:
1 |
int a = 6; |
编译器对 7.5 是作为 double 类型处理的,在求解表达式时,先将 a 转换为 double 类型,然后与 7.5 相加,得到和为 13.5。在向整型变量 a 赋值时,将 13.5 转换为整数 13,然后赋给 a。整个过程中,我们并没有告诉编译器如何去做,编译器使用内置的规则完成数据类型的转换。
强制类型转换示例:
1 |
int n = 100; |
p1
是int *
类型,它指向的内存里面保存的是整数,p2
是float *
类型,将 p1
赋值给 p2
后,p2
也指向了这块内存,并把这块内存中的数据作为小数处理。我们知道,整数和小数的存储格式大相径庭,将整数作为小数处理非常荒诞,可能会引发莫名其妙的错误,所以编译器默认不允许将 p1
赋值给p2
。但是,使用强制类型转换后,编译器就认为我们知道这种风险的存在,并进行了适当的权衡,所以最终还是允许了这种行为。
不管是自动类型转换还是强制类型转换,前提必须是编译器知道如何转换,例如,将小数转换为整数会抹掉小数点后面的数字,将int *
转换为float *
只是简单地复制指针的值,这些规则都是编译器内置的,我们并没有告诉编译器。
换句话说,如果编译器不知道转换规则就不能转换,使用强制类型也无用,请看下面的例子:
1 |
|
25.5 是实数,a 是复数,将 25.5 赋值给 a 后,我们期望 a 的实部变为 25.5,而虚部为 0。但是,编译器并不知道这个转换规则,这超出了编译器的处理能力,所以转换失败,即使加上强制类型转换也无用。
幸运的是,C++ 允许我们自定义类型转换规则,用户可以将其它类型转换为当前类类型,也可以将当前类类型转换为其它类型。这种自定义的类型转换规则只能以类的成员函数的形式出现,换句话说,这种转换规则只适用于类。
本节我们先讲解如何将其它类型转换为当前类类型,下节再讲解如何将当前类类型转换为其它类型。
转换构造函数
将其它类型转换为当前类类型需要借助转换构造函数(Conversion constructor)。
转换构造函数也是一种构造函数,它遵循构造函数的一般规则。转换构造函数只有一个参数。
仍然以 Complex 类为例,我们为它添加转换构造函数:
1 |
|
运行结果:
1 |
10 + 20i |
Complex(double real);
就是转换构造函数,它的作用是将 double 类型的参数 real 转换成 Complex 类的对象,并将 real 作为复数的实部,将 0 作为复数的虚部。这样一来,a = 25.5;
整体上的效果相当于:
1 |
a.Complex(25.5); |
将赋值的过程转换成了函数调用的过程。
在进行数学运算、赋值、拷贝等操作时,如果遇到类型不兼容、需要将 double 类型转换为 Complex 类型时,编译器会检索当前的类是否定义了转换构造函数,如果没有定义的话就转换失败,如果定义了的话就调用转换构造函数。
转换构造函数也是构造函数的一种,它除了可以用来将其它类型转换为当前类类型,还可以用来初始化对象,这是构造函数本来的意义。下面创建对象的方式是正确的:
1 |
Complex c1(26.4); //创建具名对象 |
在以拷贝的方式初始化对象时,编译器先调用转换构造函数,将 240.3 转换为 Complex 类型(创建一个 Complex 类的匿名对象),然后再拷贝给 c2
。
如果已经对+
运算符进行了重载,使之能进行两个 Complex 类对象的相加,那么下面的语句也是正确的:
1 |
Complex c1(15.6, 89.9); |
在进行加法运算符时,编译器先将 29.6 转换为 Complex 类型(创建一个 Complex 类的匿名对象)再相加。
需要注意的是,为了获得目标类型,编译器会“不择手段”,会综合使用内置的转换规则和用户自定义的转换规则,并且会进行多级类型转换,例如:
- 编译器会根据内置规则先将 int 转换为 double,再根据用户自定义规则将 double 转换为
Complex(int --> double --> Complex)
; - 编译器会根据内置规则先将 char 转换为 int,再将 int 转换为 double,最后根据用户自定义规则将 double 转换为
Complex(char --> int --> double --> Complex)
。
从本例看,只要一个类型能转换为 double 类型,就能转换为 Complex 类型。请看下面的例子:
1 |
int main(){ |
运行结果:
1 |
100 + 0i |
再谈构造函数
构造函数的本意是在创建对象的时候初始化对象,编译器会根据传递的实参来匹配不同的(重载的)构造函数。回顾一下以前的章节,到目前为止我们已经学习了以下几种构造函数。
1) 默认构造函数。就是编译器自动生成的构造函数。以 Complex 类为例,它的原型为:
1 |
Complex(); //没有参数 |
2) 普通构造函数。就是用户自定义的构造函数。以 Complex 类为例,它的原型为:
1 |
Complex(double real, double imag); //两个参数 |
3) 拷贝构造函数。在以拷贝的方式初始化对象时调用。以 Complex 类为例,它的原型为:
1 |
Complex(const Complex &c); |
4) 转换构造函数。将其它类型转换为当前类类型时调用。以 Complex 为例,它的原型为:
1 |
Complex(double real); |
不管哪一种构造函数,都能够用来初始化对象,这是构造函数的本意。假设 Complex 类定义了以上所有的构造函数,那么下面创建对象的方式都是正确的:
1 |
Complex c1(); //调用Complex() |
这些代码都体现了构造函数的本意——在创建对象时初始化对象。
除了在创建对象时初始化对象,其他情况下也会调用构造函数,例如,以拷贝的的方式初始化对象时会调用拷贝构造函数,将其它类型转换为当前类类型时会调用转换构造函数。这些在其他情况下调用的构造函数,就成了特殊的构造函数了。特殊的构造函数并不一定能体现出构造函数的本意。
对 Complex 类的进一步精简
上面的 Complex 类中我们定义了三个构造函数,其中包括两个普通的构造函数和一个转换构造函数。其实,借助函数的默认参数,我们可以将这三个构造函数简化为一个,请看下面的代码:
1 |
|
精简后的构造函数包含了两个默认参数,在调用它时可以省略部分或者全部实参,也就是可以向它传递 0 个、1 个、2 个实参。转换构造函数就是包含了一个参数的构造函数,恰好能够和其他两个普通的构造函数“融合”在一起。
类型转换函数
转换构造函数能够将其它类型转换为当前类类型(例如将 double 类型转换为 Complex 类型),但是不能反过来将当前类类型转换为其它类型(例如将 Complex 类型转换为 double 类型)。
C++ 提供了类型转换函数(Type conversion function)来解决这个问题。类型转换函数的作用就是将当前类类型转换为其它类型,它只能以成员函数的形式出现,也就是只能出现在类中。
类型转换函数的语法格式为:
1 |
operator type(){ |
operator 是 C++ 关键字,type 是要转换的目标类型,data 是要返回的 type 类型的数据。
因为要转换的目标类型是 type,所以返回值 data 也必须是 type 类型。既然已经知道了要返回 type 类型的数据,所以没有必要再像普通函数一样明确地给出返回值类型。这样做导致的结果是:类型转换函数看起来没有返回值类型,其实是隐式地指明了返回值类型。
类型转换函数也没有参数,因为要将当前类的对象转换为其它类型,所以参数不言而喻。实际上编译器会把当前对象的地址赋值给 this 指针,这样在函数体内就可以操作当前对象了。
【示例】为 Complex 类添加类型转换函数,使得 Complex 类型能够转换为 double 类型。
1 |
|
运行结果:
1 |
f = 24.6 |
本例中,类型转换函数非常简单,就是返回成员变量 m_real 的值,所以建议写成 inline 的形式。
类型转换函数和运算符的重载非常相似,都使用 operator 关键字,因此也把类型转换函数称为类型转换运算符。
关于类型转换函数的说明
1) type 可以是内置类型、类类型以及由 typedef 定义的类型别名,任何可作为函数返回类型的类型(void 除外)都能够被支持。一般而言,不允许转换为数组或函数类型,转换为指针类型或引用类型是可以的。
2) 类型转换函数一般不会更改被转换的对象,所以通常被定义为 const
成员。
3) 类型转换函数可以被继承,可以是虚函数。
4) 一个类虽然可以有多个类型转换函数(类似于函数重载),但是如果多个类型转换函数要转换的目标类型本身又可以相互转换(类型相近),那么有时候就会产生二义性。以 Complex 类为例,假设它有两个类型转换函数:
1 |
operator double() const { return m_real; } //转换为double类型 |
那么下面的写法就会引发二义性:
1 |
Complex c1(24.6, 100); |
编译器可以调用 operator double() 将 c1
转换为 double 类型,也可以调用 operator int() 将c1
转换为 int 类型,这两种类型都可以跟 12.5 进行加法运算,并且从 Complex 转换为 double 与从 Complex 转化为 int 是平级的,没有谁的优先级更高,所以这个时候编译器就不知道该调用哪个函数了,干脆抛出一个二义性错误,让用户解决。
转换构造函数和类型转换函数异同
转换构造函数和类型转换函数的作用是相反的:转换构造函数会将其它类型转换为当前类类型,类型转换函数会将当前类类型转换为其它类型。如果没有这两个函数,Complex
类和 int
、double
、bool
等基本类型的四则运算、逻辑运算都将变得非常复杂,要编写大量的运算符重载函数。
但是,如果一个类同时存在这两个函数,就有可能产生二义性。下面以 Complex 类为例来演示:
1 |
|
①和②是正确的,相信大家很容易理解。
对于③,进行加法运算时,有两种转换方案:
- 第一种方案是先将 12.5 转换为 Complex 类型再运算,这样得到的结果也是 Complex 类型,再调用类型转换函数就可以赋值给 f 了。
- 第二种方案是先将 c1 转换为 double 类型再运算,这样得到的结果也是 double 类型,可以直接赋值给 f。
很多人会认为,既然=左边是 double 类型,很显然应该选择第二种方案,这样才符合“常理”。其实不然,编译器不会根据=左边的数据类型来选择转换方案,编译器只关注12.5 + c1这个表达式本身,站在这个角度考虑,上面的两种转换方案都可以,编译器不知道选择哪一种,所以会抛出二义性错误,让用户自己去解决。
当然,你也可以认为编译器不够智能,没有足够强大的上下文(周边环境)推导能力。反过来说,即使我们假设编译器会根据=左边的数据类型来选择解决方案,那仍然会存在二义性问题,下面就是一个例子:
1 |
Complex c1(24.6, 100); |
该语句没有将c1 + 46.7
的结果赋值给其他变量,而是直接输出,这种情况应该将c1
转换成 double 类型呢,还是应该将46.7
转换成 Complex
类型呢?很明显都可以,因为转换构造函数和类型转换函数是平级的,没有谁的优先级更高,所以该语句也会产生二义性错误。
解决二义性问题的办法也很简单粗暴,要么只使用转换构造函数,要么只使用类型转换函数。实践证明,用户对转换构造函数的需求往往更加强烈,这样能增加编码的灵活性,例如,可以将一个字符串字面量或者一个字符数组直接赋值给 string 类的对象,可以将一个int
、double
、bool
等基本类型的数据直接赋值给 Complex 类的对象。
那么,如果我们想把当前类类型转换为其它类型怎么办呢?很简单,增加一个普通的成员函数即可,例如,string 类使用c_str()
函数转换为 C 风格的字符串,complex 类使用 real() 和imag()
函数来获取复数的实部和虚部。
complex 是 C++ 标准库中的复数类,c是小写的,使用时需要引入complex头文件。Complex 是我们为了教学而自定义的复数类,C是大写的,Complex 类尽量模拟 complex 类。
下面是重新编写的 Complex 类,该类只使用了转换构造函数,没有使用类型转换函数,取而代之的是 real()
和imag()
两个普通成员函数。一个实用的 Complex
类能够进行四则运算和关系运算,需要重载 +、-、、/、+=、-=、=、/=、==、!= 这些运算符,不过作为教学演示,这里仅仅重载了 +、+=、==、!= 运算符,其它运算符的重载与此类似。
1 |
|
运行结果:
1 |
c1 = 12 + 60i |
类型转换的本质
在 C/C++ 中,不同的数据类型之间可以相互转换:无需用户指明如何转换的称为自动类型转换(隐式类型转换),需要用户显式地指明如何转换的称为强制类型转换(显式类型转换)。
隐式类型转换利用的是编译器内置的转换规则,或者用户自定义的转换构造函数以及类型转换函数(这些都可以认为是已知的转换规则),例如从 int 到 double、从派生类到基类、从type *
到void *
、从 double 到 Complex 等。
type *
是一个具体类型的指针,例如int 、double 、Student 等,它们都可以直接赋值给void 指针。而反过来是不行的,必须使用强制类型转换才能将void *
转换为type *
,例如,malloc()
分配内存后返回的就是一个void *指针,我们必须进行强制类型转换后才能赋值给指针变量。
当隐式转换不能完成类型转换工作时,我们就必须使用强制类型转换了。强制类型转换的语法也很简单,只需要在表达式的前面增加新类型的名称,格式为:
1 |
(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 到指针的转换(有些编译器也允许反过来)等,这虽然增加了灵活性,但是由于不能恰当地调整数据,所以也充满了风险,程序员要小心使用。
下面的代码演示了不同类型指针之间的转换所带来的风险:
1 |
|
运行结果:
1 |
20 |
NaN 是“not a number”的缩写,意思是“不是一个数字”。
Base 类有两个 private 属性的成员变量,原则上讲它们不能在类的外部访问,但是当把对象指针进行强制类型转换后,就突破了这种限制,破坏了类的封装性。更多内容请猛击《借助指针突破访问权限的限制》一文。
f 是 float 类型的变量,用来存储浮点数,但是我们通过指针将一个整数直接放到了 f 所在的内存,由于整数和浮点数的存储格式不一样,所以直接放入一个整数毫无意义。关于整数和浮点数在内存中的存储请猛击《整数在内存中是如何存储的》和《小数在内存中是如何存储的》。
为什么会有隐式类型转换和强制类型转换之分?
隐式类型转换和显式类型转换最根本的区别是:隐式类型转换除了会重新解释数据的二进制位,还会利用已知的转换规则对数据进行恰当地调整;而显式类型转换只能简单粗暴地重新解释二进制位,不能对数据进行任何调整。
其实,能不能对数据进行调整是显而易见地事情,有转换规则就可以调整,没有转换规则就不能调整,当进行数据类型转换时,编译器明摆着是知道有没有转换规则的。站在这个角度考虑,强制类型转换的语法就是多此一举,编译器完全可以自行判断是否需要调整数据。例如从int 转换到float ,加不加强制类型转换的语法都不能对数据进行调整。
C/C++ 之所以增加强制类型转换的语法,是为了提醒程序员这样做存在风险,一定要谨慎小心。说得通俗一点,你现在的类型转换存在风险,你自己一定要知道。
强制类型转换也不是万能的
类型转换只能发生在相关类型或者相近类型之间,两个毫不相干的类型不能相互转换,即使使用强制类型转换也不行。例如,两个没有继承关系的类不能相互转换,基类不能向派生类转换(向下转型),类类型不能向基本类型转换,指针和类类型之间不能相互转换。
下面的代码演示了不相干类型之间的转换:
1 |
|
近期评论