C++ 拷贝构造函数

对于计算机来说,拷贝是指用一份原有的、已经存在的数据创建出一份新的数据,最终的结果是多了一份相同的数据。

在 C++ 中,拷贝并没有脱离它本来的含义,只是将这个含义进行了“特化”,是指用已经存在的对象创建出一个新的对象。从本质上讲,对象也是一份数据,因为它会占用内存。

对象的创建包括两个阶段。第一,分配内存空间,第二,初始化。

  • 分配内存空间:在堆区、栈区或者全局数据区留出足够多的字节。这个时候的内存还比较“原始”,没有被“教化”,它所包含的数据一般是零值或者随机值,没有实际的意义。
  • 初始化:首次对内存赋值,让它的数据有意义。注意是首次赋值,再次赋值不叫初始化。初始化的时候还可以为对象分配其他的资源(打开文件、连接网络、动态分配内存等),或者提前进行一些计算(根据价格和数量计算出总价、根据长度和宽度计算出矩形的面积等)等。说白了,初始化就是调用构造函数。

拷贝构造函数中的拷贝:在初始化阶段进行的,其它对象的数据来初始化新对象的内存。

那么,如何用拷贝的方式来初始化一个对象呢?其实这样的例子比比皆是,string 类就是一个典型的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>
#include <string>
using namespace std;

void func(string str){
cout<<str<<endl;
}

int main(){
string s1 = "http://c.biancheng.net";
string s2(s1);
string s3 = s1;
string s4 = s1 + " " + s2;
func(s1);
cout<<s1<<endl<<s2<<endl<<s3<<endl<<s4<<endl;

return 0;
}

运行结果:

http://c.biancheng.net

http://c.biancheng.net

http://c.biancheng.net

http://c.biancheng.net

http://c.biancheng.net http://c.biancheng.net

s1、s2、s3、s4 以及 func() 的形参 str,都是使用拷贝的方式来初始化的。

对于 s1,表面上看起来是将一个字符串直接赋值给了 s1,实际上在内部进行了类型转换,将 const char * 类型转换为 string 类型后才赋值的,我们会在转换构造函数中讲解。

对于 s1、s2、s3、s4,都是将其它对象的数据拷贝给当前对象,以完成当前对象的初始化。

对于 func() 的形参 str,其实在定义时就为它分配了内存,但是此时并没有初始化,只有等到调用 func() 时,才会将其它对象的数据拷贝给 str 以完成初始化。

当以拷贝的方式初始化一个对象时,会调用一个特殊的构造函数,就是拷贝构造函数(Copy Constructor)

下面的例子演示了拷贝构造函数的定义和使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <iostream>
#include <string>
using namespace std;
class Student{
public:
Student(string name = "", int age = 0, float score = 0.0f); //普通构造函数
Student(const Student &stu); //拷贝构造函数(声明)
public:
void display();
private:
string m_name;
int m_age;
float m_score;
};
Student::Student(string name, int age, float score): m_name(name), m_age(age), m_score(score){ }
//拷贝构造函数(定义)
Student::Student(const Student &stu){
this->m_name = stu.m_name;
this->m_age = stu.m_age;
this->m_score = stu.m_score;

cout<<"Copy constructor was called."<<endl;
}
void Student::display(){
cout<<m_name<<"的年龄是"<<m_age<<",成绩是"<<m_score<<endl;
}
int main(){
Student stu1("小明", 16, 90.5);
Student stu2 = stu1; //调用拷贝构造函数
Student stu3(stu1); //调用拷贝构造函数
stu1.display();
stu2.display();
stu3.display();

return 0;
}

运行结果:

Copy constructor was called.

Copy constructor was called.

小明的年龄是16,成绩是90.5

小明的年龄是16,成绩是90.5

小明的年龄是16,成绩是90.5

第 8 行是拷贝构造函数的声明,第 20 行是拷贝构造函数的定义。拷贝构造函数只有一个参数,它的类型是当前类的引用,而且一般都是 const 引用。

1. 为什么必须是当前类的引用呢?

如果拷贝构造函数的参数不是当前类的引用,而是当前类的对象,那么在调用拷贝构造函数时,会将另外一个对象直接传递给形参,这本身就是一次拷贝,会再次调用拷贝构造函数,然后又将一个对象直接传递给了形参,将继续调用拷贝构造函数……这个过程会一直持续下去,没有尽头,陷入死循环。

只有当参数是当前类的引用时,才不会导致再次调用拷贝构造函数,这不仅是逻辑上的要求,也是 C++ 语法的要求。

2. 为什么是 const 引用呢?

拷贝构造函数的目的是用其它对象的数据来初始化当前对象,并没有期望更改其它对象的数据,添加 const 限制后,这个含义更加明确了。

另外一个原因是,添加 const 限制后,可以将 const 对象和非 const 对象传递给形参了,因为非 const 类型可以转换为 const 类型。如果没有 const 限制,就不能将 const 对象传递给形参,因为 const 类型不能转换为非 const 类型,这就意味着,不能使用 const 对象来初始化当前对象了。

以上面的 Student 类为例,将 const 去掉后,拷贝构造函数的原型变为:

1
Student::Student(Student &stu);

此时,下面的代码就会发生错误:

1
2
3
const Student stu1("小明", 16, 90.5);
Student stu2 = stu1;
Student stu3(stu1);

stu1 是 const 类型,在初始化 stu2、stu3 时,编译器希望调用Student::Student(const Student &stu),但是这个函数却不存在,又不能将 const Student 类型转换为 Student 类型去调用Student::Student(Student &stu),所以最终调用失败了。

当然,你也可以再添加一个参数为 const 引用的拷贝构造函数,这样就不会出错了。换句话说,一个类可以同时存在两个拷贝构造函数,一个函数的参数为 const 引用,另一个函数的参数为非 const 引用。

默认拷贝构造函数

在前面的教程中,我们还没有讲解拷贝构造函数,但是却已经在使用拷贝的方式创建对象了,并且也没有引发什么错误。这是因为,如果程序员没有显式地定义拷贝构造函数,那么编译器会自动生成一个默认的拷贝构造函数。这个默认的拷贝构造函数很简单,就是使用“老对象”的成员变量对“新对象”的成员变量进行一对一的赋值,和上面 Student 类的拷贝构造函数非常类似。

对于简单的类,默认拷贝构造函数一般是够用的,我们也没有必要再显式地定义一个功能类似的拷贝构造函数。但是当类持有其它资源时,如动态分配的内存、打开的文件、指向其他数据的指针、网络连接等,默认拷贝构造函数就不能拷贝这些资源,我们必须显式地定义拷贝构造函数,以完整地拷贝对象的所有数据,这点我们将在《C++深拷贝和浅拷贝》一节中深入讲解。

到底什么时候会调用拷贝构造函数?

当以拷贝的方式初始化对象时会调用拷贝构造函数。这里有两个关键点,分别是「以拷贝的方式」和「初始化对象」。

初始化对象

初始化对象是指,为对象分配内存后第一次向内存中填充数据,这个过程会调用构造函数。对象被创建后必须立即被初始化,换句话说,只要创建对象,就会调用构造函数。

初始化和赋值的区别

初始化和赋值都是将数据写入内存中,并且从表面上看起来,初始化在很多时候都是以赋值的方式来实现的,所以很容易混淆。请看下面的例子:

1
2
3
4
5
6
int a = 100;  //以赋值的方式初始化
a = 200; //赋值
a = 300; //赋值
int b; //默认初始化
b = 29; //赋值
b = 39; //赋值

在定义的同时进行赋值叫做初始化(Initialization),定义完成以后再赋值(不管在定义的时候有没有赋值)就叫做赋值(Assignment)。初始化只能有一次,赋值可以有多次

对于基本类型的数据,我们很少会区分「初始化」和「赋值」这两个概念,即使将它们混淆,也不会出现什么错误。但是对于类,它们的区别就非常重要了,因为初始化时会调用构造函数(以拷贝的方式初始化时会调用拷贝构造函数),而赋值时会调用重载过的赋值运算符。请看下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
#include <iostream>
#include <string>
using namespace std;

class Student{
public:
Student(string name = "", int age = 0, float score = 0.0f); //普通构造函数
Student(const Student &stu); //拷贝构造函数
public:
Student & operator=(const Student &stu); //重载=运算符
void display();
private:
string m_name;
int m_age;
float m_score;
};

Student::Student(string name, int age, float score): m_name(name), m_age(age), m_score(score){ }

//拷贝构造函数
Student::Student(const Student &stu){
this->m_name = stu.m_name;
this->m_age = stu.m_age;
this->m_score = stu.m_score;
cout<<"Copy constructor was called."<<endl;
}

//重载=运算符
Student & Student::operator=(const Student &stu){
this->m_name = stu.m_name;
this->m_age = stu.m_age;
this->m_score = stu.m_score;
cout<<"operator=() was called."<<endl;

return *this;
}

void Student::display(){
cout<<m_name<<"的年龄是"<<m_age<<",成绩是"<<m_score<<endl;
}

int main(){
//stu1、stu2、stu3都会调用普通构造函数Student(string name, int age, float score)
Student stu1("小明", 16, 90.5);
Student stu2("王城", 17, 89.0);
Student stu3("陈晗", 18, 98.0);

Student stu4 = stu1; //调用拷贝构造函数Student(const Student &stu)
stu4 = stu2; //调用operator=()
stu4 = stu3; //调用operator=()

Student stu5; //调用普通构造函数Student()
stu5 = stu1; //调用operator=()
stu5 = stu2; //调用operator=()

return 0;
}

运行结果:

Copy constructor was called.

operator=() was called.

operator=() was called.

operator=() was called.

operator=() was called.

以拷贝的方式初始化对象

初始化对象时会调用构造函数,不同的初始化方式会调用不同的构造函数:

  • 如果用传递进来的实参初始化对象,那么会调用普通的构造函数,我们不妨将此称为普通初始化;
  • 如果用其它对象(现有对象)的数据来初始化对象,那么会调用拷贝构造函数,这就是以拷贝的方式初始化。

在实际编程中,具体有哪些情况是以拷贝的方式来初始化对象呢?

①将其它对象作为实参

以上面的 Student 类为例,我们可以这样来创建一个新的对象:

1
2
Student stu1("小明", 16, 90.5);  //普通初始化
Student stu2(stu1); //以拷贝的方式初始化

即使我们不在类中显式地定义拷贝构造函数,这种初始化方式也是有效的,因为编译器会生成默认的拷贝构造函数

②在创建对象的同时赋值

以上面的 Student 类为例,我们可以这样来创建一个新的对象:

1
2
Student stu1("小明", 16, 90.5);  //普通初始化
Student stu2(stu1); //以拷贝的方式初始化

即使我们不在类中显式地定义拷贝构造函数,这种初始化方式也是有效的,因为编译器会生成默认的拷贝构造函数

③函数的形参为类类型

如果函数的形参为类类型(也就是一个对象),那么调用函数时要将另外一个对象作为实参传递进来赋值给形参,这也是以拷贝的方式初始化形参对象。请看下面的代码:

1
2
3
4
5
6
void func(Student s){
//TODO:
}

Student stu("小明", 16, 90.5); //普通初始化
func(stu); //以拷贝的方式初始化

func() 函数有一个 Student 类型的形参 s,将实参 stu 传递给形参 s 就是以拷贝的方式初始化的过程。

函数是一段可以重复使用的代码,只有等到真正调用函数时才会为局部数据(形参和局部变量)在栈上分配内存。对于上面的 func(),虽然它的形参 s 是一个对象,但在定义函数时 s 对象并没有被创建,只有等到调用函数时才会真正地创建 s 对象,并在栈上为它分配内存。而创建 s 对象,就是以拷贝的方式进行的,它等价于下面的代码:

1
Student s = stu;

④函数返回值为类类型

当函数的返回值为类类型时,return 语句会返回一个对象,不过为了防止局部对象被销毁,也为了防止通过返回值修改原来的局部对象,编译器并不会直接返回这个对象,而是根据这个对象先创建出一个临时对象(匿名对象),再将这个临时对象返回。而创建临时对象的过程,就是以拷贝的方式进行的,会调用拷贝构造函数。

下面的代码演示了返回一个对象的情形:

1
2
3
4
5
Student func(){
Student s("小明", 16, 90.5);
return s;
}
Student stu = func();

理论上讲,运行代码后会调用两次拷贝构造函数,一次是返回 s 对象时,另外一次是创建 stu 对象时

在较老的编译器上,你或许真的能够看到调用两次拷贝构造函数,例如 iPhone 上的 C/C++ 编译器。但是在现代编译器上,只会调用一次拷贝构造函数,或者一次也不调用,例如在 VS2010 下会调用一次拷贝构造函数,在 GCC、Xcode 下一次也不会调用。这是因为,现代编译器都支持返回值优化技术,会尽量避免拷贝对象,以提高程序运行效率。关于编译器如何实现返回值优化的我们不再展开讨论,有兴趣的读者请猛击《C++ 之返回值优化》

深拷贝和浅拷贝

对于基本类型的数据以及简单的对象,它们之间的拷贝非常简单,就是按位复制内存。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Base{

public:
Base(): m_a(0), m_b(0){ }
Base(int a, int b): m_a(a), m_b(b){ }

private:
int m_a;
int m_b;
};

int main(){
int a = 10;
int b = a; //拷贝
Base obj1(10, 20);
Base obj2 = obj1; //拷贝
return 0;
}

b 和 obj2 都是以拷贝的方式初始化的,具体来说,就是将 a 和 obj1 所在内存中的数据按照二进制位(Bit)复制到 b 和 obj2 所在的内存,这种默认的拷贝行为就是浅拷贝,这和调用 memcpy() 函数的效果非常类似。

对于简单的类,默认的拷贝构造函数一般就够用了,我们也没有必要再显式地定义一个功能类似的拷贝构造函数。但是当类持有其它资源时,例如动态分配的内存、指向其他数据的指针等,默认的拷贝构造函数就不能拷贝这些资源了,我们必须显式地定义拷贝构造函数,以完整地拷贝对象的所有数据。

下面我们通过一个具体的例子来说明显式定义拷贝构造函数的必要性。我们知道,有些较老的编译器不支持变长数组,例如 VC6.0、VS2010 等,这有时候会给编程带来不便,下面我们通过自定义的 Array 类来实现变长数组。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
#include <iostream>
#include <cstdlib>
using namespace std;
//变长数组类
class Array{
public:
Array(int len);
Array(const Array &arr); //拷贝构造函数
~Array();
public:
int operator[](int i) const { return m_p[i]; } //获取元素(读取)
int &operator[](int i){ return m_p[i]; } //获取元素(写入)
int length() const { return m_len; }
private:
int m_len;
int *m_p;
};
Array::Array(int len): m_len(len){
m_p = (int*)calloc( len, sizeof(int) );
}
Array::Array(const Array &arr){ //拷贝构造函数
this->m_len = arr.m_len;
this->m_p = (int*)calloc( this->m_len, sizeof(int) );
memcpy( this->m_p, arr.m_p, m_len * sizeof(int) );
}
Array::~Array(){ free(m_p); }
//打印数组元素
void printArray(const Array &arr){
int len = arr.length();
for(int i=0; i<len; i++){
if(i == len-1){
cout<<arr[i]<<endl;
}else{
cout<<arr[i]<<", ";
}
}
}
int main(){
Array arr1(10);
for(int i=0; i<10; i++){
arr1[i] = i;
}

Array arr2 = arr1;
arr2[5] = 100;
arr2[3] = 29;

printArray(arr1);
printArray(arr2);

return 0;
}

运行结果:

0, 1, 2, 3, 4, 5, 6, 7, 8, 9

0, 1, 2, 29, 4, 100, 6, 7, 8, 9

本例中我们显式地定义了拷贝构造函数,它除了会将原有对象的所有成员变量拷贝给新对象,还会为新对象再分配一块内存,并将原有对象所持有的内存也拷贝过来。这样做的结果是,原有对象和新对象所持有的动态内存是相互独立的,更改一个对象的数据不会影响另外一个对象,本例中我们更改了 arr2 的数据,就没有影响 arr1。

这种将对象所持有的其它资源一并拷贝的行为叫做深拷贝,我们必须显式地定义拷贝构造函数才能达到深拷贝的目的。

深拷贝的例子比比皆是,除了上面的变长数组类,我们在《C++ throw关键字》一节中使用的动态数组类也需要深拷贝;此外,标准模板库(STL)中的 string、vector、stack、set、map 等也都必须使用深拷贝。

读者如果希望亲眼目睹不使用深拷贝的后果,可以将上例中的拷贝构造函数删除,那么运行结果将变为:

1
2
0, 1, 2, 29, 4, 100, 6, 7, 8, 9
0, 1, 2, 29, 4, 100, 6, 7, 8, 9

可以发现,更改 arr2 的数据也影响到了 arr1。这是因为,在创建 arr2 对象时,默认拷贝构造函数将 arr1.m_p 直接赋值给了 arr2.m_p,导致 arr2.m_p 和 arr1.m_p 指向了同一块内存,所以会相互影响。

另外需要注意的是,printArray() 函数的形参为引用类型,这样做能够避免在传参时调用拷贝构造函数;又因为 printArray() 函数不会修改任何数组元素,所以我们添加了 const 限制,以使得语义更加明确。

到底是浅拷贝还是深拷贝

如果一个类拥有指针类型的成员变量,那么绝大部分情况下就需要深拷贝,因为只有这样,才能将指针指向的内容再复制出一份来,让原有对象和新生对象相互独立,彼此之间不受影响。如果类的成员变量没有指针,一般浅拷贝足以。

另外一种需要深拷贝的情况就是在创建对象时进行一些预处理工作,比如统计创建过的对象的数目、记录对象创建的时间等,请看下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <iostream>
#include <ctime>
#include <windows.h> //在Linux和Mac下要换成 unistd.h 头文件
using namespace std;
class Base{
public:
Base(int a = 0, int b = 0);
Base(const Base &obj); //拷贝构造函数
public:
int getCount() const { return m_count; }
time_t getTime() const { return m_time; }
private:
int m_a;
int m_b;
time_t m_time; //对象创建时间
static int m_count; //创建过的对象的数目
};
int Base::m_count = 0;
Base::Base(int a, int b): m_a(a), m_b(b){
m_count++;
m_time = time((time_t*)NULL);
}
Base::Base(const Base &obj){ //拷贝构造函数
this->m_a = obj.m_a;
this->m_b = obj.m_b;
this->m_count++;
this->m_time = time((time_t*)NULL);
}
int main(){
Base obj1(10, 20);
cout<<"obj1: count = "<<obj1.getCount()<<", time = "<<obj1.getTime()<<endl;

Sleep(3000); //在Linux和Mac下要写作 sleep(3);

Base obj2 = obj1;
cout<<"obj2: count = "<<obj2.getCount()<<", time = "<<obj2.getTime()<<endl;
return 0;
}

运行结果:

obj1: count = 1, time = 1488344372

obj2: count = 2, time = 1488344375

运行程序,先输出第一行结果,等待 3 秒后再输出第二行结果。Base 类中的 m_time 和 m_count 分别记录了对象的创建时间和创建数目,它们在不同的对象中有不同的值,所以需要在初始化对象的时候提前处理一下,这样浅拷贝就不能胜任了,就必须使用深拷贝了。

C++重载=(赋值运算符)

在定义的同时进行赋值叫做初始化(Initialization),定义完成以后再赋值(不管在定义的时候有没有赋值)就叫做赋值(Assignment)。初始化只能有一次,赋值可以有多次。

当以拷贝的方式初始化一个对象时,会调用拷贝构造函数;当给一个对象赋值时,会调用重载过的赋值运算符。

即使我们没有显式的重载赋值运算符,编译器也会以默认地方式重载它。默认重载的赋值运算符功能很简单,就是将原有对象的所有成员变量一一赋值给新对象,这和默认拷贝构造函数的功能类似。

对于简单的类,默认的赋值运算符一般就够用了,我们也没有必要再显式地重载它。但是当类持有其它资源时,例如动态分配的内存、打开的文件、指向其他数据的指针、网络连接等,默认的赋值运算符就不能处理了,我们必须显式地重载它,这样才能将原有对象的所有数据都赋值给新对象。

仍然以上节的 Array 类为例,该类拥有一个指针成员,指向动态分配的内存。为了让 Array 类的对象之间能够正确地赋值,我们必须重载赋值运算符。请看下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
#include <iostream>
#include <cstdlib>
using namespace std;

//变长数组类
class Array{
public:
Array(int len);
Array(const Array &arr); //拷贝构造函数
~Array();
public:
int operator[](int i) const { return m_p[i]; } //获取元素(读取)
int &operator[](int i){ return m_p[i]; } //获取元素(写入)
Array & operator=(const Array &arr); //重载赋值运算符
int length() const { return m_len; }
private:
int m_len;
int *m_p;
};

Array::Array(int len): m_len(len){
m_p = (int*)calloc( len, sizeof(int) );
}

Array::Array(const Array &arr){ //拷贝构造函数
this->m_len = arr.m_len;
this->m_p = (int*)calloc( this->m_len, sizeof(int) );
memcpy( this->m_p, arr.m_p, m_len * sizeof(int) );
}

Array::~Array(){ free(m_p); }

Array &Array::operator=(const Array &arr){ //重载赋值运算符
if( this != &arr){ //判断是否是给自己赋值
this->m_len = arr.m_len;
free(this->m_p); //释放原来的内存
this->m_p = (int*)calloc( this->m_len, sizeof(int) );
memcpy( this->m_p, arr.m_p, m_len * sizeof(int) );
}
return *this;
}

//打印数组元素
void printArray(const Array &arr){
int len = arr.length();
for(int i=0; i<len; i++){
if(i == len-1){
cout<<arr[i]<<endl;
}else{
cout<<arr[i]<<", ";
}
}
}

int main(){
Array arr1(10);
for(int i=0; i<10; i++){
arr1[i] = i;
}
printArray(arr1);

Array arr2(5);
for(int i=0; i<5; i++){
arr2[i] = i;
}
printArray(arr2);
arr2 = arr1; //调用operator=()
printArray(arr2);
arr2[3] = 234; //修改arr1的数据不会影响arr2
arr2[7] = 920;
printArray(arr1);

return 0;
}

运行结果:

0, 1, 2, 3, 4, 5, 6, 7, 8, 9

0, 1, 2, 3, 4

0, 1, 2, 3, 4, 5, 6, 7, 8, 9

0, 1, 2, 3, 4, 5, 6, 7, 8, 9

将 arr1 赋值给 arr2 后,修改 arr2 的数据不会影响 arr1。如果把 operator=() 注释掉,那么运行结果将变为:

0, 1, 2, 3, 4, 5, 6, 7, 8, 9

0, 1, 2, 3, 4

0, 1, 2, 3, 4, 5, 6, 7, 8, 9

0, 1, 2, 234, 4, 5, 6, 920, 8, 9

去掉operator=()后,由于 m_p 指向的堆内存会被 free() 两次,所以还会导致内存错误。

下面我们就来分析一下重载过的赋值运算符。

  1. operator=() 的返回值类型为Array &,这样不但能够避免在返回数据时调用拷贝构造函数,还能够达到连续赋值的目的。下面的语句就是连续赋值:
1
arr4 = arr3 = arr2 = arr1;
  1. if( this != &arr)语句的作用是「判断是否是给同一个对象赋值」:如果是,那就什么也不做;如果不是,那就将原有对象的所有成员变量一一赋值给新对象,并为新对象重新分配内存。下面的语句就是给同一个对象赋值:
1
2
arr1 = arr1;
arr2 = arr2;

3.
return *this表示返回当前对象(新对象)。

4.
operator=() 的形参类型为const Array &,这样不但能够避免在传参时调用拷贝构造函数,还能够同时接收 const 类型和非 const 类型的实参,这一点已经在《C++拷贝构造函数》中进行了详细讲解。

5.
赋值运算符重载函数除了能有对象引用这样的参数之外,也能有其它参数。但是其它参数必须给出默认值,例如:

1
Array & operator=(const Array &arr, int a = 100);

C++ 拷贝控制操作(三/五法则)

当定义一个类时,我们显式地或隐式地指定了此类型的对象在拷贝、赋值和销毁时做什么。一个类通过定义三种特殊的成员函数来控制这些操作,分别是拷贝构造函数、赋值运算符和析构函数。

拷贝构造函数定义了当用同类型的另一个对象初始化新对象时做什么,赋值运算符定义了将一个对象赋予同类型的另一个对象时做什么,析构函数定义了此类型的对象销毁时做什么。我们将这些操作称为拷贝控制操作。

由于拷贝控制操作是由三个特殊的成员函数来完成的,所以我们称此为“C++ 三法则”。在较新的 C++ 11 标准中,为了支持移动语义,又增加了移动构造函数和移动赋值运算符,这样共有五个特殊的成员函数,所以又称为“C++ 五法则”。也就是说,“三法则”是针对较旧的 C++ 89 标准说的,“五法则”是针对较新的 C++ 11 标准说的。为了统一称呼,后来人们干把它叫做“C++ 三/五法则”。

如果一个类没有定义所有这些拷贝控制成员,编译器会自动为它定义默认的操作,因此很多类会忽略这些拷贝控制操作。但是,对于一些持有其他资源(例如动态分配的内存、打开的文件、指向其他数据的指针、网络连接等)的类来说,依赖这些默认的操作会导致灾难,我们必须显式的定义这些操作。

C++ 并不要求我们定义所有的这些操作,你可以只定义其中的一个或两个。但是,这些操作通常应该被看做一个整体,只需要定义其中一个操作,而不需要定义其他操作的情况很少见。

需要析构函数的类也需要拷贝和赋值操作

当我们决定是否要为一个类显式地定义拷贝构造函数和赋值运算符时,一个基本原则是首先确定这个类是否需要一个析构函数。通常,对析构函数的需求要比拷贝构造函数和赋值运算符的需求更加明显。如果一个类需要定义析构函数,那么几乎可以肯定这个类也需要一个拷贝构造函数和一个赋值运算符。

如果我们为 Array 定义了一个析构函数,但却使用默认的拷贝构造函数和赋值运算符,那么将导致不同对象之间相互干扰,修改一个对象的数据会影响另外的对象。此外还可能会导致内存操作错误,请看下面的代码:

Array func(Array arr){  //按值传递,将发生拷贝

Array ret = arr;  //发生拷贝

return ret;  //ret和arr将被销毁

}

当 func() 返回时,arr 和 ret 都会被销毁,在两个对象上都会调用析构函数,此析构函数会 free() 掉 m_p 成员所指向的动态内存。但是,这两个对象的 m_p 成员指向的是同一块内存,所以该内存会被 free() 两次,这显然是一个错误,将要发生什么是未知的。

此外,func() 的调用者还会继续使用传递给 func() 的对象:

1
2
3
Array arr1(10);
func(arr1); //当 func() 调用结束时,arr1.m_p 指向的内存被释放
Array arr2 = arr1; //现在 arr2 和 arr1 都指向无效内存

arr2(以及 arr1)指向的内存不再有效,在 arr(以及 ret)被销毁时系统已经归还给操作系统了。

总之,如果一个类需要定义析构函数,那么几乎可以肯定它也需要定义拷贝构造函数和赋值运算符。

需要拷贝操作的类也需要赋值操作,反之亦然

虽然很多类需要定义所有(或是不需要定义任何)拷贝控制成员,但某些类所要完成的工作,只需要拷贝或者赋值操作,不需要析构操作。

作为一个例子,考虑一个类为每个对象分配一个独有的、唯一的编号。这个类除了需要一个拷贝构造函数为每个新创建的对象生成一个新的编号,还需要一个赋值运算符来避免将一个对象的编号赋值给另外一个对象。但是,这个类并不需要析构函数。

这个例子引出了第二个基本原则:如果一个类需要一个拷贝构造函数,几乎可以肯定它也需要一个赋值运算符;反之亦然。然而,无论需要拷贝构造函数还是需要复制运算符,都不必然意味着也需要析构函数。

类型转换

在 C/C++ 中,不同的数据类型之间可以相互转换。无需用户指明如何转换的称为自动类型转换(隐式类型转换),需要用户显式地指明如何转换的称为强制类型转换

自动类型转换示例:

1
2
int a = 6;
a = 7.5 + a;

编译器对 7.5 是作为 double 类型处理的,在求解表达式时,先将 a 转换为 double 类型,然后与 7.5 相加,得到和为 13.5。在向整型变量 a 赋值时,将 13.5 转换为整数 13,然后赋给 a。整个过程中,我们并没有告诉编译器如何去做,编译器使用内置的规则完成数据类型的转换。

强制类型转换示例:

1
2
3
int n = 100;
int *p1 = &n;
float *p2 = (float*)p1;

p1 是int _类型,它指向的内存里面保存的是整数,p2 是float _类型,将 p1 赋值给 p2 后,p2 也指向了这块内存,并把这块内存中的数据作为小数处理。我们知道,整数和小数的存储格式大相径庭,将整数作为小数处理非常荒诞,可能会引发莫名其妙的错误,所以编译器默认不允许将 p1 赋值给 p2。但是,使用强制类型转换后,编译器就认为我们知道这种风险的存在,并进行了适当的权衡,所以最终还是允许了这种行为。

不管是自动类型转换还是强制类型转换,前提必须是编译器知道如何转换,例如,将小数转换为整数会抹掉小数点后面的数字,将int _转换为float _只是简单地复制指针的值,这些规则都是编译器内置的,我们并没有告诉编译器。

换句话说,如果编译器不知道转换规则就不能转换,使用强制类型也无用,请看下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>
using namespace std;
//复数类
class Complex{
public:
Complex(): m_real(0.0), m_imag(0.0){ }
Complex(double real, double imag): m_real(real), m_imag(imag){ }
public:
friend ostream & operator<<(ostream &out, Complex &c); //友元函数
private:
double m_real; //实部
double m_imag; //虚部
};
//重载>>运算符
ostream & operator<<(ostream &out, Complex &c){
out << c.m_real <<" + "<< c.m_imag <<"i";;
return out;
}
int main(){
Complex a(10.0, 20.0);
a = (Complex)25.5; //错误,转换失败
return 0;
}

25.5 是实数,a 是复数,将 25.5 赋值给 a 后,我们期望 a 的实部变为 25.5,而虚部为 0。但是,编译器并不知道这个转换规则,这超出了编译器的处理能力,所以转换失败,即使加上强制类型转换也无用。

幸运的是,C++ 允许我们自定义类型转换规则,用户可以将其它类型转换为当前类类型,也可以将当前类类型转换为其它类型。这种自定义的类型转换规则只能以类的成员函数的形式出现,换句话说,这种转换规则只适用于类。

我们先讲解如何将其它类型转换为当前类类型,然后再讲解如何将当前类类型转换为其它类型。

转换构造函数(将其它类型转换为当前类的类型)

将其它类型转换为当前类类型需要借助转换构造函数(Conversion constructor)。转换构造函数也是一种构造函数,它遵循构造函数的一般规则。转换构造函数只有一个参数。

将其它类型转换为当前类类型需要借助转换构造函数(Conversion constructor)。转换构造函数也是一种构造函数,它遵循构造函数的一般规则。转换构造函数只有一个参数。

仍然以 Complex 类为例,我们为它添加转换构造函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <iostream>
using namespace std;
//复数类
class Complex{
public:
Complex(): m_real(0.0), m_imag(0.0){ }
Complex(double real, double imag): m_real(real), m_imag(imag){ }
Complex(double real): m_real(real), m_imag(0.0){ } //转换构造函数
public:
friend ostream & operator<<(ostream &out, Complex &c); //友元函数
private:
double m_real; //实部
double m_imag; //虚部
};
//重载>>运算符
ostream & operator<<(ostream &out, Complex &c){
out << c.m_real <<" + "<< c.m_imag <<"i";;
return out;
}
int main(){
Complex a(10.0, 20.0);
cout<<a<<endl;
a = 25.5; //调用转换构造函数
cout<<a<<endl;
return 0;
}

运行结果:

10 + 20i

25.5 + 0i

Complex(double real);就是转换构造函数,它的作用是将 double 类型的参数 real 转换成 Complex 类的对象,并将 real 作为复数的实部,将 0 作为复数的虚部。这样一来,a = 25.5;整体上的效果相当于:

1
a.Complex(25.5);

将赋值的过程转换成了函数调用的过程。

在进行数学运算、赋值、拷贝等操作时,如果遇到类型不兼容、需要将 double 类型转换为 Complex 类型时,编译器会检索当前的类是否定义了转换构造函数,如果没有定义的话就转换失败,如果定义了的话就调用转换构造函数。

转换构造函数也是构造函数的一种,它除了可以用来将其它类型转换为当前类类型,还可以用来初始化对象,这是构造函数本来的意义。下面创建对象的方式是正确的:

1
2
3
Complex c1(26.4);  //创建具名对象
Complex c2 = 240.3; //以拷贝的方式初始化对象
Complex(15.9); //创建匿名对象

c1 = Complex(46.9);  //创建一个匿名对象并将它赋值给 c1

在以拷贝的方式初始化对象时,编译器先调用转换构造函数,将 240.3 转换为 Complex 类型(创建一个 Complex 类的匿名对象),然后再拷贝给 c2。

如果已经对+运算符进行了重载,使之能进行两个 Complex 类对象的相加,那么下面的语句也是正确的:

1
2
3
4
Complex c1(15.6, 89.9);
Complex c2;
c2 = c1 + 29.6;
cout<<c2<<endl;

在进行加法运算符时,编译器先将 29.6 转换为 Complex 类型(创建一个 Complex 类的匿名对象)再相加。

需要注意的是,为了获得目标类型,编译器会“不择手段”,会综合使用内置的转换规则和用户自定义的转换规则,并且会进行多级类型转换,例如:

  • 编译器会根据内置规则先将 int 转换为 double,再根据用户自定义规则将 double 转换为 Complex(int –> double –> Complex);
  • 编译器会根据内置规则先将 char 转换为 int,再将 int 转换为 double,最后根据用户自定义规则将 double 转换为 Complex(char –> int –> double –> Complex)。

从本例看,只要一个类型能转换为 double 类型,就能转换为 Complex 类型。请看下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
int main(){
Complex c1 = 100; //int --> double --> Complex
cout<<c1<<endl;
c1 = 'A'; //char --> int --> double --> Complex
cout<<c1<<endl;
c1 = true; //bool --> int --> double --> Complex
cout<<c1<<endl;
Complex c2(25.8, 0.7);
//假设已经重载了+运算符
c1 = c2 + 'H' + true + 15; //将char、bool、int都转换为Complex类型再运算
cout<<c1<<endl;
return 0;
}

运行结果:

100 + 0i

65 + 0i

1 + 0i

113.8 + 0.7i

学了这么多构造函数回顾一下吧!点击这里C++ 中的构造函数

以上Complex类可以使用函数的默认参数精简,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <iostream>
using namespace std;
//复数类
class Complex{
public:
Complex(double real = 0.0, double imag = 0.0): m_real(real), m_imag(imag){ }
public:
friend ostream & operator<<(ostream &out, Complex &c); //友元函数
private:
double m_real; //实部
double m_imag; //虚部
};
//重载>>运算符
ostream & operator<<(ostream &out, Complex &c){
out << c.m_real <<" + "<< c.m_imag <<"i";;
return out;
}
int main(){
Complex a(10.0, 20.0); //向构造函数传递 2 个实参,不使用默认参数
Complex b(89.5); //向构造函数传递 1 个实参,使用 1 个默认参数
Complex c; //不向构造函数传递实参,使用全部默认参数
a = 25.5; //调用转换构造函数(向构造函数传递 1 个实参,使用 1 个默认参数)
return 0;
}

精简后的构造函数包含了两个默认参数,在调用它时可以省略部分或者全部实参,也就是可以向它传递 0 个、1 个、2 个实参。转换构造函数就是包含了一个参数的构造函数,恰好能够和其他两个普通的构造函数“融合”在一起。

类型转换函数(将当前类的类型转换为其它类型)

转换构造函数能够将其它类型转换为当前类类型(例如将 double 类型转换为 Complex 类型),但是不能反过来将当前类类型转换为其它类型(例如将 Complex 类型转换为 double 类型)。

C++ 提供了类型转换函数(Type conversion function)来解决这个问题。类型转换函数的作用就是将当前类类型转换为其它类型,它只能以成员函数的形式出现,也就是只能出现在类中。

类型转换函数的语法格式为:

1
2
3
4
operator type(){
//TODO:
return data;
}

operator 是 C++ 关键字,type 是要转换的目标类型,data 是要返回的 type 类型的数据。

因为要转换的目标类型是 type,所以返回值 data 也必须是 type 类型。既然已经知道了要返回 type 类型的数据,所以没有必要再像普通函数一样明确地给出返回值类型。这样做导致的结果是:类型转换函数看起来没有返回值类型,其实是隐式地指明了返回值类型。

类型转换函数也没有参数,因为要将当前类的对象转换为其它类型,所以参数不言而喻。实际上编译器会把当前对象的地址赋值给 this 指针,这样在函数体内就可以操作当前对象了。

为 Complex 类添加类型转换函数,使得 Complex 类型能够转换为 double 类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <iostream>
using namespace std;
//复数类
class Complex{
public:
Complex(): m_real(0.0), m_imag(0.0){ }
Complex(double real, double imag): m_real(real), m_imag(imag){ }
public:
friend ostream & operator<<(ostream &out, Complex &c);
friend Complex operator+(const Complex &c1, const Complex &c2);
operator double() const { return m_real; } //类型转换函数
private:
double m_real; //实部
double m_imag; //虚部
};
//重载>>运算符
ostream & operator<<(ostream &out, Complex &c){
out << c.m_real <<" + "<< c.m_imag <<"i";;
return out;
}
//重载+运算符
Complex operator+(const Complex &c1, const Complex &c2){
Complex c;
c.m_real = c1.m_real + c2.m_real;
c.m_imag = c1.m_imag + c2.m_imag;
return c;
}
int main(){
Complex c1(24.6, 100);
double f = c1; //相当于 double f = Complex::operator double(&c1);
cout<<"f = "<<f<<endl;
f = 12.5 + c1 + 6; //相当于 f = 12.5 + Complex::operator double(&c1) + 6;
cout<<"f = "<<f<<endl;
int n = Complex(43.2, 9.3); //先转换为 double,再转换为 int
cout<<"n = "<<n<<endl;
return 0;
}

运行结果:

f = 24.6

f = 43.1

n = 43

本例中,类型转换函数非常简单,就是返回成员变量 m_real 的值,所以建议写成 inline 的形式。

类型转换函数和运算符的重载非常相似,都使用 operator 关键字,因此也把类型转换函数称为类型转换运算符

关于类型转换函数的说明

①type 可以是内置类型、类类型以及由 typedef 定义的类型别名,任何可作为函数返回类型的类型(void 除外)都能够被支持。一般而言,不允许转换为数组或函数类型,转换为指针类型或引用类型是可以的。

②类型转换函数一般不会更改被转换的对象,所以通常被定义为 const 成员。

③类型转换函数可以被继承,可以是虚函数。

④一个类虽然可以有多个类型转换函数(类似于函数重载),但是如果多个类型转换函数要转换的目标类型本身又可以相互转换(类型相近),那么有时候就会产生二义性。以 Complex 类为例,假设它有两个类型转换函数:

1
2
operator double() const { return m_real; }  //转换为double类型
operator int() const { return (int)m_real; } //转换为int类型

那么下面的写法就会引发二义性:

1
2
Complex c1(24.6, 100);
float f = 12.5 + c1;

编译器可以调用 operator double() 将 c1 转换为 double 类型,也可以调用 operator int() 将 c1 转换为 int 类型,这两种类型都可以跟 12.5 进行加法运算,并且从 Complex 转换为 double 与从 Complex 转化为 int 是平级的,没有谁的优先级更高,所以这个时候编译器就不知道该调用哪个函数了,干脆抛出一个二义性错误,让用户解决。

C++转换构造函数和类型转换函数参产生的二义性

转换构造函数和类型转换函数的作用是相反的:转换构造函数会将其它类型转换为当前类类型,类型转换函数会将当前类类型转换为其它类型。如果没有这两个函数,Complex 类和 int、double、bool 等基本类型的四则运算、逻辑运算都将变得非常复杂,要编写大量的运算符重载函数。

但是,如果一个类同时存在这两个函数,就有可能产生二义性。下面以 Complex 类为例来演示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <iostream>
using namespace std;
//复数类
class Complex{
public:
Complex(double real = 0.0, double imag = 0.0): m_real(real), m_imag(imag){ } //包含了转换构造函数
public:
friend ostream & operator<<(ostream &out, Complex &c);
friend Complex operator+(const Complex &c1, const Complex &c2);
operator double() const { return m_real; } //类型转换函数
private:
double m_real; //实部
double m_imag; //虚部
};
//重载>>运算符
ostream & operator<<(ostream &out, Complex &c){
out << c.m_real <<" + "<< c.m_imag <<"i";;
return out;
}
//重载+运算符
Complex operator+(const Complex &c1, const Complex &c2){
Complex c;
c.m_real = c1.m_real + c2.m_real;
c.m_imag = c1.m_imag + c2.m_imag;
return c;
}
int main(){
Complex c1(24.6, 100);
double f = c1; //①正确,调用类型转换函数
c1 = 78.4; //②正确,调用转换构造函数
f = 12.5 + c1; //③错误,产生二义性
Complex c2 = c1 + 46.7; //④错误,产生二义性
return 0;
}

①和②是正确的,相信大家很容易理解。

对于③,进行加法运算时,有两种转换方案:

  • 第一种方案是先将 12.5 转换为 Complex 类型再运算,这样得到的结果也是 Complex 类型,再调用类型转换函数就可以赋值给 f 了。
  • 第二种方案是先将 c1 转换为 double 类型再运算,这样得到的结果也是 double 类型,可以直接赋值给 f。

很多读者会认为,既然=左边是 double 类型,很显然应该选择第二种方案,这样才符合“常理”。其实不然,编译器不会根据=左边的数据类型来选择转换方案,编译器只关注12.5 + c1这个表达式本身,站在这个角度考虑,上面的两种转换方案都可以,编译器不知道选择哪一种,所以会抛出二义性错误,让用户自己去解决。

当然,你也可以认为编译器不够智能,没有足够强大的上下文(周边环境)推导能力。反过来说,即使我们假设编译器会根据=左边的数据类型来选择解决方案,那仍然会存在二义性问题,下面就是一个例子:

1
2
Complex c1(24.6, 100);
cout<<c1 + 46.7<<endl;

该语句没有将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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
#include <iostream>
using namespace std;
//复数类
class Complex{
public: //构造函数
Complex(double real = 0.0, double imag = 0.0): m_real(real), m_imag(imag){ } //包含了转换构造函数
public: //运算符重载
//以全局函数的形式重载
friend ostream & operator<<(ostream &out, Complex &c);
friend istream & operator>>(istream &in, Complex &c);
friend Complex operator+(const Complex &c1, const Complex &c2);
friend bool operator==(const Complex &c1, const Complex &c2);
friend bool operator!=(const Complex &c1, const Complex &c2);
//以成员函数的形式重载
Complex & operator+=(const Complex &c);
public: //成员函数
double real() const{ return m_real; }
double imag() const{ return m_imag; }
private:
double m_real; //实部
double m_imag; //虚部
};
//重载>>运算符
ostream & operator<<(ostream &out, Complex &c){
out << c.m_real <<" + "<< c.m_imag <<"i";;
return out;
}
//重载<<运算符
istream & operator>>(istream &in, Complex &c){
in >> c.m_real >> c.m_imag;
return in;
}
//重载+运算符
Complex operator+(const Complex &c1, const Complex &c2){
Complex c;
c.m_real = c1.m_real + c2.m_real;
c.m_imag = c1.m_imag + c2.m_imag;
return c;
}
//重载+=运算符
Complex & Complex::operator+=(const Complex &c){
this->m_real += c.m_real;
this->m_imag += c.m_imag;
return *this;
}
//重载==运算符
bool operator==(const Complex &c1, const Complex &c2){
if( c1.m_real == c2.m_real && c1.m_imag == c2.m_imag ){
return true;
}else{
return false;
}
}
//重载!=运算符
bool operator!=(const Complex &c1, const Complex &c2){
if( c1.m_real != c2.m_real || c1.m_imag != c2.m_imag ){
return true;
}else{
return false;
}
}
int main(){
Complex c1(12, 60);
cout<<"c1 = "<<c1<<endl;
//先调用转换构造函数将 22.8 转换为 Complex 类型,再调用重载过的 + 运算符
Complex c2 = c1 + 22.8;
cout<<"c2 = "<<c2<<endl;
//同上
Complex c3 = 8.3 + c1;
cout<<"c3 = "<<c3<<endl;
//先调用转换构造函数将 73 转换为 Complex 类型,再调用重载过的 += 运算符
Complex c4(4, 19);
c4 += 73;
cout<<"c4 = "<<c4<<endl;
//调用重载过的 += 运算符
Complex c5(14.6, 26.2);
c5 += c1;
cout<<"c5 = "<<c5<<endl;

//调用重载过的 == 运算符
if(c1 == c2){
cout<<"c1 == c2"<<endl;
}else{
cout<<"c1 != c2"<<endl;
}

//先调用转换构造函数将 77 转换为 Complex 类型,再调用重载过的 != 运算符
if(c4 != 77){
cout<<"c4 != 77"<<endl;
}else{
cout<<"c4 == 77"<<endl;
}
//将 Complex 转换为 double,没有调用类型转换函数,而是调用了 real() 这个普通的成员函数
double f = c5.real();
cout<<"f = "<<f<<endl;

return 0;
}

运行结果:

c1 = 12 + 60i

c2 = 34.8 + 60i

c3 = 20.3 + 60i

c4 = 77 + 19i

c5 = 26.6 + 86.2i

c1 != c2

c4 != 77

f = 26.6

C/C++类型转换的本质

在 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#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++ 之所以增加强制类型转换的语法,是为了提醒程序员这样做存在风险,一定要谨慎小心。说得通俗一点,你现在的类型转换存在风险,你自己一定要知道。

强制类型转换也不是万能的

类型转换只能发生在相关类型或者相近类型之间,两个毫不相干的类型不能相互转换,即使使用强制类型转换也不行。例如,两个没有继承关系的类不能相互转换,基类不能向派生类转换(向下转型),类类型不能向基本类型转换,指针和类类型之间不能相互转换。

下面的代码演示了不相干类型之间的转换:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#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;
}

C++ static_cast、dynamic_cast、const_cast和reinterpret_cast(四种类型转换运算符)

隐式类型转换是安全的,显式类型转换是有风险的,C语言之所以增加强制类型转换的语法,就是为了强调风险,让程序员意识到自己在做什么。

但是,这种强调风险的方式还是比较粗放,粒度比较大,它并没有表明存在什么风险,风险程度如何。再者,C风格的强制类型转换统一使用( ),而( )在代码中随处可见,所以也不利于使用文本检索工具(例如 Windows 下的 Ctrl+F、Linux 下的 grep 命令、Mac 下的 Command+F)定位关键代码。

为了使潜在风险更加细化,使问题追溯更加方便,使书写格式更加规范,C++ 对类型转换进行了分类,并新增了四个关键字来予以支持,它们分别是:

关键字 说明
static_cast 用于良性转换,一般不会导致意外发生,风险很低。
const_cast 用于 const 与非 const、volatile 与非 volatile 之间的转换。
reinterpret_cast 高度危险的转换,这种转换仅仅是对二进制位的重新解释,不会借助已有的转换规则对数据进行调整,但是可以实现最灵活的 C++ 类型转换。
dynamic_cast 借助 RTTI(运行时类型识别机制),用于类型安全的向下转型(Downcasting)。

这四个关键字的语法格式都是一样的,具体为:

1
xxx_cast<newType>(data)

newType 是要转换成的新类型,data 是被转换的数据。例如,老式的C风格的 double 转 int 的写法为:

1
2
double scores = 95.5;
int n = (int)scores;

C++ 新风格的写法为:

1
2
double scores = 95.5;
int n = static_cast<int>(scores);

static_cast 关键字

static_cast 只能用于良性转换,这样的转换风险较低,一般不会发生什么意外,例如:

  • 原有的自动类型转换,例如 short 转 int、int 转 double、const 转非 const、向上转型等;
  • void 指针和具体类型指针之间的转换,例如void _转int _、char _转void _等;
  • 有转换构造函数或者类型转换函数的类与其它类型之间的转换,例如 double 转 Complex(调用转换构造函数)、Complex 转 double(调用类型转换函数)。

需要注意的是,static_cast 不能用于无关类型之间的转换,因为这些转换都是有风险的,例如:

  • 两个具体类型指针之间的转换,例如int _转double _、Student _转int _等。不同类型的数据存储格式不一样,长度也不一样,用 A 类型的指针指向 B 类型的数据后,会按照 A 类型的方式来处理数据:如果是读取操作,可能会得到一堆没有意义的值;如果是写入操作,可能会使 B 类型的数据遭到破坏,当再次以 B 类型的方式读取数据时会得到一堆没有意义的值。
  • int 和指针之间的转换。将一个具体的地址赋值给指针变量是非常危险的,因为该地址上的内存可能没有分配,也可能没有读写权限,恰好是可用内存反而是小概率事件。

static_cast 也不能用来去掉表达式的 const 修饰和 volatile 修饰。换句话说,不能将 const/volatile 类型转换为非 const/volatile 类型。

static_cast 是“静态转换”的意思,也就是在编译期间转换,转换失败的话会抛出一个编译错误。

下面的代码演示了 static_cast 的正确用法和错误用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <iostream>
#include <cstdlib>
using namespace std;
class Complex{
public:
Complex(double real = 0.0, double imag = 0.0): m_real(real), m_imag(imag){ }
public:
operator double() const { return m_real; } //类型转换函数
private:
double m_real;
double m_imag;
};
int main(){
//下面是正确的用法
int m = 100;
Complex c(12.5, 23.8);
long n = static_cast<long>(m); //宽转换,没有信息丢失
char ch = static_cast<char>(m); //窄转换,可能会丢失信息
int *p1 = static_cast<int*>( malloc(10 * sizeof(int)) ); //将void指针转换为具体类型指针
void *p2 = static_cast<void*>(p1); //将具体类型指针,转换为void指针
double real= static_cast<double>(c); //调用类型转换函数

//下面的用法是错误的
float *p3 = static_cast<float*>(p1); //不能在两个具体类型的指针之间进行转换
p3 = static_cast<float*>(0X2DF9); //不能将整数转换为指针类型
return 0;
}

const_cast 关键字

const_cast 比较好理解,它用来去掉表达式的 const 修饰或 volatile 修饰。换句话说,const_cast 就是用来将 const/volatile 类型转换为非 const/volatile 类型。

下面我们以 const 为例来说明 const_cast 的用法:

1
2
3
4
5
6
7
8
9
10
#include <iostream>
using namespace std;
int main(){
const int n = 100;
int *p = const_cast<int*>(&n);
*p = 234;
cout<<"n = "<<n<<endl;
cout<<"*p = "<<*p<<endl;
return 0;
}

运行结果:

n = 100

*p = 234

&n用来获取 n 的地址,它的类型为const int *,必须使用 const_cast 转换为int *类型后才能赋值给 p。由于 p 指向了 n,并且 n 占用的是栈内存,有写入权限,所以可以通过 p 修改 n 的值。

有读者可能会问,为什么通过 n 和 *p 输出的值不一样呢?这是因为 C++ 对常量的处理更像是编译时期的#define,是一个值替换的过程,代码中所有使用 n 的地方在编译期间就被替换成了 100。换句话说,第 8 行代码被修改成了下面的形式:

1
cout<<"n = "<<100<<endl;

这样以来,即使程序在运行期间修改 n 的值,也不会影响 cout 语句了。

使用 const_cast 进行强制类型转换可以突破 C/C++ 的常数限制,修改常数的值,因此有一定的危险性;但是程序员如果这样做的话,基本上会意识到这个问题,因此也还有一定的安全性。

reinterpret_cast 关键字

reinterpret 是“重新解释”的意思,顾名思义,reinterpret_cast 这种转换仅仅是对二进制位的重新解释,不会借助已有的转换规则对数据进行调整,非常简单粗暴,所以风险很高。

reinterpret_cast 可以认为是 static_cast 的一种补充,一些 static_cast 不能完成的转换,就可以用 reinterpret_cast 来完成,例如两个具体类型指针之间的转换、int 和指针之间的转换(有些编译器只允许 int 转指针,不允许反过来)。

下面的代码代码演示了 reinterpret_cast 的使用:

#include

using namespace std;

class A{

public:

A(int a = 0, int b = 0): m_a(a), m_b(b){}

private:

int m_a;

int m_b;

};

int main(){

//将 char_ 转换为 float_

char str[]=”http://c.biancheng.net“;

float _p1 = reinterpret_cast(str);

cout<<*p1<<endl;

//将 int 转换为 int*

int _p = reinterpret_cast(100);

//将 A_ 转换为 int_

p = reinterpret_cast<int*>(new A(25, 96));

cout<<*p<<endl;

1
return 0;

}

运行结果:

3.0262e+29

25

可以想象,用一个 float 指针来操作一个 char 数组是一件多么荒诞和危险的事情,这样的转换方式不到万不得已的时候不要使用。将A*转换为int*,使用指针直接访问 private 成员刺穿了一个类的封装性,更好的办法是让类提供 get/set 函数,间接地访问成员变量。

dynamic_cast 关键字

dynamic_cast 用于在类的继承层次之间进行类型转换,它既允许向上转型(Upcasting),也允许向下转型(Downcasting)。向上转型是无条件的,不会进行任何检测,所以都能成功;向下转型的前提必须是安全的,要借助 RTTI 进行检测,所有只有一部分能成功。

dynamic_cast 与 static_cast 是相对的,dynamic_cast 是“动态转换”的意思,static_cast 是“静态转换”的意思。dynamic_cast 会在程序运行期间借助 RTTI 进行类型转换,这就要求基类必须包含虚函数;static_cast 在编译期间完成类型转换,能够更加及时地发现错误。

dynamic_cast 的语法格式为:

1
dynamic_cast <newType> (expression)

newType 和 expression 必须同时是指针类型或者引用类型。换句话说,dynamic_cast 只能转换指针类型和引用类型,其它类型(int、double、数组、类、结构体等)都不行。

对于指针,如果转换失败将返回 NULL;对于引用,如果转换失败将抛出std::bad_cast异常。

1. 向上转型(Upcasting)

向上转型时,只要待转换的两个类型之间存在继承关系,并且基类包含了虚函数(这些信息在编译期间就能确定),就一定能转换成功。因为向上转型始终是安全的,所以 dynamic_cast 不会进行任何运行期间的检查,这个时候的 dynamic_cast 和 static_cast 就没有什么区别了。

「向上转型时不执行运行期检测」虽然提高了效率,但也留下了安全隐患,请看下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <iostream>
#include <iomanip>
using namespace std;
class Base{
public:
Base(int a = 0): m_a(a){ }
int get_a() const{ return m_a; }
virtual void func() const { }
protected:
int m_a;
};
class Derived: public Base{
public:
Derived(int a = 0, int b = 0): Base(a), m_b(b){ }
int get_b() const { return m_b; }
private:
int m_b;
};
int main(){
//情况①
Derived *pd1 = new Derived(35, 78);
Base *pb1 = dynamic_cast<Derived*>(pd1);
cout<<"pd1 = "<<pd1<<", pb1 = "<<pb1<<endl;
cout<<pb1->get_a()<<endl;
pb1->func();
//情况②
int n = 100;
Derived *pd2 = reinterpret_cast<Derived*>(&n);
Base *pb2 = dynamic_cast<Base*>(pd2);
cout<<"pd2 = "<<pd2<<", pb2 = "<<pb2<<endl;
cout<<pb2->get_a()<<endl; //输出一个垃圾值
pb2->func(); //内存错误
return 0;
}

情况①是正确的,没有任何问题。对于情况②,pd 指向的是整型变量 n,并没有指向一个 Derived 类的对象,在使用 dynamic_cast 进行类型转换时也没有检查这一点,而是将 pd 的值直接赋给了 pb(这里并不需要调整偏移量),最终导致 pb 也指向了 n。因为 pb 指向的不是一个对象,所以get_a()得不到 m_a 的值(实际上得到的是一个垃圾值),pb2->func()也得不到 func() 函数的正确地址。

pb2->func()得不到 func() 的正确地址的原因在于,pb2 指向的是一个假的“对象”,它没有虚函数表,也没有虚函数表指针,而 func() 是虚函数,必须到虚函数表中才能找到它的地址。

2. 向下转型(Downcasting)

向下转型是有风险的,dynamic_cast 会借助 RTTI 信息进行检测,确定安全的才能转换成功,否则就转换失败。那么,哪些向下转型是安全地呢,哪些又是不安全的呢?下面我们通过一个例子来演示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
#include <iostream>
using namespace std;
class A{
public:
virtual void func() const { cout<<"Class A"<<endl; }
private:
int m_a;
};
class B: public A{
public:
virtual void func() const { cout<<"Class B"<<endl; }
private:
int m_b;
};
class C: public B{
public:
virtual void func() const { cout<<"Class C"<<endl; }
private:
int m_c;
};
class D: public C{
public:
virtual void func() const { cout<<"Class D"<<endl; }
private:
int m_d;
};
int main(){
A *pa = new A();
B *pb;
C *pc;

//情况①
pb = dynamic_cast<B*>(pa); //向下转型失败
if(pb == NULL){
cout<<"Downcasting failed: A* to B*"<<endl;
}else{
cout<<"Downcasting successfully: A* to B*"<<endl;
pb -> func();
}
pc = dynamic_cast<C*>(pa); //向下转型失败
if(pc == NULL){
cout<<"Downcasting failed: A* to C*"<<endl;
}else{
cout<<"Downcasting successfully: A* to C*"<<endl;
pc -> func();
}

cout<<"-------------------------"<<endl;

//情况②
pa = new D(); //向上转型都是允许的
pb = dynamic_cast<B*>(pa); //向下转型成功
if(pb == NULL){
cout<<"Downcasting failed: A* to B*"<<endl;
}else{
cout<<"Downcasting successfully: A* to B*"<<endl;
pb -> func();
}
pc = dynamic_cast<C*>(pa); //向下转型成功
if(pc == NULL){
cout<<"Downcasting failed: A* to C*"<<endl;
}else{
cout<<"Downcasting successfully: A* to C*"<<endl;
pc -> func();
}

return 0;
}

运行结果:

1
2
3
4
5
6
7
Downcasting failed: A* to B*
Downcasting failed: A* to C*
-------------------------
Downcasting successfully: A* to B*
Class D
Downcasting successfully: A* to C*
Class D

这段代码中类的继承顺序为:A –> B –> C –> D。pa 是A*类型的指针,当 pa 指向 A 类型的对象时,向下转型失败,pa 不能转换为B*C*类型。当 pa 指向 D 类型的对象时,向下转型成功,pa 可以转换为B*C*类型。同样都是向下转型,为什么 pa 指向的对象不同,转换的结果就大相径庭呢?

在《C++ RTTI机制下的对象内存模型(透彻)》一节中,我们讲到了有虚函数存在时对象的真实内存模型,并且也了解到,每个类都会在内存中保存一份类型信息,编译器会将存在继承关系的类的类型信息使用指针“连接”起来,从而形成一个继承链(Inheritance Chain),也就是如下图所示的样子:

当使用 dynamic_cast 对指针进行类型转换时,程序会先找到该指针指向的对象,再根据对象找到当前类(指针指向的对象所属的类)的类型信息,并从此节点开始沿着继承链向上遍历,如果找到了要转化的目标类型,那么说明这种转换是安全的,就能够转换成功,如果没有找到要转换的目标类型,那么说明这种转换存在较大的风险,就不能转换。

对于本例中的情况①,pa 指向 A 类对象,根据该对象找到的就是 A 的类型信息,当程序从这个节点开始向上遍历时,发现 A 的上方没有要转换的 B 类型或 C 类型(实际上 A 的上方没有任何类型了),所以就转换败了。对于情况②,pa 指向 D 类对象,根据该对象找到的就是 D 的类型信息,程序从这个节点向上遍历的过程中,发现了 C 类型和 B 类型,所以就转换成功了。

总起来说,dynamic_cast 会在程序运行过程中遍历继承链,如果途中遇到了要转换的目标类型,那么就能够转换成功,如果直到继承链的顶点(最顶层的基类)还没有遇到要转换的目标类型,那么就转换失败。对于同一个指针(例如 pa),它指向的对象不同,会导致遍历继承链的起点不一样,途中能够匹配到的类型也不一样,所以相同的类型转换产生了不同的结果。

从表面上看起来 dynamic_cast 确实能够向下转型,本例也很好地证明了这一点:B 和 C 都是 A 的派生类,我们成功地将 pa 从 A 类型指针转换成了 B 和 C 类型指针。但是从本质上讲,dynamic_cast 还是只允许向上转型,因为它只会向上遍历继承链。造成这种假象的根本原因在于,派生类对象可以用任何一个基类的指针指向它,这样做始终是安全的。本例中的情况②,pa 指向的对象是 D 类型的,pa、pb、pc 都是 D 的基类的指针,所以它们都可以指向 D 类型的对象,dynamic_cast 只是让不同的基类指针指向同一个派生类对象罢了。