美文网首页
全面梳理 C++ 拷贝构造与赋值运算符重载(operator=)

全面梳理 C++ 拷贝构造与赋值运算符重载(operator=)

作者: 王技术 | 来源:发表于2021-07-14 17:42 被阅读0次
    本文全面梳理 C++ 的拷贝构造与赋值运算符重载(operator=)
    默认拷贝构造函数和赋值运算符

    在默认情况下用户没有定义,编译器会自动的隐式生成一个拷贝构造函数和赋值运算符。
    但用户可以使用delete来指定不生成拷贝构造函数和赋值运算符,这样的对象就不能通过值传递,也不能进行赋值运算。

    class Person {
    public:
        Person(const Person& p) = delete;
        Person& operator=(const Person& p) = delete;
    private:
        int age;
        string name;
    };
    

    上面的定义的类Person显式的删除了拷贝构造函数和赋值运算符,在需要调用拷贝构造函数或者赋值运算符的地方,会提示_无法调用该函数,它是已删除的函数。

    何时调用

    拷贝构造函数和赋值运算符的行为比较相似,都是将一个对象的值复制给另一个对象;
    但是其结果却有些不同,拷贝构造函数使用传入对象的值生成一个新的对象的实例,
    而赋值运算符是将对象的值复制给一个已经存在的实例。
    这种区别从两者的名字也可以很轻易的分辨出来,
    拷贝构造函数也是一种构造函数,那么它的功能就是创建一个新的对象实例;
    赋值运算符是执行某种运算,将一个对象的值复制给另一个对象(已经存在的)。
    调用的是拷贝构造函数还是赋值运算符,主要是看是否有新的对象实例产生
    如果产生了新的对象实例,那调用的就是拷贝构造函数;
    如果没有,那就是对已有的对象赋值,调用的是赋值运算符。

    调用拷贝构造函数主要有以下场景:
    对象作为函数的参数,以值传递的方式传给函数
    对象作为函数的返回值,以值的方式从函数返回
    使用一个对象给另一个对象初始化

    class Person {
    public:
        Person(){}
        Person(const Person& p) {
            cout << "Copy 构造" << endl;
        }
    
        Person& operator=(const Person& p) {
            cout << "operator =" << endl;
            return *this;
        }
    
    private:
        int age;
        string name;
    };
    
    void f(Person p) {
        return;
    }
    
    Person f1() {
        Person p;
        return p;
    }
    
    int main() {
        Person p;
        Person p1 = p;    // 1
        Person p2;
        p2 = p;           // 2
        f(p2);            // 3
        p2 = f1();        // 4
        Person p3 = f1(); // 5
        return 0;
    }
    

    在main中模拟了5中场景,分别是:
    用一个对象初始化一个对象
    用一个对象给一个对象赋值
    对象作为函数的参数,以值传递的方式传给函数
    对象作为函数的返回值,以值的方式从函数返回
    以值返回的方式初始化对象
    测试调用的是拷贝构造函数还是赋值运算符。执行结果如下:

    Copy 构造
    operator =
    Copy 构造
    Copy 构造
    operator =
    Copy 构造
    

    分析如下:

    1. 这是虽然使用了"=",但是实际上使用对象p来创建一个新的对象p1。也就是产生了新的对象,所以调用的是拷贝构造函数。
    2. 首先声明一个对象p2,然后使用赋值运算符"=",将p的值复制给p2,显然是调用赋值运算符,为一个已经存在的对象赋值 。
    3. 以值传递的方式将对象p2传入函数f内,调用拷贝构造函数构建一个函数f可用的实参。
    4. 这条语句拷贝构造函数和赋值运算符都调用了。函数f1以值的方式返回一个Person对象,在返回时会调用拷贝构造函数创建一个临时对象tmp作为返回值;返回后调用赋值运算符将临时对象tmp赋值给p2.(有时候编译器会帮我们优化,省去返回值的拷贝构造)
    5. 按照4的解释,应该是首先调用拷贝构造函数创建临时对象;然后再调用拷贝构造函数使用刚才创建的临时对象创建新的对象p3,也就是会调用两次拷贝构造函数。不过,编译器也没有那么傻,应该是直接调用拷贝构造函数使用返回值创建了对象p3。
    深拷贝、浅拷贝

    说到拷贝构造函数,就不得不提深拷贝和浅拷贝。
    通常,默认生成的拷贝构造函数和赋值运算符,只是简单的进行值的复制。
    例如:上面的Person类,字段只有int和string两种类型,这在拷贝或者赋值时进行值复制创建的出来的对象和源对象也是没有任何关联,对源对象的任何操作都不会影响到拷贝出来的对象。
    反之,假如Person有一个对象为int *,这时在拷贝时还只是进行值复制,那么创建出来的Person对象的int *的值就和源对象的int *指向的是同一个位置。任何一个对象对该值的修改都会影响到另一个对象,这种情况就是浅拷贝。

    深拷贝和浅拷贝主要是针对类中的指针和动态分配的空间来说的,因为对于指针只是简单的值复制并不能分割开两个对象的关联,任何一个对象对该指针的操作都会影响到另一个对象。这时候就需要提供自定义的深拷贝的拷贝构造函数,消除这种影响。通常的原则是:

    • 含有指针类型的成员或者有动态分配内存的成员都应该提供自定义的拷贝构造函数
    • 在提供拷贝构造函数的同时,还应该考虑实现自定义的赋值运算符
    • 对于拷贝构造函数的实现要确保以下几点:
      • 对于值类型的成员进行值复制
      • 对于指针和动态分配的空间,在拷贝中应重新分配分配空间
      • 对于基类,要调用基类合适的拷贝方法,完成基类的拷贝
    关于赋值运算符上一坨代码
    #include <iostream>
    #include "StringBad.hpp"
    #include <string>
    
    using namespace std;
    
    class MyStr {
    private:
        char *name;
        int id;
    public:
        MyStr() {
            cout << "构造" << endl;
            id = 0;
            name = nullptr;
        }
        
        MyStr(int _id, char *_name) {
            cout << "参数 构造" << endl;
            id = _id;
            name = new char[strlen(_name) + 1];
            std::strcpy(name, _name);
        }
        
        MyStr(const MyStr& str) {
            cout << "copy 构造" << endl;
            id = str.id;
            name = new char[strlen(str.name) + 1];
            std::strcpy(name, str.name);
        }
        
        MyStr& operator =(const MyStr& str) {
            cout << "operator =" << endl;
            if (this != &str) {
                if (name != NULL)
                    delete[] name;
                this->id = str.id;
                int len = strlen(str.name);
                name = new char[len + 1];
                std::strcpy(name, str.name);
            }
            return *this;
        }
        
        ~MyStr() {
            cout << "析构" << endl;
            delete[] name;
        }
    };
    
    
    int main(int argc, const char * argv[]) {
        MyStr str1(1, "huberyhx");
        cout << "====================" << endl;
        MyStr str2;
        str2 = str1;
        cout << "====================" << endl;
        MyStr str3 = str2;
        return 0;
    }
    

    输出:

    参数 构造
    ====================
    构造
    operator =
    ====================
    copy 构造
    析构
    析构
    析构
    
    关于const:

    一般地,赋值运算符重载函数的参数是函数所在类的const类型的引用,加 const 是因为:

    • 我们不希望在这个函数中对用来进行赋值的“原版”做任何修改。
    • 加上const,对于const的和非const的实参,函数就能接受;如果不加,就只能接受非const的实参。

    用引用是因为:
    这样可以避免在函数调用时对实参的一次拷贝,提高了效率。

    注意:
    当然这都不是强制的,可以不加const,也可以没有引用,甚至参数可以不是函数所在的对象

    关于返回值

    一般地,返回值是被赋值者的引用,即*this(如上面),原因是

    • 这样在函数返回时避免一次拷贝,提高了效率。
    • 更重要的,这样可以实现连续赋值,即类似a=b=c这样。如果不是返回引用而是返回值类型,那么,执行a=b时,调用赋值运算符重载函数,在函数返回时,由于返回的是值类型,所以要对return后边的“东西”进行一次拷贝,得到一个未命名的副本(有些资料上称之为“匿名对象”),然后将这个副本返回,而这个副本是右值,所以,执行a=b后,得到的是一个右值,再执行=c就会出错。

    注意:
    这也不是强制的,我们可以将函数返回值声明为void,然后什么也不返回,只不过这样就不能够连续赋值了。

    关于调用时机

    当为一个类对象赋值(注意:可以用本类对象为其赋值,也可以用其它类型(如内置类型)的值为其赋值)时,会由该对象调用该类的赋值运算符重载函数。
    如上边代码中str2 = str1;一句,用str1为str2赋值,会由str2调用MyStr类的赋值运算符重载函数。
    需要注意的是
    MyStr str2;
    str2 = str1;

    MyStr str3 = str2;
    在调用函数上是有区别的,正如我们在上面结果中看到的那样。
    前者MyStr str2;一句是str2的声明加定义,调用无参构造函数,所以str2 = str1;一句是在str2已经存在的情况下,用str1来为str2赋值,调用的是拷贝赋值运算符重载函数;而后者,是用str2来初始化str3,调用的是拷贝构造函数。

    关于默认复制运算符重载函数

    当程序没有显式地提供一个以本类或本类的引用为参数的赋值运算符重载函数时,编译器会自动生成这样一个赋值运算符重载函数。
    注意限定条件,不是说只要程序中有了显式的赋值运算符重载函数,编译器就一定不再提供默认的版本,而是说只有显式提供了以本类本类的引用为参数的赋值运算符重载函数时,编译器才不会提供默认的版本。
    例如提供这样一个重载函数

    #include<iostream>
    #include<string>
    using namespace std;
    
    class Data
    {
    private:
        int data;
    public:
        Data() {};
        Data(int _data)
            :data(_data)
        {
            cout << "参数 构造" << endl;
        }
        Data& operator=(const int _data)
        {
            cout << "operator=" << endl;
            data = _data;
            return *this;
        }
    };
    
    int main()
    {
        Data data1(1);
        Data data2,data3;
        cout << "=====================" << endl;
        data2 = 1;
        cout << "=====================" << endl;
        data3 = data2;
        return 0;
    }
    

    是一个以 int 型为参数的运算符重载,输出:

    参数 构造
    ====================
    operator =
    ====================
    

    data3 = data2; 还是会走系统提供的默认赋值运算
    如果把Data& operator=(const int _data) 删除,也是可以编译通过的
    输出就变成这样

    参数 构造
    ====================
    参数 构造
    ====================
    

    由此可见:
    当用一个非类A的值(如上面的int型值)为类A的对象赋值时

    • 如果匹配的构造函数和赋值运算符重载函数同时存在),会调用赋值运算符重载函数。
    • 如果只有匹配的构造函数存在,就会调用这个构造函数。
    显式提供赋值运算符重载函数的时机
    • 用非类A类型的值为类A的对象赋值时(从上面的代码可以看出,这种情况下我们可以不提供相应的赋值运算符重载函数而只提供相应的构造函数来完成任务)。
    • 当用类A类型的值为类A的对象赋值且类A的成员变量中含有指针时,为避免浅拷贝,必须显式提供赋值运算符重载函数。
    赋值运算符重载函数只能是类的非静态的成员函数

    C++规定,赋值运算符重载函数只能是类的非静态的成员函数,不能是静态成员函数,也不能是友元函数。
    关于原因,有人说,赋值运算符重载函数往往要返回*this,而无论是静态成员函数还是友元函数都没有this指针。这乍看起来很有道理,但仔细一想,我们完全可以写出这样的代码

    其实,之所以不是静态成员函数,是因为静态成员函数只能操作类的静态成员,不能操作非静态成员。如果我们将赋值运算符重载函数定义为静态成员函数,那么,该函数将无法操作类的非静态成员,这显然是不可行的。

    当程序没有显式地提供一个以本类或本类的引用为参数的赋值运算符重载函数时,编译器会自动提供一个。现在,假设 C++ 允许将赋值运算符重载函数定义为友元函数并且我们也确实这么做了,而且以类的引用为参数。与此同时,我们在类内却没有显式提供一个以本类或本类的引用为参数的赋值运算符重载函数。由于友元函数并不属于这个类,所以,此时编译器一看,类内并没有一个以本类或本类的引用为参数的赋值运算符重载函数,所以会自动提供一个。此时,我们再执行类似于str2=str1这样的代码,那么,编译器是该执行它提供的默认版本呢,还是执行我们定义的友元函数版本呢?

    为了避免这样的二义性,C++强制规定,赋值运算符重载函数只能定义为类的成员函数,这样,编译器就能够判定是否要提供默认版本了,也不会再出现二义性。

    赋值运算符重载函数不能被继承

    因为相较于基类,派生类往往要添加一些自己的数据成员和成员函数,如果允许派生类继承基类的赋值运算符重载函数,那么,在派生类不提供自己的赋值运算符重载函数时,就只能调用基类的,但基类版本只能处理基类的数据成员,在这种情况下,派生类自己的数据成员怎么办?

    所以,C++规定,赋值运算符重载函数不能被继承。

    赋值运算符重载函数要避免自赋值

    对于赋值运算符重载函数,我们要避免自赋值情况(即自己给自己赋值)的发生,一般地,我们通过比较赋值者与被赋值者的地址是否相同来判断两者是否是同一对象(正如 if (this != &str))。

    为什么要避免自赋值呢?

    • 为了效率。显然,自己给自己赋值完全是毫无意义的无用功,特别地,对于基类数据成员间的赋值,还会调用基类的赋值运算符重载函数,开销是很大的。如果我们一旦判定是自赋值,就立即return *this,会避免对其它函数的调用。

    • 如果类的数据成员中含有指针,自赋值有时会导致灾难性的后果。
      对于指针间的赋值(注意这里指的是指针所指内容间的赋值,这里假设用_p给p赋值)
      先要将p所指向的空间delete掉(为什么要这么做呢?因为指针p所指的空间通常是new来的,如果在为p重新分配空间前没有将p原来的空间delete掉,会造成内存泄露)
      然后再为p重新分配空间,将_p所指的内容拷贝到p所指的空间。
      如果是自赋值,那么p和_p是同一指针,在赋值操作前对p的delete操作,将导致p所指的数据同时被销毁。那么重新赋值时,拿什么来赋?
      所以,对于赋值运算符重载函数,一定要先检查是否是自赋值,如果是,直接return *this。

    总结

    拷贝构造函数和赋值运算符的行为比较相似,却产生不同的结果;拷贝构造函数使用已有的对象创建一个新的对象,赋值运算符是将一个对象的值复制给另一个已存在的对象。区分是调用拷贝构造函数还是赋值运算符,主要是否有新的对象产生。
    关于深拷贝和浅拷贝。当类有指针成员或有动态分配空间,都应实现自定义的拷贝构造函数。提供了拷贝构造函数,最后也实现赋值运算符。

    相关文章

      网友评论

          本文标题:全面梳理 C++ 拷贝构造与赋值运算符重载(operator=)

          本文链接:https://www.haomeiwen.com/subject/efpspltx.html