C++ 系列 -- 面向对象进阶

C++ 之面向对象进阶内容包括:拷贝构造函数、深拷贝浅拷贝、转换构造函数、类型转换函数

深拷贝和浅拷贝

对象创建的阶段

严格来说,对象的创建包括两个阶段,首先要分配内存空间,然后再进行初始化:

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

拷贝的本质

很明显,这里所说的拷贝是在初始化阶段 进行的,也就是用其它对象的数据来初始化新对象的内存

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

cpp 复制代码
#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;
    func(s1);
    cout<<s1<<endl<<s2<<endl<<s3<<endl;
   
    return 0;
}

运行结果:

txt 复制代码
http://c.biancheng.net  
http://c.biancheng.net  
http://c.biancheng.net  
http://c.biancheng.net
  • 对于 s1、s2、s3、s4,都是将其它对象的数据拷贝给当前对象,以完成当前对象的初始化
  • 对于 func() 的形参 str,其实在定义时就为它分配了内存,但是此时并没有初始化,只有等到调用 func() 时,才会将其它对象的数据拷贝给 str 以完成初始化

拷贝构造函数

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

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

cpp 复制代码
#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;
}

运行结果:

txt 复制代码
Copy constructor was called.  
Copy constructor was called.  
小明的年龄是16,成绩是90.5  
小明的年龄是16,成绩是90.5  
小明的年龄是16,成绩是90.5

默认拷贝构造函数

如果没有显式地定义 拷贝构造函数,那么编译器会自动 生成一个默认的拷贝构造函数

这个默认的拷贝构造函数很简单,就是使用"老对象"的成员变量对"新对象"的成员变量进行一一赋值,和上面 Student 类的拷贝构造函数非常类似

对于简单的类,默认拷贝构造函数一般是够用的,我们也没有必要再显式地定义一个功能类似的拷贝构造函数。

但是当类持有其它资源时,如动态分配的内存、打开的文件、指向其他数据的指针、网络连接 等,默认拷贝构造函数就不能拷贝 这些资源,我们必须显式地定义 拷贝构造函数,以完整地拷贝对象的所有数据

浅拷贝

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

cpp 复制代码
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 所在 内存中的数据 按照二进制位复制 到 b 和 obj2 所在的内存,这种默认的拷贝行为就是浅拷贝,这和调用 memcpy() 函数的效果非常类似

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

深拷贝

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

cpp 复制代码
#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;
}

运行结果:

txt 复制代码
0, 1, 2, 3, 4, 5, 6, 7, 8, 9  
0, 1, 2, 29, 4, 100, 6, 7, 8, 9

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

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

如果将上例中的拷贝构造函数删除,那么运行结果将变为:

txt 复制代码
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 限制,以使得语义更加明确。

到底是浅拷贝还是深拷贝

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

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

cpp 复制代码
#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;
}

运行结果:

txt 复制代码
obj1: count = 1, time = 1488344372  
obj2: count = 2, time = 1488344375

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

重载=(赋值运算符)

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

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

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

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

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

cpp 复制代码
#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;
}

运行结果:

txt 复制代码
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=() 注释掉,那么运行结果将变为:

txt 复制代码
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 &,这样不但能够避免在返回数据时调用拷贝构造函数,还能够达到连续赋值的目的。下面的语句就是连续赋值:
cpp 复制代码
arr4 = arr3 = arr2 = arr1;
  1. if( this != &arr)语句的作用是「判断是否是给同一个对象赋值」:如果是,那就什么也不做;如果不是,那就将原有对象的所有成员变量一一赋值给新对象,并为新对象重新分配内存。下面的语句就是给同一个对象赋值:
cpp 复制代码
arr1 = arr1;  
arr2 = arr2;
  1. return *this表示返回当前对象(新对象)。

  2. operator=() 的形参类型为const Array &,这样不但能够避免在传参时调用拷贝构造函数,还能够同时接收 const 类型和非 const 类型的实参

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

cpp 复制代码
Array & operator=(const Array &arr, int a = 100);

转换构造函数

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

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

自动类型转换示例:

cpp 复制代码
int a = 6;
a = 7.5 + a;

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

强制类型转换示例:

cpp 复制代码
int n = 100;
int *p1 = &n;
float *p2 = (float*)p1;

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

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

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

cpp 复制代码
#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++ 允许我们自定义类型转换规则,用户可以将其它类型转换为当前类类型,也可以将当前类类型转换为其它类型。这种自定义的类型转换规则只能以类的成员函数的形式出现,换句话说,这种转换规则只适用于类。

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

转换构造函数

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

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

cpp 复制代码
#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;
}

运行结果:

txt 复制代码
10 + 20i  
25.5 + 0i 

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

txt 复制代码
a.Complex(25.5);

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

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

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

cpp 复制代码
Complex c1(26.4);  // 创建具名对象
Complex c2 = 240.3;  // 以拷贝的方式初始化对象
Complex(15.9);  // 创建匿名对象
c1 = Complex(46.9);  // 创建一个匿名对象并将它赋值给 c1

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

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

cpp 复制代码
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 类型。请看下面的例子:

cpp 复制代码
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;
}

运行结果:

txt 复制代码
100 + 0i  
65 + 0i  
1 + 0i  
113.8 + 0.7i 

再谈构造函数

构造函数的本意是在创建对象的时候初始化对象,编译器会根据传递的实参来匹配不同的(重载的)构造函数。回顾一下以前的章节,到目前为止我们已经学习了以下几种构造函数。

  1. 默认构造函数。就是编译器自动生成的构造函数。以 Complex 类为例,它的原型为:
cpp 复制代码
Complex();  //没有参数
  1. 普通构造函数。就是用户自定义的构造函数。以 Complex 类为例,它的原型为:
cpp 复制代码
Complex(double real, double imag);  //两个参数
  1. 拷贝构造函数。在以拷贝的方式初始化对象时调用。以 Complex 类为例,它的原型为:
cpp 复制代码
Complex(const Complex &c);
  1. 转换构造函数。将其它类型转换为当前类类型时调用。以 Complex 为例,它的原型为:
cpp 复制代码
Complex(double real);

不管哪一种构造函数,都能够用来初始化对象,这是构造函数的本意。假设 Complex 类定义了以上所有的构造函数,那么下面创建对象的方式都是正确的:

cpp 复制代码
Complex c1();  //调用Complex()
Complex c2(10, 20);  //调用Complex(double real, double imag)
Complex c3(c2);  //调用Complex(const Complex &c)
Complex c4(25.7);  //调用Complex(double real)

这些代码都体现了构造函数的本意------在创建对象时初始化对象。

除了在创建对象时初始化对象,其他情况下也会调用构造函数,例如,以拷贝的的方式初始化对象时会调用拷贝构造函数,将其它类型转换为当前类类型时会调用转换构造函数。这些在其他情况下调用的构造函数,就成了特殊的构造函数了。特殊的构造函数并不一定能体现出构造函数的本意。

对 Complex 类的进一步精简

上面的 Complex 类中我们定义了三个构造函数,其中包括两个普通的构造函数和一个转换构造函数。其实,借助函数的默认参数,我们可以将这三个构造函数简化为一个,请看下面的代码:

cpp 复制代码
#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++ 提供了类型转换函数来解决这个问题。类型转换函数的作用就是将当前类类型转换为其它类型,它只能以成员函数的形式出现,也就是只能出现在类中。

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

cpp 复制代码
operator type(){  
    // TODO:  
    return data;  
}

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

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

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

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

cpp 复制代码
#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;
}

运行结果:

txt 复制代码
f = 24.6  
f = 43.1  
n = 43  

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

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

关于类型转换函数的说明

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

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

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

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

cpp 复制代码
operator double() const { return m_real; }  //转换为double类型
operator int() const { return (int)m_real; }  //转换为int类型

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

cpp 复制代码
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语言之所以增加强制类型转换的语法,就是为了强调风险,让程序员意识到自己在做什么。

但是,这种强调风险的方式还是比较粗放,粒度比较大,它并没有表明存在什么风险,风险程度如何。再者,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)

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

cpp 复制代码
xxx_cast<newType>(data)

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

cpp 复制代码
double scores = 95.5;int n = (int)scores;

C++ 新风格的写法为:

cpp 复制代码
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 的正确用法和错误用法:

cpp 复制代码
#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 的用法:

cpp 复制代码
#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 行代码被修改成了下面的形式:

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 的使用:

cpp 复制代码
#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://c.biancheng.net";
    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;
}

运行结果:

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 的语法格式为:

cpp 复制代码
dynamic_cast <newType> (expression)

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

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

向上转型

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

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

cpp 复制代码
#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() 是虚函数,必须到虚函数表中才能找到它的地址。

向下转型

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

cpp 复制代码
#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;
}

运行结果:

txt 复制代码
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 指向的对象不同,转换的结果就大相径庭呢?

每个类都会在内存中保存一份类型信息,编译器会将存在继承关系的类的类型信息使用指针"连接"起来,从而形成一个继承链(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 只是让不同的基类指针指向同一个派生类对象罢了。

相关推荐
半盏茶香19 分钟前
【C语言】分支和循环详解(下)猜数字游戏
c语言·开发语言·c++·算法·游戏
小堇不是码农25 分钟前
在VScode中配置C_C++环境
c语言·c++·vscode
Jack黄从零学c++27 分钟前
C++ 的异常处理详解
c++·经验分享
捕鲸叉6 小时前
创建线程时传递参数给线程
开发语言·c++·算法
A charmer6 小时前
【C++】vector 类深度解析:探索动态数组的奥秘
开发语言·c++·算法
Peter_chq6 小时前
【操作系统】基于环形队列的生产消费模型
linux·c语言·开发语言·c++·后端
青花瓷8 小时前
C++__XCode工程中Debug版本库向Release版本库的切换
c++·xcode
幺零九零零9 小时前
【C++】socket套接字编程
linux·服务器·网络·c++
捕鲸叉9 小时前
MVC(Model-View-Controller)模式概述
开发语言·c++·设计模式
Dola_Pan10 小时前
C++算法和竞赛:哈希算法、动态规划DP算法、贪心算法、博弈算法
c++·算法·哈希算法