第9章 C++面向对象进阶总结
C++拷贝构造函数(复制构造函数)
在 C++ 中,拷贝并没有脱离它本来的含义,只是将这个含义进行了“特化”,是指用已经存在的对象创建出一个新的对象。从本质上讲,对象也是一份数据,因为它会占用内存。
严格来说,对象的创建包括两个阶段,首先要分配内存空间,然后再进行初始化:
- 分配内存很好理解,就是在堆区、栈区或者全局数据区留出足够多的字节。这个时候的内存还比较“原始”,没有被“教化”,它所包含的数据一般是零值或者随机值,没有实际的意义。
- 初始化就是首次对内存赋值,让它的数据有意义。注意是首次赋值,再次赋值不叫初始化。初始化的时候还可以为对象分配其他的资源(打开文件、连接网络、动态分配内存等),或者提前进行一些计算(根据价格和数量计算出总价、根据长度和宽度计算出矩形的面积等)等。说白了,初始化就是调用构造函数。
很明显,这里所说的拷贝是在初始化阶段进行的,也就是用其它对象的数据来初始化新对象的内存。(个人:这也就是拷贝构造函数的意义,它用在创建一个对象时,用来进行初始化,而初始化是用一个相同的类型的对象来做的,通常的复制操作,调用的是复制运算符,而不是拷贝构造函数,因为复制的对象已经存在,不是没有创建)
那么,如何用拷贝的方式来初始化一个对象呢?其实这样的例子比比皆是,string 类就是一个典型的例子。
#include <iostream> #include <string> using namespace std; void func(string str) { cout << str << endl; } int main() { string s1 = "http://www.baidu.com"; string s2(s1); string s3 = s1; string s4 = s1 + " " + s2; func(s1); cout << s1 << endl << s2 << endl << s3 << endl << s4 << endl; return 0; }
s1、s2、s3、s4 以及 func() 的形参 str,都是使用拷贝的方式来初始化的。
对于 s1,表面上看起来是将一个字符串直接赋值给了 s1,实际上在内部进行了类型转换,将 const char * 类型转换为 string 类型后才赋值的,s4 也是类似的道理。
- 对于 s1、s2、s3、s4,都是将其它对象的数据拷贝给当前对象,以完成当前对象的初始化。
- 对于 func() 的形参str,其实在定义时就为它分配了内存,但是此时并没有初始化,只有等到调用 func() 时,才会将其它对象的数据拷贝给 str 以完成初始化。(个人:初始化时首次赋值,再次赋值不叫初始化)
当以拷贝的方式初始化一个对象时,会调用一个特殊的构造函数,就是拷贝构造函数(Copy Constructor)。(个人:在创建一个对象时调用,并且是使用一个相同类型的对象进行初始化操作)
#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; }
拷贝构造函数只有一个参数,它的类型是当前类的引用,而且一般都是 const 引用。
- 为什么必须是当前类的引用呢?如果拷贝构造函数的参数不是当前类的引用,而是当前类的对象,那么在调用拷贝构造函数时,会将另外一个对象直接传递给形参,这本身就是一次拷贝,会再次调用拷贝构造函数,然后又将一个对象直接传递给了形参,将继续调用拷贝构造函数……这个过程会一直持续下去,没有尽头,陷入死循环。只有当参数是当前类的引用时,才不会导致再次调用拷贝构造函数,这不仅是逻辑上的要求,也是 C++ 语法的要求。
- 为什么是 const 引用呢?
- 拷贝构造函数的目的是用其它对象的数据来初始化当前对象,并没有期望更改其它对象的数据,添加 const 限制后,这个含义更加明确了。
- 另外一个原因是,添加 const 限制后,可以将 const 对象和非 const 对象传递给形参了,因为非 const 类型可以转换为 const 类型。如果没有 const 限制,就不能将 const 对象传递给形参,因为 const 类型不能转换为非 const 类型,这就意味着,不能使用 const 对象来初始化当前对象了。
如果程序员没有显式地定义拷贝构造函数,那么编译器会自动生成一个默认的拷贝构造函数。这个默认的拷贝构造函数很简单,就是使用“老对象”的成员变量对“新对象”的成员变量进行一一赋值,和上面 Student 类的拷贝构造函数非常类似。对于简单的类,默认拷贝构造函数一般是够用的,我们也没有必要再显式地定义一个功能类似的拷贝构造函数。但是当类持有其它资源时,如动态分配的内存、打开的文件、指向其他数据的指针、网络连接等,默认拷贝构造函数就不能拷贝这些资源,我们必须显式地定义拷贝构造函数,以完整地拷贝对象的所有数据,这点我们将在《C++深拷贝和浅拷贝》一节中深入讲解。
到底什么时候会调用拷贝构造函数?
当以拷贝的方式初始化对象时,会调用拷贝构造函数。这里有两个关键点,分别是
- 「以拷贝的方式」
- 「初始化对象」
初始化对象是指,为对象分配内存后,第一次向内存中填充数据,这个过程会调用构造函数。对象被创建后必须立即被初始化,换句话说,只要创建对象,就会调用构造函数。
初始化和赋值都是将数据写入内存中,并且从表面上看起来,初始化在很多时候都是以赋值的方式来实现的,所以很容易混淆。请看下面的例子:
int a = 100; //以赋值的方式初始化 a = 200; //赋值 a = 300; //赋值 int b; //默认初始化 b = 29; //赋值 b = 39; //赋值
- 在定义的同时进行赋值叫做初始化(Initialization),(个人:也就是初始化发生时,对象刚刚在创建)
- 定义完成以后再赋值(不管在定义的时候有没有赋值)就叫做赋值(Assignment)。
- 初始化只能有一次,赋值可以有多次。
对于基本类型的数据,我们很少会区分「初始化」和「赋值」这两个概念,即使将它们混淆,也不会出现什么错误。但是对于类,它们的区别就非常重要了,因为:
- 初始化时会调用构造函数(以拷贝的方式初始化时会调用拷贝构造函数),
- 而赋值时会调用重载过的赋值运算符。(个人:也就是对于类来说,初始化和赋值内部调用的是不同的函数,所以对于类来说,初始化和赋值是不同的,是需要区分的)
请看下面的例子:
#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; }
初始化对象时会调用构造函数,不同的初始化方式会调用不同的构造函数:
- 如果用传递进来的实参初始化对象,那么会调用普通的构造函数,我们不妨将此称为普通初始化;
- 如果用其它对象(现有对象)的数据来初始化对象,那么会调用拷贝构造函数,这就是以拷贝的方式初始化。(个人:也就是构造函数的参数只有一个,为其他的同类型对象,利用这个同类型的对象的数据来初始化本对象)
在实际编程中,具体有哪些情况是以拷贝的方式来初始化对象呢?
- 将其它对象作为实参,
Student stu1("小明", 16, 90.5); //普通初始化 Student stu2(stu1); //以拷贝的方式初始化
即使我们不在类中显式地定义拷贝构造函数,这种初始化方式也是有效的,因为编译器会生成默认的拷贝构造函数。
- 在创建对象的同时赋值,(个人:这里看着是赋值,其实是初始化,因为这时stu2在创建,调用的一定是构造函数,创建一个对象时,不可能调用赋值运算符)
Student stu1("小明", 16, 90.5); //普通初始化 Student stu2 = stu1; //以拷贝的方式初始化
这是最常见的一种以拷贝的方式初始化对象的情况,非常容易理解,我们也已经多次使用。
- 函数的形参为类类型,
void func(Student s){ //TODO: } Student stu("小明", 16, 90.5); //普通初始化 func(stu); //以拷贝的方式初始化
函数是一段可以重复使用的代码,只有等到真正调用函数时,才会为局部数据(形参和局部变量)在栈上分配内存。对于上面的 func(),虽然它的形参 s 是一个对象,但在定义函数时s对象并没有被创建,只有等到调用函数时,才会真正地创建 s 对象,并在栈上为它分配内存。而创建 s 对象,就是以拷贝的方式进行的,它等价于下面的代码:
Student s = stu;
- 函数返回值为类类型,
当函数的返回值为类类型时,return 语句会返回一个对象,不过为了防止局部对象被销毁,也为了防止通过返回值修改原来的局部对象,编译器并不会直接返回这个对象,而是根据这个对象先创建出一个临时对象(匿名对象),再将这个临时对象返回。而创建临时对象的过程,就是以拷贝的方式进行的,会调用拷贝构造函数。
下面的代码演示了返回一个对象的情形:
Student func(){ Student s("小明", 16, 90.5); return s; } Student stu = func();
理论上讲,运行代码后会调用两次拷贝构造函数,一次是返回s对象时,另外一次是创建 stu 对象时。
- 在较老的编译器上,你或许真的能够看到调用两次拷贝构造函数,例如 iPhone 上的 C/C++ 编译器。
- 但是在现代编译器上,只会调用一次拷贝构造函数,或者一次也不调用,例如在 VS2010 下会调用一次拷贝构造函数,在 GCC、Xcode 下一次也不会调用。
#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; } Student func(){ Student s("小明", 16, 90.5); return s; } int main() { Student stu = func(); return 0; }
(个人:实际实验之,vstudio2022拷贝构造函数调用了一次,mingw64一次也没调用)
这是因为,现代编译器都支持返回值优化技术,会尽量避免拷贝对象,以提高程序运行效率。关于编译器如何实现返回值优化的我们不再展开讨论,有兴趣的读者请猛击:
http://blog.csdn.net/sad_sugar/article/details/50569434
http://www.jianshu.com/p/f3b8c3e4f2ad
C++深拷贝和浅拷贝
对于基本类型的数据以及简单的对象,它们之间的拷贝非常简单,就是按位复制内存。(个人:也就是把内存里的东西原封的复制过去,当然了,如果对象持有资源,复制的只是对象的数据成员的内存,即对资源句柄的复制,至于资源本身,没有复制,只是复制了指向资源的句柄或者指向资源的指针),例如:
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() 函数的效果非常类似。
- 对于简单的类,默认的拷贝构造函数一般就够用了,我们也没有必要再显式地定义一个功能类似的拷贝构造函数。
- 但是当类持有其它资源时,例如动态分配的内存、指向其他数据的指针等,默认的拷贝构造函数就不能拷贝这些资源了,我们必须显式地定义拷贝构造函数,以完整地拷贝对象的所有数据。
#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; }
本例中,我们显式地定义了拷贝构造函数,它除了会将原有对象的所有成员变量拷贝给新对象,还会为新对象再分配一块内存,并将原有对象所持有的资源也拷贝过来。这样做的结果是,原有对象和新对象所持有的动态内存是相互独立的,更改一个对象的数据不会影响另外一个对象,本例中我们更改了 arr2 的数据,就没有影响 arr1。
这种将对象所持有的其它资源一并拷贝的行为叫做深拷贝,我们必须显式地定义拷贝构造函数才能达到深拷贝的目的。
深拷贝的例子比比皆是,除了上面的变长数组类,标准模板库(STL)中的 string、vector、stack、set、map 等也都必须使用深拷贝。
到底是浅拷贝还是深拷贝?
- 如果一个类拥有指针类型的成员变量,那么绝大部分情况下就需要深拷贝,因为只有这样,才能将指针指向的内容再复制出一份来,让原有对象和新生对象相互独立,彼此之间不受影响。
- 如果类的成员变量没有指针,一般浅拷贝足以。
- 另外一种需要深拷贝的情况就是在创建对象时进行一些预处理工作,比如统计创建过的对象的数目、记录对象创建的时间等,请看下面的例子:
#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; }
C++重载=(赋值运算符)
即使我们没有显式的重载赋值运算符,编译器也会以默认的方式重载它。默认重载的赋值运算符功能很简单,就是将原有对象的所有成员变量一一赋值给新对象,这和默认拷贝构造函数的功能类似。
对于简单的类,默认的赋值运算符一般就够用了,我们也没有必要再显式地重载它。但是当类持有其它资源时,例如动态分配的内存、打开的文件、指向其他数据的指针、网络连接等,默认的赋值运算符就不能处理了,我们必须显式地重载它,这样才能将原有对象的所有数据都赋值给新对象。
#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; //修改arr2的数据不会影响arr1 arr2[7] = 920; printArray(arr1); return 0; }
- operator=() 的返回值类型为Array &,这样不但能够避免在返回数据时调用拷贝构造函数,还能够达到连续赋值的目的。(个人:也就是一般的情况下,arr2 = arr1,这是一个表达式,执行这个表达式,会调用左操作数的赋值运算符,如果,不返回值,返回void,似乎也合理,但是这样却不能连续赋值,为了连续赋值和避免返回时调用拷贝构造,设定为arr2=arr1这个表达式的返回值为Array &),下面的语句就是连续赋值:
arr4 = arr3 = arr2 = arr1;
- if( this != &arr)语句的作用是「判断是否是给同一个对象赋值」:如果是,那就什么也不做;如果不是,那就将原有对象的所有成员变量一一赋值给新对象,并为新对象重新分配内存。下面的语句就是给同一个对象赋值:
arr1 = arr1; arr2 = arr2;
- return *this表示返回当前对象(新对象)。
- operator=() 的形参类型为const Array &,这样不但能够避免在传参时调用拷贝构造函数,还能够同时接收 const 类型和非 const 类型的实参,
- 赋值运算符重载函数除了能有对象引用这样的参数之外,也能有其它参数。但是其它参数必须给出默认值,(个人:肯定的,赋值运算符是二元运算符,如果没有给出默认值,岂不是三元,四元运算符),例如:
Array & operator=(const Array &arr, int a = 100);
C++拷贝控制操作(三/五法则)
当定义一个类时,我们显式地或隐式地指定了此类型的对象在拷贝、赋值和销毁时做什么。一个类通过定义三种特殊的成员函数来控制这些操作,分别是拷贝构造函数、赋值运算符和析构函数。
- 拷贝构造函数定义了当用同类型的另一个对象初始化新对象时做什么,
- 赋值运算符定义了将一个对象赋予同类型的另一个对象时做什么,
- 析构函数定义了此类型的对象销毁时做什么。
我们将这些操作称为拷贝控制操作。(个人:拷贝控制操作,拷贝:拷贝构造,赋值;消耗:析构;这几个操作都设计到资源的复制和销毁,所以作为一个整体存在)
由于拷贝控制操作是由三个特殊的成员函数来完成的,所以我们称此为“C++三法则”。在较新的 C++11 标准中,为了支持移动语义,又增加了移动构造函数和移动赋值运算符,这样共有五个特殊的成员函数,所以又称为“C++五法则”。也就是说,“三法则”是针对较旧的 C++89 标准说的,“五法则”是针对较新的 C++11 标准说的。为了统一称呼,后来人们干把它叫做“C++ 三/五法则”。
如果一个类没有定义所有这些拷贝控制成员,编译器会自动为它定义默认的操作,因此很多类会忽略这些拷贝控制操作。但是,对于一些持有其他资源(例如动态分配的内存、打开的文件、指向其他数据的指针、网络连接等)的类来说,依赖这些默认的操作会导致灾难,我们必须显式的定义这些操作。
C++ 并不要求我们定义所有的这些操作,你可以只定义其中的一个或两个。但是,这些操作通常应该被看做一个整体,只需要定义其中一个操作,而不需要定义其他操作的情况很少见。(个人:这几个操作,彼此相关,牵一发而动全身)
- 需要析构函数的类也需要拷贝和赋值操作:当我们决定是否要为一个类显式地定义拷贝构造函数和赋值运算符时,一个基本原则是首先确定这个类是否需要一个析构函数。通常,对析构函数的需求要比拷贝构造函数和赋值运算符的需求更加明显。如果一个类需要定义析构函数,那么几乎可以肯定这个类也需要一个拷贝构造函数和一个赋值运算符。(个人:如果一个类需要定义析构函数,那么说明这个类往往持有资源,那么对这个类的构造,赋值必然设计到资源的拷贝,这时,当然需要定制拷贝构造函数和赋值运算符)
- 需要拷贝操作的类也需要赋值操作,反之亦然。
- 虽然很多类需要定义所有(或是不需要定义任何)拷贝控制成员,但某些类所要完成的工作,只需要拷贝或者赋值操作,不需要析构操作。(个人:也就是此时类并不持有资源,只是需要在构造或者赋值时有某种特殊的操作要执行,这时,当然不需要析构函数来释放资源),作为一个例子,考虑一个类为每个对象分配一个独有的、唯一的编号。这个类除了需要一个拷贝构造函数为每个新创建的对象生成一个新的编号,还需要一个赋值运算符来避免将一个对象的编号赋值给另外一个对象。但是,这个类并不需要析构函数。这个例子引出了第二个基本原则:如果一个类需要一个拷贝构造函数,几乎可以肯定它也需要一个赋值运算符;反之亦然。然而,无论需要拷贝构造函数还是需要复制运算符,都不必然意味着也需要析构函数。(个人:析构函数的主要判断标准是是否有释放资源的需求)
C++转换构造函数
不管是自动类型转换还是强制类型转换,前提必须是编译器知道如何转换,例如,将小数转换为整数会抹掉小数点后面的数字,这些规则都是编译器内置的,我们并没有告诉编译器。 换句话说,如果编译器不知道转换规则就不能转换,使用强制类型也无用,请看下面的例子:
#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)。转换构造函数也是一种构造函数,它遵循构造函数的一般规则。转换构造函数只有一个参数。
#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) { cout<<"this called is 转换构造函数"<<endl; } //转换构造函数 Complex & operator = (const Complex & rhs){ cout<<"this called is 赋值运算符"<<endl; if(this!=&rhs){ this->m_real = rhs.m_real; this->m_imag = rhs.m_imag; return *this; }else{ return *this; } } 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; }
转换构造函数也是构造函数的一种,它除了可以用来将其它类型转换为当前类类型,还可以用来初始化对象,这是构造函数本来的意义。下面创建对象的方式是正确的:
Complex c1(26.4); //创建具名对象 Complex c2 = 240.3; //以拷贝的方式初始化对象 Complex(15.9); //创建匿名对象 c1 = Complex(46.9); //创建一个匿名对象并将它赋值给 c
在以拷贝的方式初始化对象时,编译器先调用转换构造函数,将 240.3 转换为 Complex 类型(创建一个 Complex 类的匿名对象),然后再拷贝给 c2。
在进行数学运算、赋值、拷贝等操作时,如果遇到类型不兼容,(个人:如果有转换规则,编译器会自动干这件事),需要将 double 类型转换为 Complex 类型时,编译器会检索当前的类是否定义了转换构造函数,如果没有定义的话就转换失败,如果定义了的话就调用转换构造函数。
需要注意的是,为了获得目标类型,编译器会“不择手段”,会综合使用内置的转换规则和用户自定义的转换规则,并且会进行多级类型转换,例如:
- 编译器会根据内置规则先将 int 转换为 double,再根据用户自定义规则将 double 转换为 Complex(int --> double --> Complex);
- 编译器会根据内置规则先将 char 转换为 int,再将 int 转换为 double,最后根据用户自定义规则将 double 转换为 Complex(char --> int --> double --> Complex)。
从本例看,只要一个类型能转换为 double 类型,就能转换为 Complex 类型。请看下面的例子:
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; }
C++类型转换函数(重载强制类型转换运算符)
转换构造函数能够将其它类型转换为当前类类型(例如将 double 类型转换为 Complex 类型),但是不能反过来将当前类类型转换为其它类型,例如将 Complex 类型转换为 double 类型(因为转换构造函数本质上是构造函数)。
C++ 提供了类型转换函数(Type conversion function)来解决这个问题。类型转换函数的作用就是将当前类类型转换为其它类型,它只能以成员函数的形式出现,也就是只能出现在类中。(个人:也就是重载强制类型转换运算符),类型转换函数的语法格式为:
operator type(){ //TODO: return data; }
operator 是 C++ 关键字,type 是要转换的目标类型,data 是要返回的 type 类型的数据。
- 因为要转换的目标类型是 type,所以返回值 data 也必须是 type 类型。既然已经知道了要返回 type 类型的数据,所以没有必要再像普通函数一样明确地给出返回值类型。这样做导致的结果是:类型转换函数看起来没有返回值类型,其实是隐式地指明了返回值类型。
- 类型转换函数也没有参数,因为要将当前类的对象转换为其它类型,所以参数不言而喻。(个人:这是个一元运算符,又是类的成员函数,所以没有参数),实际上编译器会把当前对象的地址赋值给 this 指针,这样在函数体内就可以操作当前对象了。
#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; }
本例中,类型转换函数非常简单,就是返回成员变量 m_real 的值,所以建议写成 inline 的形式。
类型转换函数和运算符的重载非常相似,都使用operator 关键字,因此也把类型转换函数称为类型转换运算符。(个人:类型转换函数定义后,我们既可以在代码中显示的强制类型转换,也可以在运算中如果编译器觉得有必要,会在背后自动调用尝试可行的转换方案,我们不需要在代码中显示调用)
关于类型转换函数的说明:
- type 可以是内置类型、类类型以及由 typedef 定义的类型别名,任何可作为函数返回类型的类型(void 除外)都能够被支持。一般而言,不允许转换为数组或函数类型,转换为指针类型或引用类型是可以的。
- 类型转换函数一般不会更改被转换的对象,所以通常被定义为 const 成员。
- 类型转换函数可以被继承,可以是虚函数。
- 一个类虽然可以有多个类型转换函数(类似于函数重载),但是如果多个类型转换函数要转换的目标类型本身又可以相互转换(类型相近),那么有时候就会产生二义性。以Complex 类为例,假设它有两个类型转换函数:(个人:这两个类型转换函数的转换类型int和double类型相近,可以相互转化)
operator double() const { return m_real; } //转换为double类型 operator int() const { return (int)m_real; } //转换为int类型
那么下面的写法就会引发二义性:
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 等基本类型的四则运算、逻辑运算都将变得非常复杂,要编写大量的运算符重载函数。 但是,如果一个类同时存在这两个函数,就有可能产生二义性。(个人:都是自定义的转换规则,编译器都可以自动的在背后调用,谁并不比谁高级,优先)
#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这个表达式本身,站在这个角度考虑,上面的两种转换方案都可以,编译器不知道选择哪一种,所以会抛出二义性错误,让用户自己去解决。(个人:也就是编译器只会关心转后回到表达式只要这个表达式是合法的,能运算就行)
当然,你也可以认为编译器不够智能,没有足够强大的上下文(周边环境)推导能力。反过来说,即使我们假设编译器会根据=左边的数据类型来选择解决方案,那仍然会存在二义性问题,下面就是一个例子:
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风格的字符串,(个人:返回类型为const char *),complex 类使用 real() 和 imag() 函数来获取复数的实部和虚部。
C/C++类型转换的本质(经典)
type *是一个具体类型的指针,例如int *、double *、Student *等,它们都可以直接赋值给void *指针。而反过来是不行的,必须使用强制类型转换才能将void *转换为type *,例如,malloc() 分配内存后返回的就是一个void *指针,我们必须进行强制类型转换后才能赋值给指针变量。
- 数据是放在内存中的,变量(以及指针、引用)是给这块内存起的名字,有了变量就可以找到并使用这份数据。
- 但问题是,该如何使用呢?诸如数字、文字、符号、图形、音频、视频等数据都是以二进制形式存储在内存中的,它们并没有本质上的区别,(个人:也就是在内存中都是以二进制的形式存放的),那么,00010000 该理解为数字 16 呢,还是图像中某个像素的颜色呢,还是要发出某个声音呢?如果没有特别指明,我们并不知道。
- 也就是说,内存中的数据有多种解释方式,使用之前必须要确定。这种「确定数据的解释方式」的工作就是由数据类型(Data Type)来完成的。例如int a;表明a这份数据是整数,不能理解为像素、声音、视频等。顾名思义,数据类型用来说明数据的类型,确定了数据的解释方式,让计算机和程序员不会产生歧义。
所谓数据类型转换,就是对数据所占用的二进制位做出重新解释。如果有必要,在重新解释的同时还会修改数据,改变它的二进制位。
对于隐式类型转换,编译器可以根据已知的转换规则来决定是否需要修改数据的二进制位;
修改数据的二进制位非常重要,它能把转换后的数据调整到正确的值,所以这种修改时常会发生,例如:
- 整数和浮点数在内存中的存储形式大相径庭,将浮点数 f 赋值给整数 i 时,不能原样拷贝 f 的二进制位,也不能截取部分二进制位,必须先将 f 的二进制位读取出来,以浮点数的形式呈现,然后直接截掉小数部分,把剩下的整数部分再转换成二进制形式,拷贝到 i 所在的内存中。(个人:这里float转换为int编译器是知道规则的)
- short 一般占用两个字节,int 一般占用四个字节,将 short 类型的 s 赋值给 int 类型的 i 时,如果仅仅是将 s 的二进制位拷贝给 i,那么 i 最后的两个字节会原样保留,这样会导致赋值结束后 i 的值并不等于 s 的值,所以这样做是错误的。正确的做法是,先给 s 添加 16 个二进制位(两个字节)并全部置为 0,然后再拷贝给 i 所在的内存。(个人:同样的,编译器是知道short转换为int时的转换规则的)
- 当存在多重继承时,如果把派生类指针 pd 赋值给基类指针 pb,就必须考虑基类子对象在派生类对象中的偏移,偏移不为 0 时就要调整 pd 的值,让它加上或减去偏移量,这样赋值后才能让 pb 恰好指向基类子对象。(个人:同样的,编译器是知道这个规则的)
- 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; }
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; }
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)。 |
这四个关键字的语法格式都是一样的,具体为:
xxx_cast<newType>(data)
newType 是要转换成的新类型,data 是被转换的数据。例如,老式的C风格的 double 转 int 的写法为:
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 和指针之间的转换。将一个具体的地址赋值给指针变量是非常危险的,因为该地址上的内存可能没有分配,也可能没有读写权限,恰好是可用内存反而是小概率事件。(个人:直接将一个指针赋给一个int,是错误的,
但是,我们如果使用强制类型转换,就可以转换了,但是风险我们自己承担,并且编译器不会修改数据,
反之,类似,
)
static_cast 也不能用来去掉表达式的 const 修饰和 volatile 修饰。换句话说,不能将 const/volatile 类型转换为非 const/volatile 类型。
static_cast 是“静态转换”的意思,也就是在编译期间转换,转换失败的话会抛出一个编译错误。
下面的代码演示了 static_cast 的正确用法和错误用法:
#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 的用法:
#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; }
(个人:直接将一个int常量变量的指针赋给一个普通的int *指针,是错误的,但是我们可以使用强制类型转换,可以转换,但是编译器不会修改数据)
使用 const_cast 进行强制类型转换可以突破 C/C++ 的常数限制,修改常数的值,因此有一定的危险性;但是程序员如果这样做的话,基本上会意识到这个问题,因此也还有一定的安全性。
reinterpret_cast 关键字
- reinterpret是“重新解释”的意思,顾名思义,reinterpret_cast 这种转换仅仅是对二进制位的重新解释,不会借助已有的转换规则对数据进行调整,非常简单粗暴,所以风险很高。
- reinterpret_cast 可以认为是 static_cast 的一种补充,一些 static_cast 不能完成的转换,就可以用 reinterpret_cast 来完成,例如两个具体类型指针之间的转换、int 和指针之间的转换(有些编译器只允许 int 转指针,不允许反过来)。
下面的代码代码演示了 reinterpret_cast 的使用:
#include <iostream> 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://www.cdsy.xyz"; float *p1 = reinterpret_cast<float*>(str); cout<<*p1<<endl; //将 int 转换为 int* int *p = reinterpret_cast<int*>(100); //将 A* 转换为 int* p = reinterpret_cast<int*>(new A(25, 96)); cout<<*p<<endl; return 0; }
可以想象,用一个 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 的语法格式为:
dynamic_cast <newType> (expression)
- newType 和 expression 必须同时是指针类型或者引用类型。换句话说,dynamic_cast 只能转换指针类型和引用类型,其它类型(int、double、数组、类、结构体等)都不行。
- 对于指针,如果转换失败将返回 NULL;对于引用,如果转换失败将抛出std::bad_cast异常。
1) 向上转型(Upcasting)
向上转型时,只要待转换的两个类型之间存在继承关系,并且基类包含了虚函数(这些信息在编译期间就能确定),就一定能转换成功。因为向上转型始终是安全的,所以 dynamic_cast 不会进行任何运行期间的检查,这个时候的 dynamic_cast 和 static_cast 就没有什么区别了。
「向上转型时不执行运行期检测」虽然提高了效率,但也留下了安全隐患,请看下面的代码:
#include <iostream> 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<Base*>(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 信息进行检测,确定安全的才能转换成功,否则就转换失败。那么,哪些向下转型是安全地呢,哪些又是不安全的呢?下面我们通过一个例子来演示:
#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; }
在C++ RTTI机制下的对象内存模型一节中,我们讲到了有虚函数存在时对象的真实内存模型,并且也了解到,每个类都会在内存中保存一份类型信息,编译器会将存在继承关系的类的类型信息使用指针“连接”起来,从而形成一个继承链(Inheritance Chain),也就是如下图所示的样子:
当使用 dynamic_cast 对指针进行类型转换时,程序会先找到该指针指向的对象,再根据对象找到当前类(指针指向的对象所属的类)的类型信息,并从此节点开始沿着继承链向上遍历,如果找到了要转化的目标类型,那么说明这种转换是安全的,就能够转换成功,如果没有找到要转换的目标类型,那么说明这种转换存在较大的风险,就不能转换。
总起来说,dynamic_cast 会在程序运行过程中遍历继承链,如果途中遇到了要转换的目标类型,那么就能够转换成功,如果直到继承链的顶点(最顶层的基类)还没有遇到要转换的目标类型,那么就转换失败。
对于同一个指针(例如 pa),它指向的对象不同,会导致遍历继承链的起点不一样,途中能够匹配到的类型也不一样,所以相同的类型转换产生了不同的结果。
从表面上看起来 dynamic_cast 确实能够向下转型,本例也很好地证明了这一点:B 和 C 都是 A 的派生类,我们成功地将 pa 从 A 类型指针转换成了 B 和 C 类型指针。但是从本质上讲,dynamic_cast 还是只允许向上转型,因为它只会向上遍历继承链。造成这种假象的根本原因在于,派生类对象可以用任何一个基类的指针指向它,这样做始终是安全的。本例中的情况②,pa 指向的对象是 D 类型的,pa、pb、pc 都是 D 的基类的指针,所以它们都可以指向 D 类型的对象,dynamic_cast 只是让不同的基类指针指向同一个派生类对象罢了。