Table of Contents:

拷贝构造函数

何时调用拷贝构造函数

以拷贝的方式来初始化对象的情况:
1. 构造对象时,将其它对象作为实参 Student stu2(stu1); //以拷贝的方式初始化
2. 在创建对象的同时以对象赋值
3. 函数调用时
4. 函数返回值为类类型

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

Student func(){
    Student s("小明"1690.5);
    return s;
}
Student stu = func();

理论上讲,运行代码后会调用两次拷贝构造函数,一次是返回 s 对象时,另外一次是创建 stu 对象时。但是在现代编译器上,只会调用一次拷贝构造函数,或者一次也不调用这是因为,现代编译器都支持返回值优化技术,会尽量避免拷贝对象,以提高程序运行效率

深拷贝和浅拷贝

默认浅拷贝,深拷贝要自己处理(behavior like a value)
深拷贝例子:

Array(const Array &arr);        //拷贝构造函数
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_pm_len * sizeof(int) );
}

深拷贝的例子比比皆是,标准模板库(STL)中的 string、vector、stack、set、map 等都必须使用深拷贝。

深拷贝的时机:
1. 存在指针成员,和动态分配的内存, 因为只有这样,才能将指针指向的内容再复制出一份来,让原有对象和新生对象相互独立,彼此之间不受影响。

重载=(赋值运算符)

编译器会默认生成。功能就是将原有对象的所有成员变量挨个赋值给新对象,这和默认拷贝构造函数的功能类似。
需要拷贝构造函数,则必须有赋值运算符

Array & operator=(const Array &arr);
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_pm_len * sizeof(int) );
    }
    return *this;
}

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

C++中的三/五法则(Rule of Three/Five)是一组关于自定义类(用户定义类型)的重要规则,用于确保类的正确管理资源和避免内存泄漏等问题。C++ 并不要求我们定义所有的这些操作,你可以只定义其中的一个或两个。但是,这些操作通常应该被看做一个整体,只需要定义其中一个操作,而不需要定义其他操作的情况很少见。
* 1.需要析构函数的类也需要拷贝和赋值操作。若没有拷贝和赋值操作,那么析构函数free()的时候可能free两次,造成错误
* 2.需要拷贝操作的类也需要赋值操作,反之亦然

  1. 析构函数(Destructor Rule): 如果类需要分配资源(如动态内存、文件句柄等),则需要定义析构函数来释放这些资源,以防止内存泄漏。

  2. 拷贝构造函数(Copy Constructor Rule): 如果类需要在拷贝构造函数中执行深拷贝(而不是默认的浅拷贝),则需要定义拷贝构造函数。

  3. 拷贝赋值操作符(Copy Assignment Operator Rule): 如果类需要在拷贝赋值操作符中执行深拷贝,并且为了支持对象的赋值操作,则需要定义拷贝赋值操作符。

  4. 移动构造函数(Move Constructor Rule,C++11引入): 如果类中有动态分配的资源,而且拷贝构造函数的成本很高,可以通过定义移动构造函数来支持对象的高效移动。移动构造函数允许将资源的所有权从一个对象转移到另一个对象,避免不必要的资源拷贝。

  5. 移动赋值操作符(Move Assignment Operator Rule,C++11引入): 类似于移动构造函数,移动赋值操作符允许对象之间的资源所有权的高效转移。如果类中有动态分配的资源,同时要支持对象的赋值操作,可以定义移动赋值操作符。

  6. 转换构造函数

  7. 类型转换函数

  8. 函数对象

    转换构造函数

    将其它类型转换为当前类的类型
    不管是自动类型转换还是强制类型转换,前提必须是编译器知道如何转换,例如,将小数转换为整数会抹掉小数点后面的数字,将int *转换为float *只是简单地复制指针的值,这些规则都是编译器内置的,我们并没有告诉编译器。
    换句话说,如果编译器不知道转换规则就不能转换,使用强制类型也无用
    幸运的是,C++ 允许我们自定义类型转换规则,用户可以将其它类型转换为当前类类型,也可以将当前类类型转换为其它类型。
    这种自定义的类型转换规则只能以类的成员函数的形式出现,换句话说,这种转换规则只适用于类。
    在进行数学运算、赋值、拷贝等操作时,如果遇到类型不兼容、需要将 double 类型转换为 Complex 类型时,编译器会检索当前的类是否定义了转换构造函数,如果没有定义的话就转换失败,如果定义了的话就调用转换构造函数。

class MyString {
public:
    // 转换构造函数:将const char* 转换为 MyString
    MyString(const char* str) : data_(str) {
        std::cout << "转换构造函数被调用" << std::endl;
    }

    void print() const {
        std::cout << data_ << std::endl;
    }

private:
    std::string data_;
};

int main() {
    const char* text = "Hello, World!";
    
    // 使用转换构造函数将 const char* 转换为 MyString 对象
    MyString myStr = text;

    // 若转换构造函数加上explicit后,必须显式调用构造函数来创建 MyString 对象
    MyString myStr(text);

    myStr.print();

    return 0;
}

再谈构造函数

Complex(); //没有参数
Complex(double real, double imag); //两个参数
Complex(const Complex &c);
Complex(double real);

不管哪一种构造函数,都能够用来初始化对象,这是构造函数的本意。
除了在创建对象时初始化对象,其他情况下也会调用构造函数,例如,以拷贝的的方式初始化对象时会调用拷贝构造函数,将其它类型转换为当前类类型时会调用转换构造函数。

类型转换函数

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

operator type(){        //this参数会自动传入进去
    return data;
}
//例子:
operator double() const { return m_real; } //类型转换函数,函数的名字就叫做double
#include <iostream>

class MyNumber {
public:
    MyNumber(int value) : number(value) {}

    // 类型转换函数将 MyNumber 转换为 int
    operator int() const {
        return number;
    }

private:
    int number;
};

int main() {
    MyNumber num(42);

    // 使用类型转换函数将 MyNumber 对象转换为 int
    int intValue = num;

    std::cout << "intValue: " << intValue << std::endl;

    return 0;
}

因为要转换的目标类型是 type,所以返回值 data 也必须是 type 类型。类型转换函数看起来没有返回值类型,其实是隐式地指明了返回值类型
关于类型转换函数的说明:
* 1) type 可以是内置类型、类类型以及由 typedef 定义的类型别名,任何可作为函数返回类型的类型(void 除外)都能够被支持。一般而言,不允许转换为数组或函数类型,转换为指针类型或引用类型是可以的。
* 2) 类型转换函数一般不会更改被转换的对象,所以通常被定义为 const 成员
* 3) 类型转换函数可以被继承,可以是虚函数。
* 4) 一个类虽然可以有多个类型转换函数(类似于函数重载),但是如果多个类型转换函数要转换的目标类型本身又可以相互转换(类型相近),那么有时候就会产生二义性
比如:

operator double() const { return m_real; } //转换为double类型
operator int() const { return (int)m_real; } //转换为int类型
// 下面会出错
Complex c1(24.6100);
float f = 12.5 + c1;    // 隐式转换

总结:
转换构造函数和类型转换函数的作用是相反的
转换构造函数会将其它类型转换为当前类类型,类型转换函数会将当前类类型转换为其它类型
如果没有这两个函数,Complex 类和 int、double、bool 等基本类型的四则运算、逻辑运算都将变得非常复杂,要编写大量的运算符重载函数
但是,如果一个类同时存在这两个函数,就有可能产生二义性。
解决二义性问题的办法也很简单粗暴,要么只使用转换构造函数,要么只使用类型转换函数。

实践证明,用户对转换构造函数的需求往往更加强烈,这样能增加编码的灵活性,例如,可以将一个字符串字面量或者一个字符数组直接赋值给 string 类的对象,可以将一个 int、double、bool 等基本类型的数据直接赋给 Complex 类的对象。

如果我们想把当前类类型转换为其它类型怎么办呢?很简单,增加一个普通的成员函数即可std::string就是最好的例子。例如,string 类使用 c_str() 函数转换为 C 风格的字符串,complex 类使用 real() 和 imag() 函数来获取复数的实部和虚部。

函数对象详解

如果一个类将()运算符重载为成员函数,这个类就称为函数对象类,这个类的对象就是函数对象。函数对象其实就是重载了()的对象,是一个更加面向对象的函数指针,它可以用很多面相对象的很多特性。

class CAverage
{
public:
    double operator()(int a1, int a2, int a3)
    { //重载()运算符
        return (double)(a1 + a2 + a3) / 3;
    }
};
int main()
{
    CAverage average; //能够求三个整数平均数的函数对象
    cout << average(323); //等价于 cout << average.operator(3, 2, 3);
    return 0;
}

类型转换的本质

原则:有强制转换就有风险
内存中的数据有多种解释方式,使用之前必须要确定。这种「确定数据的解释方式」的工作就是由数据类型(Data Type)来完成的。例如int a;表明,a 这份数据是整数,不能理解为像素、声音、视频等。
所谓数据类型转换,就是对数据所占用的二进制位做出重新解释。如果有必要,在重新解释的同时还会修改数据,改变它的二进制位。
对于隐式类型转换,编译器可以根据已知的转换规则来决定是否需要修改数据的二进制位;
而对于强制类型转换,由于没有对应的转换规则,所以能做的事情仅仅是重新解释数据的二进制位,但无法对数据的二进制位做出修正。这就是隐式类型转换和强制类型转换最根本的区别

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

隐式类型转换和显式类型转换最根本的区别:
* 隐式类型转换除了会重新解释数据的二进制位,还会利用已知的转换规则(编译器和自己定义的转换函数)对数据进行恰当地调整;
* 显式类型转换只能简单粗暴地重新解释二进制位,不能对数据进行任何调整。

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

四种类型转换运算符

C风格的强制类型转换统一使用( ),而他是很难再程序中搜索到的。
为了使潜在风险更加细化,使问题追溯更加方便,使书写格式更加规范,C++ 对类型转换进行了分类,并新增了四个关键字来予以支持,它们分别是:

static_cast

用于良性转换,一般不会导致意外发生,风险很低。在编译期间转换,能够更加及时地发现错误,转换失败的话会抛出一个编译错误。
* 原有的自动类型转换,例如 short 转 int、int 转 double、向上转型等;
* void 指针和具体类型指针之间的转换,例如void *int *char *void *等;
* 有转换构造函数或者类型转换函数的类与其它类型之间的转换,例如 double 转 Complex(调用转换构造函数)、Complex 转 double(调用类型转换函数)。

//下面是正确的用法
int m = 100;
Complex c(12.523.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 修饰或 volatile 修饰。换句话说,const_cast 就是用来将 const/volatile 类型转换为非 const/volatile 类型。

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,  是因为n相当于编译期间的替换。
// *p = 234

reinterpret_cast

高度危险的转换,这种转换仅仅是对二进制位的重新解释不会借助已有的转换规则对数据进行调整,但是最灵活

dynamic_cast

会在程序运行期间借助 RTTI 进行类型转换,这就要求基类必须包含虚函数
dynamic_cast <newType> (expression) 
对于指针,如果转换失败将返回 NULL;对于引用,如果转换失败将抛出std::bad_cast异常。

dynamic_cast 只能转换指针类型引用类型,其它类型(int、double、数组、类、结构体等)都不行。
每个类都会在内存中保存一份类型信息,编译器会将存在继承关系的类的类型信息使用指针“连接”起来,从而形成一个继承链(Inheritance Chain)
总起来说,dynamic_cast 会在程序运行过程中遍历继承链,如果途中遇到了要转换的目标类型,那么就能够转换成功,如果直到继承链的顶点(最顶层的基类)还没有遇到要转换的目标类型,那么就转换失败。
但是从本质上讲,dynamic_cast 还是只允许向上转型,因为它只会向上遍历继承链。
造成这种假象的根本原因在于,派生类对象可以用任何一个基类的指针指向它(基类指针本来就指向派生类对象)

例子:

#include <iostream>

class Base {
public:
    virtual void show() {
        std::cout << "Base" << std::endl;
    }
};

class Derived : public Base {
public:
    void show() override {
        std::cout << "Derived" << std::endl;
    }
};

int main() {
    Base* basePtr = new Derived;

    Derived* derivedPtr = dynamic_cast<Derived*>(basePtr);
    if (derivedPtr) {
        derivedPtr->show();
    } else {
        std::cout << "Conversion failed" << std::endl;
    }

    delete basePtr;

    return 0;
}

在上述示例中,即使不进行类型转换,你仍然可以通过基类指针 basePtr 调用 show() 方法,因为基类指针可以在运行时动态地绑定到派生类的函数。但是,如果你希望在派生类中进行更多的操作,或者访问派生类特有的成员,那么将基类指针转换为派生类指针会更加方便和合适。