【C++面试高频】深拷贝、浅拷贝、拷贝构造函数和赋值运算符详解

一、什么是对象拷贝?

C++ 中经常会出现"把一个对象交给另一个对象"的情况,例如:

复制代码
MyString a("hello");

// 使用 a 创建一个新对象 b
MyString b(a);

// 对象 c 已经存在,再把 a 的内容赋给 c
MyString c("world");
c = a;

上面两种写法看起来都像"复制对象",但本质不同:

复制代码
MyString b(a);  → 拷贝构造函数
c = a;          → 赋值运算符重载函数

简单理解:

复制代码
创建新对象时进行复制:调用拷贝构造函数。
对象已经存在,再覆盖原内容:调用赋值运算符。

二、拷贝构造函数和赋值运算符什么时候调用?

1. 拷贝构造函数

拷贝构造函数用于:使用一个已有对象创建一个新对象。

复制代码
MyString a("hello");

// 用 a 创建 b,调用拷贝构造函数
MyString b(a);

// 这也是初始化新对象,概念上同样属于拷贝构造
MyString c = a;

拷贝构造函数的常见写法:

复制代码
MyString(const MyString& other);

其中:

复制代码
const MyString& other

表示按常量引用传参。

这样做有两个好处:

复制代码
1. 使用引用,避免再次拷贝对象。
2. 使用 const,保证不会修改原对象。

2. 赋值运算符

赋值运算符用于:左边对象已经存在,再把右边对象的数据赋给它。

复制代码
MyString a("hello");
MyString b("world");

// b 已经创建完成,现在把 a 的内容赋给 b
b = a;

赋值运算符重载通常写成:

复制代码
MyString& operator=(const MyString& other);

这里返回 MyString&,是为了支持连续赋值:

复制代码
a = b = c;

执行过程可以理解为:

复制代码
先执行 b = c,
再把 b 的引用作为结果返回,
然后执行 a = b。

三、什么是浅拷贝?

浅拷贝指的是:只复制成员变量本身,不复制成员变量指向的实际资源。

例如一个类中有指针成员:

复制代码
class BadString {
public:
    char* data;

    BadString(const char* text) {
        int len = strlen(text);

        // 在堆区申请空间保存字符串
        data = new char[len + 1];

        strcpy(data, text);
    }

    ~BadString() {
        // 释放 data 指向的堆区内存
        delete[] data;
    }
};

如果没有自己写拷贝构造函数和赋值运算符,编译器会默认生成。

默认生成的拷贝逻辑通常只是把成员变量逐个复制:

复制代码
BadString a("hello");

// 编译器默认复制 data 指针中的地址
BadString b = a;

此时:

复制代码
a.data 和 b.data 保存的是同一个地址。
a 和 b 实际上指向同一块堆区内存。

可以理解成:

复制代码
a.data  ───┐
           ├──> "hello"
b.data  ───┘

这样会产生两个严重问题。

1. 修改一个对象可能影响另一个对象

因为两个对象共用同一块数据,一个对象修改内容,另一个对象看到的内容也可能变化。

2. 可能发生重复释放

ab 生命周期结束时,它们都会调用析构函数:

复制代码
delete[] data;

但是这块内存只能释放一次。

如果第一次释放后,第二个对象又尝试释放同一地址,就可能导致:

复制代码
重复释放内存
程序崩溃
未定义行为

这就是浅拷贝在"类中管理堆区资源"时最常见的问题。


四、什么是深拷贝?

深拷贝指的是:不仅复制成员变量,还要重新申请一块独立资源,把原对象的数据复制过去。

也就是说:

复制代码
a.data  ───> "hello"
b.data  ───> "hello"

虽然两个对象内容相同,但它们分别拥有自己的内存,互不影响。

下面通过一个完整示例,说明如何实现深拷贝。

复制代码
#include <iostream>
#include <cstring>
using namespace std;

class MyString {
private:
    char* data;

public:
    // 普通构造函数:在堆区申请空间并保存字符串
    MyString(const char* text = "") {
        int len = strlen(text);

        // 多申请 1 个字符位置,用来保存字符串结尾的 '\0'
        data = new char[len + 1];

        // 将 text 的内容复制到 data 指向的内存中
        strcpy(data, text);

        cout << "普通构造函数:" << data << endl;
    }

    // 拷贝构造函数:使用 other 创建一个新对象
    MyString(const MyString& other) {
        int len = strlen(other.data);

        // 重点:重新申请一块新的堆区内存
        data = new char[len + 1];

        // 复制 other 对象中的字符串内容,而不是只复制指针地址
        strcpy(data, other.data);

        cout << "拷贝构造函数:" << data << endl;
    }

    // 赋值运算符重载:把 other 的内容赋给当前对象
    MyString& operator=(const MyString& other) {
        // 防止自己给自己赋值,例如:a = a;
        if (this == &other) {
            return *this;
        }

        int len = strlen(other.data);

        // 先申请新空间
        // 这样即使申请失败,也不会破坏当前对象原有的数据
        char* newData = new char[len + 1];

        // 将 other 的内容复制到新空间
        strcpy(newData, other.data);

        // 再释放当前对象原来管理的内存
        delete[] data;

        // 让当前对象指向新的内存
        data = newData;

        cout << "赋值运算符:" << data << endl;

        // 返回当前对象本身,支持连续赋值
        return *this;
    }

    // 输出字符串内容
    void print() const {
        cout << "当前内容:" << data << endl;
    }

    // 析构函数:释放当前对象自己管理的堆区内存
    ~MyString() {
        cout << "析构函数释放:" << data << endl;

        delete[] data;
        data = nullptr;
    }
};

int main() {
    // 调用普通构造函数
    MyString a("hello");

    // 使用 a 创建新对象 b,调用拷贝构造函数
    MyString b(a);

    // c 已经创建完成
    MyString c("world");

    // 把 a 的内容赋给 c,调用赋值运算符
    c = a;

    // 自赋值,不应该出现问题
    c = c;

    a.print();
    b.print();
    c.print();

    return 0;
}

这段代码中:

复制代码
MyString b(a);

会调用拷贝构造函数。

复制代码
c = a;

会调用赋值运算符。

由于拷贝构造函数和赋值运算符都重新申请了内存,所以 abc 各自拥有独立的数据,析构时不会重复释放。


五、为什么赋值运算符中要判断自赋值?

自赋值指的是:

复制代码
a = a;

也就是当前对象给自己赋值。

在赋值运算符中:

复制代码
if (this == &other) {
    return *this;
}

其中:

复制代码
this:当前对象的地址。
&other:传入对象的地址。

如果两者相同,说明当前对象和传入对象是同一个对象,不需要继续执行复制操作。

如果没有处理自赋值,并且写成下面这种不安全的逻辑:

复制代码
delete[] data;
data = new char[strlen(other.data) + 1];
strcpy(data, other.data);

other 就是当前对象本身时,delete[] data 会先把自己的数据释放。

后面再访问:

复制代码
other.data

就相当于访问已经释放的内存,可能导致程序崩溃。

因此,赋值运算符中一般都要考虑自赋值问题。


六、拷贝构造函数和赋值运算符的区别

对比项 拷贝构造函数 赋值运算符
作用 用已有对象创建新对象 给已存在对象重新赋值
典型写法 MyString b(a); b = a;
左侧对象状态 左侧对象还没有创建 左侧对象已经存在
是否需要释放旧资源 不需要,因为对象是新创建的 需要,避免旧资源泄漏
常见形式 MyString(const MyString&) MyString& operator=(const MyString&)

可以这样记:

复制代码
拷贝构造:新对象出生时,参考另一个对象创建自己。
赋值运算符:对象已经存在,把自己的旧内容换成另一个对象的内容。

七、Rule of Three、Rule of Five 和 Rule of Zero

1. Rule of Three

C++ 中有一个常见经验:

如果一个类需要自己管理资源,并且自己定义了以下任意一个函数:

复制代码
析构函数
拷贝构造函数
拷贝赋值运算符

那么通常也需要认真考虑另外两个函数是否应该自己实现。

这就是 Rule of Three,也就是"三法则"。

原因是:这三个函数都与资源所有权和对象拷贝有关。

例如类中有:

复制代码
char* data;

并且析构函数中写了:

复制代码
delete[] data;

那么默认浅拷贝通常就不安全,因此通常需要自己实现拷贝构造函数和赋值运算符。


2. Rule of Five

C++11 引入右值引用和移动语义后,又多了两个和资源转移有关的函数:

复制代码
移动构造函数
移动赋值运算符

所以现在常说 Rule of Five:

复制代码
析构函数
拷贝构造函数
拷贝赋值运算符
移动构造函数
移动赋值运算符

这部分在初学阶段不需要一下子写得很复杂,但要知道:

复制代码
管理资源的类,除了要考虑复制,还要考虑资源是否可以高效转移。

3. Rule of Zero

现代 C++ 中更推荐的思想是 Rule of Zero。

意思是:尽量不要自己手动管理资源,而是使用已经实现好资源管理的类型。

例如:

复制代码
std::string
std::vector
std::unique_ptr
std::shared_ptr

这样一般不需要自己写析构函数、拷贝构造函数和赋值运算符。

例如使用 std::string

复制代码
#include <iostream>
#include <string>
using namespace std;

class Student {
private:
    string name;

public:
    Student(const string& n) : name(n) {}

    void print() const {
        cout << "name = " << name << endl;
    }
};

int main() {
    Student s1("Tom");

    // string 自己会正确处理拷贝
    Student s2 = s1;

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

    return 0;
}

std::string 已经帮我们处理好了内存申请、释放和深拷贝问题。

所以实际项目中:

复制代码
能使用 std::string,就尽量不要自己用 char* 管理字符串。
能使用 vector,就尽量不要手写动态数组。
能使用智能指针,就尽量不要手动 new/delete。

八、面试高频问题整理

1. 什么是浅拷贝?

浅拷贝是指只复制对象成员变量本身,不复制成员变量指向的实际资源。

如果类中有指针成员,默认拷贝通常只会复制指针地址,导致多个对象指向同一块堆区内存。这样可能出现数据互相影响、重复释放和程序崩溃等问题。


2. 什么是深拷贝?

深拷贝是指重新申请独立资源,再把原对象的数据复制到新资源中。

深拷贝后,两个对象内容相同,但各自拥有独立内存,修改一个对象不会影响另一个对象,也不会发生重复释放。


3. 拷贝构造函数和赋值运算符有什么区别?

拷贝构造函数用于使用已有对象创建新对象,例如:

复制代码
MyString b(a);

赋值运算符用于对象已经存在后重新赋值,例如:

复制代码
b = a;

拷贝构造时对象没有旧资源需要释放;赋值时对象可能已经管理了旧资源,因此需要先正确处理旧资源,避免内存泄漏。


4. 为什么拷贝构造函数参数通常写成 const 引用?

常见写法是:

复制代码
MyString(const MyString& other);

使用引用可以避免传参时再次调用拷贝构造函数。

使用 const 可以保证函数内部不修改原对象,也可以让该函数接收常量对象。


5. 为什么赋值运算符要返回引用?

赋值运算符通常返回当前对象的引用:

复制代码
MyString& operator=(const MyString& other);

这样可以支持连续赋值:

复制代码
a = b = c;

同时返回引用可以避免返回对象副本带来的额外拷贝。


6. 为什么赋值运算符要处理自赋值?

因为可能出现:

复制代码
a = a;

如果没有处理自赋值,并且先释放当前对象资源,再从 other 中读取数据,可能会访问已经释放的内存。

因此通常会先判断:

复制代码
if (this == &other) {
    return *this;
}

7. 什么是 Rule of Three?

Rule of Three 指的是:如果一个类自己管理资源,并且定义了析构函数、拷贝构造函数、拷贝赋值运算符中的任意一个,那么通常需要考虑是否应该同时定义另外两个。

因为这三个函数共同决定了资源如何释放和复制。


九、总结

深拷贝、浅拷贝、拷贝构造函数和赋值运算符是 C++ 面试中非常常见的知识点。

浅拷贝只复制指针地址,多个对象可能指向同一块内存。当类中存在动态申请的资源,并且析构函数负责释放资源时,浅拷贝可能导致重复释放和程序崩溃。

深拷贝会重新申请独立内存,并复制原对象的数据。这样每个对象都拥有自己的资源,修改和析构互不影响。

拷贝构造函数用于创建新对象时的复制,赋值运算符用于已存在对象之间的赋值。赋值运算符需要注意旧资源释放、自赋值和返回引用等问题。

简单记忆:

复制代码
浅拷贝:复制地址,多个对象共用资源,容易出问题。
深拷贝:重新申请资源,多个对象各自独立。
拷贝构造:新对象从旧对象创建。
赋值运算符:已有对象覆盖旧内容。
Rule of Three:析构、拷贝构造、拷贝赋值通常要一起考虑。
Rule of Zero:优先使用 string、vector、智能指针等现成类型管理资源。

实际开发中,优先使用 std::stringstd::vector 和智能指针,尽量减少手写 new/delete。只有在确实需要自己管理底层资源时,才需要手动实现深拷贝和相关特殊成员函数。

0voice · GitHub