12. 类与对象(下):拷贝构造函数;拷贝赋值运算符;移动语义;RAII原则

好,咱们继续往下聊。上一章我们把构造函数和析构函数讲透了,这一章要聊的这几个东西,说实话,是C++里最容易踩坑的地方。我自己带团队的时候,代码Review里有一半的问题都出在这几个概念上。

拷贝构造、拷贝赋值、移动语义、RAII。这四个东西,你搞明白了,C++就算入门了。搞不明白,写出来的代码就是定时炸弹。

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

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

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

什么时候会触发?我列几个常见场景:

  • 用一个对象初始化另一个对象:MyClass b = a;
  • 函数传参时按值传递:void func(MyClass obj);
  • 函数返回时按值返回:MyClass createObj();

嗯,这里要注意:MyClass b = a;MyClass b(a); 其实是一回事,都是拷贝构造,不是赋值。

核心要点:如果你没有显式定义拷贝构造函数,编译器会帮你生成一个。但这个默认版本是浅拷贝——只复制成员变量的值,不复制指针指向的资源。

12.2 拷贝赋值运算符:和拷贝构造有什么区别?

拷贝赋值运算符处理的是两个都已经存在的对象之间的赋值。签名是:

MyClass& operator=(const MyClass& other);

区别在哪?我举个例子你就明白了:

MyClass a;
MyClass b = a;   // 拷贝构造:b还没出生
MyClass c;
c = a;           // 拷贝赋值:c已经存在了

写拷贝赋值的时候,有个经典陷阱——自赋值。我曾经在项目里见过一个bug,就是因为没处理自赋值,导致对象把自己释放了然后又去读自己的数据,直接崩溃。

避坑指南:写拷贝赋值运算符时,一定要检查自赋值。标准写法是:

MyClass& operator=(const MyClass& other) {
    if (this == &other) return *this;  // 自赋值检查
    // 释放旧资源
    // 拷贝新资源
    return *this;
}

12.3 移动语义:为什么需要它?

你想想看,有时候我们拷贝一个对象,其实源对象马上就要被销毁了。比如函数返回一个临时对象,或者把一个vector里的元素移到另一个位置。这时候做深拷贝,完全是浪费性能。

移动语义就是干这个的。它把源对象的资源偷过来,然后把源对象置为空。C++11引入了右值引用 && 来支持这个机制。

class MyString {
public:
    // 移动构造函数
    MyString(MyString&& other) noexcept 
        : data_(other.data_), size_(other.size_) {
        other.data_ = nullptr;  // 把源对象置空
        other.size_ = 0;
    }
    
    // 移动赋值运算符
    MyString& operator=(MyString&& other) noexcept {
        if (this == &other) return *this;
        delete[] data_;          // 释放自己的旧资源
        data_ = other.data_;     // 偷走对方的资源
        size_ = other.size_;
        other.data_ = nullptr;   // 把对方置空
        other.size_ = 0;
        return *this;
    }
};

个人经验:移动构造函数和移动赋值运算符最好加上 noexcept。为什么?因为标准库的容器(比如vector)在重新分配内存时,如果移动操作是noexcept的,它会优先使用移动而不是拷贝。否则,为了保证异常安全,它只能老老实实做拷贝。这个性能差距,在大量数据时非常明显。

12.4 RAII原则:资源管理的核心思想

RAII,全称是Resource Acquisition Is Initialization。名字很绕,但思想很简单:资源在构造函数中获取,在析构函数中释放

说白了,就是让对象的生命周期来管理资源。比如智能指针 std::unique_ptr,它在构造时获取内存,在析构时自动释放。你不需要手动调用delete。

我在项目中遇到过这样一个案例:一个老同事写了一段代码,打开文件后忘记关闭,导致文件句柄泄漏。后来我帮他改成RAII风格,用一个FileGuard类来管理文件句柄,问题就再也没出现过。

RAII的核心原则:

  • 资源获取即初始化:在构造函数中获取资源
  • 资源释放即析构:在析构函数中释放资源
  • 禁止拷贝或正确管理拷贝:要么禁用拷贝,要么实现深拷贝
  • 支持移动:允许资源所有权的转移

12.5 知识体系图

下面这张图把本章的核心逻辑串起来了,你可以对照着看:

类与对象(下) 拷贝构造函数 用已有对象初始化新对象 默认浅拷贝,需深拷贝 拷贝赋值运算符 两个已存在对象间赋值 注意自赋值检查 移动语义 右值引用 && 偷资源,置空源对象 RAII原则 资源获取即初始化 析构函数释放资源 核心:管理资源,避免泄漏

12.6 综合示例:一个完整的RAII类

最后,我给你看一个完整的例子。这个类管理一块动态内存,实现了拷贝构造、拷贝赋值、移动构造、移动赋值,遵循RAII原则:

class Buffer {
public:
    // 构造函数:获取资源
    explicit Buffer(size_t size) : size_(size), data_(new char[size]) {}
    
    // 析构函数:释放资源
    ~Buffer() { delete[] data_; }
    
    // 拷贝构造:深拷贝
    Buffer(const Buffer& other) 
        : size_(other.size_), data_(new char[other.size_]) {
        std::copy(other.data_, other.data_ + size_, data_);
    }
    
    // 拷贝赋值:深拷贝 + 自赋值检查
    Buffer& operator=(const Buffer& other) {
        if (this == &other) return *this;
        delete[] data_;
        size_ = other.size_;
        data_ = new char[size_];
        std::copy(other.data_, other.data_ + size_, data_);
        return *this;
    }
    
    // 移动构造:偷资源
    Buffer(Buffer&& other) noexcept 
        : size_(other.size_), data_(other.data_) {
        other.data_ = nullptr;
        other.size_ = 0;
    }
    
    // 移动赋值:偷资源 + 自赋值检查
    Buffer& operator=(Buffer&& other) noexcept {
        if (this == &other) return *this;
        delete[] data_;
        data_ = other.data_;
        size_ = other.size_;
        other.data_ = nullptr;
        other.size_ = 0;
        return *this;
    }
    
private:
    size_t size_;
    char* data_;
};

这个类,你拿去用,不会出现内存泄漏,不会出现双重释放,不会出现野指针。这就是RAII的力量。

我的建议:写C++类的时候,先想清楚这个类需不需要管理资源。如果需要,那就老老实实把拷贝构造、拷贝赋值、移动构造、移动赋值、析构函数这五个函数(C++11里叫"五法则")都实现好。别偷懒,偷懒的代价就是线上bug。


公众号:蓝海资料掘金营,微信deep3321