Loading

C++ 基础系列——面向对象进阶

一、拷贝构造函数

拷贝指用已经存在的对象创建出一个新的对象,拷贝是在初始化阶段进行的,也就是用其他对象的数据来初始化新对象的内存。


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

int main()
{
    string s1 = "asdf";     // 拷贝
    string s2(s1);          // 拷贝
    string s3 = s1;         // 拷贝
    string s4 = s1 + " " + s2;  // 拷贝
    func(s1);   // 拷贝
    return 0;
}

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

当以拷贝的方式初始化一个对象时,会调用拷贝构造函数(copy constructor)。

// 拷贝构造函数的定义和使用

class Student{
public:
    Student(string name="", int age=0; float score = 0.0f){};
    Student(const Student &stu);    // 声明拷贝构造函数
private:
    string m_name;
    int m_age;
    float m_score;
};

Student::Student(const Student &stu){
    this->m_name = stu.m_name;
    this->m_age = stu.m_age;
    this->m_score = stu.m_score;
}

int main(){
    Student stu1("小明", 15, 98.3);
    Student stu2 = stu1;    // 拷贝构造函数
    Student stu3(stu1);     // 拷贝构造函数
    return 0;
}
  1. 拷贝构造函数参数必须是引用,如果不是,实参传形参本身就是一次拷贝,会再次调用拷贝构造函数,这个过程会一直持续下去,陷入死循环。
  2. const 引用保证不修改原本对象的数据,此外非 const 对象和 const 对象都可以传。

如果没有显式定义拷贝构造函数,编译器会自动生成一个默认的拷贝构造函数,这个默认构造函数与上面的非常类似,使用老对象的成员变量对新对象的成员变量一一赋值。

当类持有其他资源,如动态分配内存、打开文件、指向其他数据的指针、网络连接等,默认构造函数不能拷贝这些资源,必须显式定义拷贝构造函数。

二、什么时候会调用拷贝构造函数

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

以拷贝方式进行初始化的情况:

  • 将其他对象作为实参

    Student stu2(stu1);

  • 在创建对象的同时赋值

    Student stu2 = stu1;

  • 函数的形参为类类型

    void func(Student s){}

  • 函数返回值为类类型

    Student func()

三、深拷贝和浅拷贝

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

当类持有其他资源时,如动态分配内存、指向其他数据的指针等,默认构造函数的浅拷贝不能拷贝这些资源。

class  Array{
public:
    Array(int len);
    Array(const Array &arr);
    ~Array();
    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(const Array &arr){     // 定义拷贝构造函数
    this -> m_len = arr.m_len;
    this -> m_p = (int*) calloc( this->m_len * sizeof(int) );   // 为新的 m_p 新建内存
    memcpy(this->m_p, arr.m_p, m_len * sizeof(int));
}

如果不进行深拷贝,为 m_p 创建内存,两个对象的 m_p 指针会指向同一块内存。

如果一个类拥有指针类型的成员变量,那么绝大部分情况下需要进行深拷贝,只有这样,才能将指针指向的内容再复制出一份来,让原有对象和新生对象相互独立。

如果创建对象时需要进行预处理工作,如统计创建对象数目、记录对象创建时间等等,需要用深拷贝。

四、重载=(赋值运算符)

在定义的同时进行赋值是初始化,定义完成后再赋值是赋值,初始化只能一次,赋值可以多次。

即使没有显式重载赋值运算符,编译器会默认重载它,默认将原有对象一一赋值给新对象,这和默认拷贝构造函数的功能类似。

class  Array{
public:
    Array(int len);
    Array(const Array &arr);
    ~Array();
    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(const Array &arr){     // 定义拷贝构造函数
    this -> m_len = arr.m_len;
    this -> m_p = (int*) calloc( this->m_len * sizeof(int) );   // 为新的 m_p 新建内存
    memcpy(this->m_p, arr.m_p, m_len * sizeof(int));
}

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;
}
  • operator=() 的返回值类型为 Array &,这样不但能够避免在返回数据时调用拷贝构造函数,还能够达到连续赋值的目的。下面的语句就是连续赋值:

    arr4 = arr3 = arr2 = arr1;

  • if( this != &arr)语句的作用是「判断是否是给同一个对象赋值」:如果是,那就什么也不做;如果不是,那就将原有对象的所有成员变量一一赋值给新对象,并为新对象重新分配内存。
  • return *this 表示返回当前对象(新对象)。
  • operator=() 的形参类型为 const Array &,这样不但能够避免在传参时调用拷贝构造函数,还能够同时接收 const 类型和非 const 类型的实参
  • 赋值运算符重载函数除了能有对象引用这样的参数之外,也能有其它参数。但是其它参数必须给出默认值。

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

拷贝控制操作:拷贝、赋值和销毁时做什么。分别对应拷贝构造函数、赋值运算符和析构函数。

三法则:三个特殊的成员函数来完成拷贝控制操作。
五法则:在三法则基础上,增加了移动构造函数和移动赋值运算符。(C++11)

通常一个类对析构函数的需求要比拷贝构造函数和赋值运算符的需求更加明显。如果一个类需要定义析构函数,那么这个类肯定也需要拷贝构造函数和一个赋值运算符。

如果一个类需要一个拷贝构造函数,几乎可以肯定它也需要一个赋值元素运算符,反之依然。但都不必然意味着也需要析构函数。

六、转换构造函数

C++ 中不同的数据类型可以相互转换,分为隐式类型转换和强制类型转换。

C++ 允许自定义类型转换规则,这种规则只能以类的成员函数实现,也就是说只适用于类。

将其它类型转换为当前类类型需要借助转换构造函数,转换构造函数只有一个参数。

class Complex{
public:
    // ...
    Complex(double real):m_real(real),m_imag(0.0){} // 转换构造函数
private:
    double m_real;  // 实部
    double m_imag;  // 虚部
}

int main(){
    Complex a(10.0, 20.0);
    a = 25.5;   // 调用转换构造函数
};

转换构造函数也是构造函数的一种,除了可以用来将其它类型转换为当前类类型,还可以用来初始化对象。

七、类型转换函数

转换构造函数能够将其他类型转换为当前类类型,但不能反过来将当前类类型转换为其他类型。类型转换函数是来解决这个问题的,它只能以成员函数出现。

语法格式:

operator type(){
    // ...
    return data;
}

operator 是 C++ 关键字,type 是要转换的目标类型, data 是要返回的 type 类型:类型转换函数看起来没有返回值类型,其实是隐式指明了返回值类型。

class Complex{
public:
    // ...
    operator double() const{ return m_real; }   // 类型转换函数
private:
    double m_real;  // 实部
    double m_imag;  // 虚部
};

// ...

int main{
    Complex();
    double f = c1;  // 相当于 double f = Complex::operator double(&c);
    return 0;
}

关于类型转换函数的说明:

  1. type 可以是内置类型、类类型以及由 typedef 定义的类型别名,任何可作为函数返回类型的类型(void 除外)都能够被支持。一般而言,不允许转换为数组或函数类型,转换为指针类型或引用类型是可以的。
  2. 类型转换函数一般不会更改被转换的对象,所以通常被定义为 const 成员。
  3. 类型转换函数可以被继承,可以是虚函数。
  4. 一个类虽然可以有多个类型转换函数(类似于函数重载),但是如果多个类型转换函数要转换的目标类型本身又可以相互转换(类型相近),那么有时候就会产生二义性。以 Complex 类为例,假设它有两个类型转换函数:

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;

从 Complex 转换为 double 与从 Complex 转化为 int 是平级的,没有谁的优先级更高

转换构造函数和类型转换函数

如果一个类同时存在转换构造函数和类型转换函数,就有可能产生二义性。如:

// 复数类,同时实现转换构造函数和类型转换函数
class Complex{};    

int main(){
    Complex c1(24.6, 100);
    double f = c1;  // 调用类型转换函数
    c1 = 78.4;      // 调用转换构造函数
    f = 12.5 + c1;  // 错误,转换构造函数和类型转换函数同级,有两种转换方案,double/Complex
    Complex c2 = c1 + 46.7; // 错误,产生二义性
}

编译器不会根据 = 左边的数据类型来选择转换方案,编译器只关注整个表达式本身。

解决二义性问题,要么只使用转换构造函数,要么只使用类型转换函数。实际上,往往只定义转换构造函数,当需要把当前类转换为其他类时,定义一个普通成员函数即可。

class Complex{
    double real() const{ return m_real; }   // 直接定义在类内,内联函数。
}

八、类型转换的本质

隐式类型转换利用的是编译器内置的转换规则,或者用户自定义的转换构造函数以及类型转换函数。当隐式转换不能完成类型转换工作时,必须使用强转,如必须使用强转才能将 void * 转换为 type *。

所谓数据类型转换,就是对数据所占用的二进制位做出重新解释。隐式类型转换可以根据已知的转换规则来决定是否修改数据的二进制位;强转由于没有对应规则,所能做的仅仅是重新解释数据的二进制位,无法对数据进行修正,这是隐式和强转的最根本区别。

隐式类型转换必须使用已知的转换规则,虽然灵活性受到了限制,但是由于能够对数据进行恰当地调整,所以更加安全(几乎没有风险)。强制类型转换能够在更大范围的数据类型之间进行转换,例如不同类型指针(引用)之间的转换、从 const 到非 const 的转换、从 int 到指针的转换(有些编译器也允许反过来)等,这虽然增加了灵活性,但是由于不能恰当地调整数据,所以也充满了风险。

C/C++ 之所以增加强制类型转换的语法,是为了提醒程序员这样做存在风险,一定要谨慎小心。说得通俗一点,你现在的类型转换存在风险,你自己一定要知道。

类型转换只能发生在相关类型或者相近类型之间,两个毫不相干的类型不能相互转换,即使使用强制类型转换也不行。

九、四种类型转换运算符

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

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

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

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

xxx_cast(data)

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 *等。
  • int 和指针之间的转换。将一个具体的地址赋值给指针变量是非常危险的,因为该地址上的内存可能没有分配,也可能没有读写权限,恰好是可用内存反而是小概率事件。

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

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

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); //不能将整数转换为指针类型
}

const_cast

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

const int n = 100;
int *p = const_cast<int*>(&n);
*p = 234;

cout<<"n = "<<n<<endl;      // 100
cout<<"*p = "<<*p<<endl;    // 234

因为 C++ 对常量的处理更像是编译时期的 #define,是一个值替换的过程,代码中所有使用 n 的地方在编译期间就被替换成了 100。换句话说,代码被修改成了下面的形式:

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

reinterpret_cast

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

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

int main(){
    //将 char* 转换为 float*
    char str[]="asdfasdf";
    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;

dynamic_cast

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

dynamic_cast 的语法格式为:

dynamic_cast (expression)

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

十、总结

  • 拷贝构造函数参数必须是引用,如果不是,实参传形参也会调用拷贝构造函数,会陷入死循环。
  • 调用拷贝构造函数时期:用其他对象初始化当前对象、创造对象时赋值、形参为类类型、返回类类型。
  • 当类持有其他资源时,如动态分配内存、指向其他数据的指针等,默认构造函数的浅拷贝不能拷贝这些资源。
  • 重载赋值运算符
  • 通常一个类对析构函数的需求要比拷贝构造函数和赋值运算符的需求更加明显。如果一个类需要定义析构函数,那么这个类肯定也需要拷贝构造函数和一个赋值运算符。
  • 将其他类型转换为当前类类型需要借助转换构造函数,转换构造函数只有一个参数。
  • 类型转换函数是来解决这个问题的,它只能以成员函数出现。
  • 数据类型转换,就是对数据所占用的二进制位做出重新解释。隐式类型转换可以根据已知的转换规则来决定是否修改数据的二进制位;强转由于没有对应规则,所能做的仅仅是重新解释数据的二进制位,无法对数据进行修正,这是隐式和强转的最根本区别。
  • static_cast,良性转换
  • const_cast,const 与 非 const 转换
  • reinterpret_cast, 强转
  • dynamic_cast,继承关系中向下转型
posted @ 2021-10-25 10:35  锦瑟,无端  阅读(147)  评论(0编辑  收藏  举报