14、类与对象(下):拷贝构造函数、深拷贝与浅拷贝、拷贝赋值运算符、移动构造函数与移动赋值运算符、RAII原则

好,咱们继续往下聊。上一章我们把构造函数和析构函数讲透了,这一章要聊的,是C++里最容易踩坑、也最能体现功底的地方——拷贝和移动。

说实话,我见过太多人写了几年C++,还在为浅拷贝的问题头疼。我自己刚入行那会儿,也因为这个bug熬过好几个通宵。嗯,今天咱们就把这块彻底掰扯清楚。

14.1 拷贝构造函数:什么时候会被调用?

拷贝构造函数,说白了就是用已存在的对象去初始化新对象。它的签名长这样:

class Widget {
public:
    Widget(const Widget& other);  // 拷贝构造函数
    // ...
};

什么时候会触发它?三种情况:

  • 用对象直接初始化另一个对象:Widget w2 = w1;
  • 函数传参时按值传递:void func(Widget w);
  • 函数返回对象时按值返回:Widget createWidget();

你想想看,如果编译器没做优化,每次拷贝都要完整复制一份,代价可不小。不过现代编译器有RVO(返回值优化),这个我们后面再聊。

小提示:我个人习惯把拷贝构造函数的参数声明为const引用。为什么?因为如果不是引用,传参时又会触发拷贝构造,死循环了。你试试看就知道了。

14.2 浅拷贝 vs 深拷贝:一个指针引发的血案

这是C++新手最容易翻车的地方。我当年第一次写带指针的类,就栽在这里。

浅拷贝:只是简单地把成员变量的值复制过去。对于指针,复制的是地址,不是指针指向的数据。

深拷贝:不仅复制指针本身,还要复制指针指向的那块内存。

看个例子:

class StringBad {
private:
    char* str;
    int len;
public:
    StringBad(const char* s) {
        len = strlen(s);
        str = new char[len + 1];
        strcpy(str, s);
    }
    // 默认拷贝构造函数——浅拷贝!
    // StringBad(const StringBad& other) 
    //     : str(other.str), len(other.len) {}  // 两个对象指向同一块内存
    
    ~StringBad() {
        delete[] str;  // 两个对象析构时,同一块内存被释放两次!
    }
};

为什么会这样?两个对象共享同一块内存,第一个析构时释放了,第二个析构时再释放——程序直接崩溃。

解决办法就是自己写深拷贝:

class StringGood {
private:
    char* str;
    int len;
public:
    StringGood(const char* s) {
        len = strlen(s);
        str = new char[len + 1];
        strcpy(str, s);
    }
    
    // 深拷贝构造函数
    StringGood(const StringGood& other) {
        len = other.len;
        str = new char[len + 1];  // 重新分配内存
        strcpy(str, other.str);   // 复制数据
    }
    
    ~StringGood() {
        delete[] str;
    }
};

警告:我曾经在一个项目中,因为忘记写深拷贝构造函数,导致两个对象共享同一个文件句柄。一个对象关闭了文件,另一个还在用——结果就是诡异的"文件已关闭"错误,查了两天才找到根因。记住:只要类里有指针成员,大概率需要深拷贝。

14.3 拷贝赋值运算符:不只是拷贝

拷贝赋值运算符和拷贝构造函数很像,但有本质区别:

  • 拷贝构造函数:创建新对象时调用
  • 拷贝赋值运算符:已有对象被赋值时调用

它的标准写法,我建议你记住这个模式:

class StringGood {
public:
    StringGood& operator=(const StringGood& other) {
        // 1. 自赋值检查
        if (this == &other) {
            return *this;
        }
        
        // 2. 释放旧资源
        delete[] str;
        
        // 3. 分配新资源并复制数据
        len = other.len;
        str = new char[len + 1];
        strcpy(str, other.str);
        
        // 4. 返回自身引用
        return *this;
    }
};

这里有个细节:为什么要先释放旧资源?因为当前对象可能已经持有了一块内存,不释放就泄漏了。为什么要检查自赋值?因为如果自己给自己赋值,先释放再分配就出事了——数据都没了。

核心要点:拷贝赋值运算符必须处理自赋值、释放旧资源、深拷贝新资源、返回引用这四个步骤。少一步都不行。

14.4 移动构造函数与移动赋值运算符:C++11的礼物

说实话,C++11之前,临时对象的拷贝效率是个大问题。你想想看,一个函数返回一个很大的vector,要完整拷贝一份,多浪费啊。

移动语义就是来解决这个问题的。它的核心思想:偷走临时对象的资源,而不是复制

class Buffer {
private:
    int* data;
    size_t size;
public:
    // 移动构造函数
    Buffer(Buffer&& other) noexcept 
        : data(other.data), size(other.size) {
        other.data = nullptr;  // 把源对象置为空
        other.size = 0;
    }
    
    // 移动赋值运算符
    Buffer& operator=(Buffer&& other) noexcept {
        if (this != &other) {
            delete[] data;           // 释放自己的资源
            data = other.data;       // 偷走对方的资源
            size = other.size;
            other.data = nullptr;    // 对方不再持有资源
            other.size = 0;
        }
        return *this;
    }
};

注意那个noexcept关键字。为什么加它?因为标准库容器(比如vector)在重新分配内存时,如果移动操作不抛异常,就会优先用移动;否则只能用拷贝。加了noexcept,性能能提升不少。

经验之谈:我建议你养成习惯,只要写了移动构造函数和移动赋值运算符,就加上noexcept。这不仅是好习惯,还能让标准库容器更高效地使用你的类。

14.5 RAII原则:资源管理的终极答案

RAII,全称是"资源获取即初始化"。名字听着高大上,说白了就是:把资源和对象的生命周期绑定在一起

资源在构造函数中获取,在析构函数中释放。这样,只要对象活着,资源就在;对象死了,资源自动释放。

你想想看,这解决了什么问题?异常安全。如果代码中途抛出异常,栈展开时会自动调用析构函数,资源就不会泄漏。

class FileGuard {
private:
    FILE* file;
public:
    FileGuard(const char* filename, const char* mode) {
        file = fopen(filename, mode);
        if (!file) {
            throw std::runtime_error("文件打开失败");
        }
    }
    
    ~FileGuard() {
        if (file) {
            fclose(file);
        }
    }
    
    // 禁止拷贝
    FileGuard(const FileGuard&) = delete;
    FileGuard& operator=(const FileGuard&) = delete;
    
    // 允许移动
    FileGuard(FileGuard&& other) noexcept 
        : file(other.file) {
        other.file = nullptr;
    }
    
    void write(const char* data) {
        fprintf(file, "%s", data);
    }
};

这个类用起来很安全:

void processData() {
    FileGuard guard("output.txt", "w");
    guard.write("Hello, RAII!");
    // 不管这里是否抛出异常,guard析构时都会关闭文件
}

注意:RAII类通常应该禁止拷贝,或者实现深拷贝/移动语义。为什么?因为如果两个对象共享同一个文件句柄,一个析构时关闭了文件,另一个就成野指针了。我曾经在一个遗留代码里看到过这种bug,排查起来特别痛苦。

14.6 知识体系总览

这一章的内容比较多,我画了张图帮你理清关系:

类与对象(下)核心知识体系 特殊成员函数 拷贝构造函数 拷贝赋值运算符 移动构造函数 移动赋值 析构函数 浅拷贝 vs 深拷贝 指针成员必须深拷贝 自赋值检查 if (this == &other) noexcept 保证 移动操作不抛异常 资源转移 偷走而非复制 RAII 原则 资源获取即初始化 · 析构自动释放

这张图把这一章的核心概念串起来了。你看,拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符、析构函数——这五个特殊成员函数构成了C++资源管理的基石。而RAII原则,就是把这些函数串起来的那根线。

最后说一句:写C++类的时候,我建议你遵循"三五法则"——如果你需要显式定义析构函数、拷贝构造函数、拷贝赋值运算符中的任何一个,那么大概率三个都需要。C++11之后,移动构造函数和移动赋值运算符也加入了这个行列。

好了,这一章的内容就到这里。记住:资源管理是C++的核心能力,而拷贝和移动是资源管理的核心操作。把这些搞明白了,你的C++水平会上一个大台阶。


专注资料整理