MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

C++赋值运算符与拷贝构造函数的区别与联系

2023-07-197.5k 阅读

C++ 赋值运算符与拷贝构造函数的区别与联系

一、C++ 赋值运算符

在 C++ 中,赋值运算符 = 用于将一个对象的值赋给另一个对象。对于基本数据类型,如 intfloat 等,赋值操作是直接将一个变量的值复制到另一个变量。但对于自定义类型(类),情况会复杂一些。

当我们定义一个类时,如果没有显式地定义赋值运算符重载函数,编译器会自动生成一个默认的赋值运算符。这个默认的赋值运算符会对类的成员变量进行逐个赋值,这被称为位拷贝(bitwise copy)。

1.1 示例代码

#include <iostream>

class MyClass {
private:
    int data;
public:
    MyClass(int value) : data(value) {}

    // 显示定义赋值运算符重载
    MyClass& operator=(const MyClass& other) {
        if (this != &other) {
            data = other.data;
        }
        return *this;
    }

    void printData() const {
        std::cout << "Data: " << data << std::endl;
    }
};

int main() {
    MyClass obj1(10);
    MyClass obj2(20);

    obj2 = obj1;
    obj2.printData();

    return 0;
}

在上述代码中,我们定义了一个 MyClass 类,并显式地定义了赋值运算符重载函数。在 operator= 函数中,首先检查是否是自我赋值(this != &other),这是为了避免不必要的操作和潜在的错误。如果不是自我赋值,就将 other 对象的 data 成员变量的值赋给当前对象的 data 成员变量。最后返回 *this,以便支持链式赋值,例如 a = b = c

1.2 浅拷贝与深拷贝问题

默认的赋值运算符(位拷贝)对于包含指针成员变量的类可能会引发问题,这涉及到浅拷贝和深拷贝的概念。

浅拷贝:默认的赋值运算符进行的是浅拷贝,它只是简单地复制指针的值,而不是指针所指向的内存。这意味着两个对象的指针成员变量将指向同一块内存,当其中一个对象销毁时,这块内存会被释放,另一个对象的指针就会成为野指针,导致程序出现未定义行为。

深拷贝:为了避免浅拷贝带来的问题,我们需要在赋值运算符重载函数中实现深拷贝。深拷贝是指在赋值时,不仅复制指针的值,还会为目标对象分配新的内存,并将源对象指针所指向的内容复制到新的内存中。

1.3 示例代码 - 浅拷贝问题

#include <iostream>
#include <cstring>

class String {
private:
    char* str;
public:
    String(const char* s) {
        if (s) {
            str = new char[strlen(s) + 1];
            strcpy(str, s);
        } else {
            str = new char[1];
            *str = '\0';
        }
    }

    // 未定义赋值运算符重载,使用默认的(浅拷贝)
    ~String() {
        delete[] str;
    }

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

int main() {
    String s1("Hello");
    String s2("World");

    s2 = s1;
    s1.print();
    s2.print();

    return 0;
}

在上述代码中,String 类包含一个指向动态分配内存的指针 str。由于没有显式定义赋值运算符重载,这里使用的是默认的浅拷贝。当执行 s2 = s1 时,s2.strs1.str 会指向同一块内存。当 s1s2 其中一个对象被销毁时,这块内存会被释放,另一个对象的 str 指针就会成为野指针。

1.4 示例代码 - 深拷贝实现

#include <iostream>
#include <cstring>

class String {
private:
    char* str;
public:
    String(const char* s) {
        if (s) {
            str = new char[strlen(s) + 1];
            strcpy(str, s);
        } else {
            str = new char[1];
            *str = '\0';
        }
    }

    // 定义赋值运算符重载,实现深拷贝
    String& operator=(const String& other) {
        if (this != &other) {
            delete[] str;
            if (other.str) {
                str = new char[strlen(other.str) + 1];
                strcpy(str, other.str);
            } else {
                str = new char[1];
                *str = '\0';
            }
        }
        return *this;
    }

    ~String() {
        delete[] str;
    }

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

int main() {
    String s1("Hello");
    String s2("World");

    s2 = s1;
    s1.print();
    s2.print();

    return 0;
}

在这个改进的代码中,我们显式定义了赋值运算符重载函数。在函数中,首先检查是否是自我赋值。如果不是,先释放当前对象的 str 所指向的内存,然后根据 other.str 的情况为当前对象分配新的内存,并将 other.str 的内容复制到新的内存中,从而实现了深拷贝。

二、C++ 拷贝构造函数

拷贝构造函数是一种特殊的构造函数,用于创建一个新对象,该对象是另一个已存在对象的副本。它的作用是在对象初始化时进行拷贝操作。

2.1 拷贝构造函数的定义

拷贝构造函数的一般形式为:ClassName(const ClassName& other),其中 ClassName 是类名,other 是要拷贝的对象的引用。注意,参数必须是引用类型,否则会导致无限递归调用拷贝构造函数(因为值传递本身会调用拷贝构造函数)。

2.2 示例代码

#include <iostream>

class MyClass {
private:
    int data;
public:
    MyClass(int value) : data(value) {}

    // 拷贝构造函数
    MyClass(const MyClass& other) : data(other.data) {}

    void printData() const {
        std::cout << "Data: " << data << std::endl;
    }
};

int main() {
    MyClass obj1(10);
    MyClass obj2(obj1);

    obj2.printData();

    return 0;
}

在上述代码中,MyClass 类定义了一个拷贝构造函数。当创建 obj2 时,通过 MyClass obj2(obj1) 调用了拷贝构造函数,将 obj1data 成员变量的值复制给 obj2data 成员变量。

2.3 浅拷贝与深拷贝问题

与赋值运算符类似,默认的拷贝构造函数也进行浅拷贝。对于包含指针成员变量的类,这可能会导致问题。

2.4 示例代码 - 浅拷贝问题

#include <iostream>
#include <cstring>

class String {
private:
    char* str;
public:
    String(const char* s) {
        if (s) {
            str = new char[strlen(s) + 1];
            strcpy(str, s);
        } else {
            str = new char[1];
            *str = '\0';
        }
    }

    // 未定义拷贝构造函数,使用默认的(浅拷贝)
    ~String() {
        delete[] str;
    }

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

int main() {
    String s1("Hello");
    String s2(s1);

    s1.print();
    s2.print();

    return 0;
}

在这个代码中,String 类没有显式定义拷贝构造函数,使用的是默认的浅拷贝。这意味着 s2.strs1.str 会指向同一块内存,同样会出现野指针问题。

2.5 示例代码 - 深拷贝实现

#include <iostream>
#include <cstring>

class String {
private:
    char* str;
public:
    String(const char* s) {
        if (s) {
            str = new char[strlen(s) + 1];
            strcpy(str, s);
        } else {
            str = new char[1];
            *str = '\0';
        }
    }

    // 定义拷贝构造函数,实现深拷贝
    String(const String& other) {
        if (other.str) {
            str = new char[strlen(other.str) + 1];
            strcpy(str, other.str);
        } else {
            str = new char[1];
            *str = '\0';
        }
    }

    ~String() {
        delete[] str;
    }

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

int main() {
    String s1("Hello");
    String s2(s1);

    s1.print();
    s2.print();

    return 0;
}

在这个改进的代码中,我们显式定义了拷贝构造函数。在构造函数中,根据 other.str 的情况为新对象分配新的内存,并将 other.str 的内容复制到新的内存中,实现了深拷贝。

三、赋值运算符与拷贝构造函数的区别

  1. 调用时机不同

    • 赋值运算符:在已经存在的对象之间进行赋值操作时调用,例如 obj2 = obj1
    • 拷贝构造函数:在创建新对象并使用已存在对象进行初始化时调用,例如 MyClass obj2(obj1) 或者 MyClass obj2 = obj1(这种情况本质上也是调用拷贝构造函数,而不是赋值运算符)。
  2. 参数不同

    • 赋值运算符:参数是一个已存在对象的引用,并且返回值是当前对象的引用(通常用于支持链式赋值),例如 MyClass& operator=(const MyClass& other)
    • 拷贝构造函数:参数是一个已存在对象的引用,用于创建新对象,例如 MyClass(const MyClass& other)
  3. 操作对象状态不同

    • 赋值运算符:操作的是两个已存在的对象,它会改变目标对象的状态,使其值与源对象相同。
    • 拷贝构造函数:用于创建一个全新的对象,它以一个已存在对象为蓝本,在创建过程中初始化新对象。
  4. 自我赋值处理

    • 赋值运算符:需要显式处理自我赋值的情况(this != &other),以避免不必要的操作和潜在的错误,例如释放自身内存后再使用自身内存的情况。
    • 拷贝构造函数:由于是创建新对象,不存在自我赋值的问题。

四、赋值运算符与拷贝构造函数的联系

  1. 功能相似:两者都涉及到对象之间数据的复制。无论是赋值运算符还是拷贝构造函数,其目的都是为了将一个对象的数据复制到另一个对象,只不过一个是对已存在对象进行操作,另一个是在创建新对象时进行操作。

  2. 深拷贝和浅拷贝问题相同:对于包含指针成员变量的类,默认的赋值运算符和拷贝构造函数都进行浅拷贝,这可能导致野指针等问题。因此,在需要深拷贝的情况下,都需要开发者显式地实现深拷贝逻辑,以确保对象的独立性和内存安全。

  3. 遵循相同的设计原则:在设计类时,如果一个类需要自定义拷贝构造函数来处理深拷贝等特殊情况,通常也需要自定义赋值运算符重载函数,反之亦然。这就是所谓的“Rule of Three”(现在发展为“Rule of Five”,还包括移动构造函数和移动赋值运算符),即如果一个类定义了析构函数、拷贝构造函数或赋值运算符中的任何一个,那么它很可能需要定义另外两个,以保证对象的正确行为和内存管理。

五、示例代码综合展示区别与联系

#include <iostream>
#include <cstring>

class MyString {
private:
    char* str;
public:
    MyString(const char* s = nullptr) {
        if (s) {
            str = new char[strlen(s) + 1];
            strcpy(str, s);
        } else {
            str = new char[1];
            *str = '\0';
        }
    }

    // 拷贝构造函数
    MyString(const MyString& other) {
        if (other.str) {
            str = new char[strlen(other.str) + 1];
            strcpy(str, other.str);
        } else {
            str = new char[1];
            *str = '\0';
        }
    }

    // 赋值运算符重载
    MyString& operator=(const MyString& other) {
        if (this != &other) {
            delete[] str;
            if (other.str) {
                str = new char[strlen(other.str) + 1];
                strcpy(str, other.str);
            } else {
                str = new char[1];
                *str = '\0';
            }
        }
        return *this;
    }

    ~MyString() {
        delete[] str;
    }

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

int main() {
    MyString s1("Hello");
    MyString s2(s1); // 调用拷贝构造函数

    MyString s3;
    s3 = s2; // 调用赋值运算符

    s1.print();
    s2.print();
    s3.print();

    return 0;
}

在上述代码中,MyString 类同时定义了拷贝构造函数和赋值运算符重载函数,并且都实现了深拷贝。在 main 函数中,MyString s2(s1) 调用了拷贝构造函数创建 s2,而 s3 = s2 调用了赋值运算符将 s2 的值赋给 s3。通过这个示例,可以清楚地看到两者的调用时机和功能的区别与联系。

六、移动语义下的赋值运算符和拷贝构造函数

随着 C++11 的引入,移动语义为对象的赋值和初始化提供了更高效的方式。移动构造函数和移动赋值运算符与拷贝构造函数和赋值运算符有相似之处,但它们的目的是高效地转移资源(如动态分配的内存),而不是进行复制。

6.1 移动构造函数

移动构造函数的形式为 ClassName(ClassName&& other),其中 && 表示右值引用。它允许我们在对象初始化时,将一个临时对象(右值)的资源直接转移到新对象,而不是进行深拷贝。

6.2 示例代码 - 移动构造函数

#include <iostream>
#include <cstring>

class MyString {
private:
    char* str;
public:
    MyString(const char* s = nullptr) {
        if (s) {
            str = new char[strlen(s) + 1];
            strcpy(str, s);
        } else {
            str = new char[1];
            *str = '\0';
        }
    }

    // 拷贝构造函数
    MyString(const MyString& other) {
        if (other.str) {
            str = new char[strlen(other.str) + 1];
            strcpy(str, other.str);
        } else {
            str = new char[1];
            *str = '\0';
        }
    }

    // 移动构造函数
    MyString(MyString&& other) noexcept {
        str = other.str;
        other.str = nullptr;
    }

    // 赋值运算符重载
    MyString& operator=(const MyString& other) {
        if (this != &other) {
            delete[] str;
            if (other.str) {
                str = new char[strlen(other.str) + 1];
                strcpy(str, other.str);
            } else {
                str = new char[1];
                *str = '\0';
            }
        }
        return *this;
    }

    ~MyString() {
        delete[] str;
    }

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

MyString createString() {
    return MyString("Temporary String");
}

int main() {
    MyString s1 = createString();
    s1.print();

    return 0;
}

在上述代码中,MyString 类增加了移动构造函数。在 createString 函数中返回一个临时的 MyString 对象,在 main 函数中通过 MyString s1 = createString() 使用移动构造函数将临时对象的资源转移给 s1。移动构造函数中,直接将 other.str 赋值给 str,并将 other.str 置为 nullptr,这样就避免了深拷贝的开销。

6.3 移动赋值运算符

移动赋值运算符的形式为 ClassName& operator=(ClassName&& other),它用于在已存在对象之间高效地转移资源。

6.4 示例代码 - 移动赋值运算符

#include <iostream>
#include <cstring>

class MyString {
private:
    char* str;
public:
    MyString(const char* s = nullptr) {
        if (s) {
            str = new char[strlen(s) + 1];
            strcpy(str, s);
        } else {
            str = new char[1];
            *str = '\0';
        }
    }

    // 拷贝构造函数
    MyString(const MyString& other) {
        if (other.str) {
            str = new char[strlen(other.str) + 1];
            strcpy(str, other.str);
        } else {
            str = new char[1];
            *str = '\0';
        }
    }

    // 移动构造函数
    MyString(MyString&& other) noexcept {
        str = other.str;
        other.str = nullptr;
    }

    // 赋值运算符重载
    MyString& operator=(const MyString& other) {
        if (this != &other) {
            delete[] str;
            if (other.str) {
                str = new char[strlen(other.str) + 1];
                strcpy(str, other.str);
            } else {
                str = new char[1];
                *str = '\0';
            }
        }
        return *this;
    }

    // 移动赋值运算符
    MyString& operator=(MyString&& other) noexcept {
        if (this != &other) {
            delete[] str;
            str = other.str;
            other.str = nullptr;
        }
        return *this;
    }

    ~MyString() {
        delete[] str;
    }

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

int main() {
    MyString s1("Hello");
    MyString s2("World");

    s2 = std::move(s1);
    s2.print();

    return 0;
}

在上述代码中,MyString 类增加了移动赋值运算符。通过 s2 = std::move(s1) 调用移动赋值运算符,将 s1 的资源转移给 s2。移动赋值运算符中,先释放 s2 原有的资源,然后将 s1str 赋值给 s2str,并将 s1.str 置为 nullptr

七、总结赋值运算符、拷贝构造函数与移动语义的关系

  1. 拷贝构造函数和赋值运算符:它们主要用于对象之间的复制操作,在没有特殊需求时,编译器会自动生成默认版本,但对于包含动态资源(如指针)的类,需要开发者显式实现深拷贝以避免内存问题。
  2. 移动构造函数和移动赋值运算符:它们是为了提高对象在初始化和赋值过程中对临时对象(右值)资源的转移效率而引入的。移动语义通过转移资源而不是复制资源,减少了不必要的内存分配和释放操作,提高了程序性能。
  3. 相互关系:在现代 C++ 编程中,当设计一个类时,如果需要自定义拷贝构造函数和赋值运算符,通常也需要考虑实现移动构造函数和移动赋值运算符,以充分利用移动语义带来的性能提升。这就是“Rule of Five”的核心思想,确保类在各种情况下都能正确、高效地处理对象的初始化、赋值和销毁操作。

通过深入理解 C++ 赋值运算符与拷贝构造函数的区别与联系,以及移动语义的相关内容,开发者能够编写出更健壮、高效的 C++ 代码,更好地管理对象的生命周期和内存资源。无论是在小型项目还是大型系统开发中,这些知识都是 C++ 编程的重要基础。