4. 重载赋值运算符:深拷贝、浅拷贝与自赋值

好,咱们今天聊一个非常实在的话题——赋值运算符重载。说实话,这个运算符是C++里最容易出坑的地方之一。我见过不少项目,代码跑着跑着突然崩了,查到最后,十有八九是赋值运算符没写好。

为什么?因为编译器会偷偷给你生成一个默认的赋值运算符。它看起来挺聪明,实际上就是个“浅拷贝”的搬运工。你想想看,如果类里有个指针成员,它直接把指针值复制过去——两个对象指向同一块内存。然后呢?析构的时候double free,程序直接挂掉。

浅拷贝 vs 深拷贝

先看一个典型的“踩坑”例子:

class String {
public:
    String(const char* str = "") {
        m_data = new char[strlen(str) + 1];
        strcpy(m_data, str);
    }
    ~String() { delete[] m_data; }
    
    // 编译器生成的默认赋值运算符(浅拷贝)
    // String& operator=(const String& other) {
    //     m_data = other.m_data;  // 危险!两个对象共享同一块内存
    //     return *this;
    // }

private:
    char* m_data;
};

int main() {
    String s1("Hello");
    String s2("World");
    s2 = s1;  // 浅拷贝:s2.m_data 和 s1.m_data 指向同一地址
    // 析构时:s1 释放内存,s2 再次释放 → 崩溃
}

为什么会这样?因为默认赋值运算符只是把成员变量逐个复制。对于指针,它复制的是地址,而不是地址指向的内容。这就是浅拷贝。

深拷贝就不一样了——它要复制指针指向的整个数据块。说白了,就是“你有一份,我也有一份,互不干扰”。

核心区别:

  • 浅拷贝:复制指针值,两个对象共享同一块内存
  • 深拷贝:复制指针指向的内容,每个对象拥有独立的内存

重载赋值运算符的正确姿势

好,那怎么写一个靠谱的赋值运算符?我个人习惯用“拷贝并交换”这个惯用法,它既简洁又安全。不过咱们先看一个更直观的写法:

class String {
public:
    String& operator=(const String& other) {
        // 1. 处理自赋值
        if (this == &other) {
            return *this;
        }
        
        // 2. 释放当前资源
        delete[] m_data;
        
        // 3. 分配新内存并复制数据
        m_data = new char[strlen(other.m_data) + 1];
        strcpy(m_data, other.m_data);
        
        return *this;
    }
    
private:
    char* m_data;
};

嗯,这里要注意几个关键点:

  1. 自赋值检查if (this == &other) 这一步不能省。我曾经在一个项目里没写这个检查,结果用户代码里写了 obj = obj,直接把自己释放了——那场面,惨不忍睹。
  2. 先释放,再分配:顺序很重要。如果先分配内存失败(比如内存不足),原来的资源已经被释放了,对象就处于一个“半死不活”的状态。更好的做法是用临时变量做拷贝,然后交换。
  3. 返回引用:返回 *this 是为了支持链式赋值,比如 a = b = c

避坑指南: 我曾经在代码里忘了处理自赋值,结果线上服务偶尔崩溃,查了两天才定位到。自赋值看起来是“不可能发生”的事,但在容器、算法里其实很常见。别偷懒,一定要写检查。

拷贝并交换惯用法

如果你觉得上面的写法有点啰嗦,我推荐一个更优雅的方式——拷贝并交换(Copy-and-Swap)。它利用拷贝构造函数和swap函数,把赋值操作变得异常安全:

class String {
public:
    // 拷贝构造函数(深拷贝)
    String(const String& other) 
        : m_data(new char[strlen(other.m_data) + 1]) {
        strcpy(m_data, other.m_data);
    }
    
    // swap 函数
    void swap(String& other) noexcept {
        std::swap(m_data, other.m_data);
    }
    
    // 赋值运算符(拷贝并交换)
    String& operator=(String other) {  // 注意:传值,不是传引用
        swap(other);  // 交换当前对象和临时对象
        return *this;
    }
    
private:
    char* m_data;
};

这个写法妙在哪里?

  • operator= 的参数是传值,所以会调用拷贝构造函数创建一个临时对象。如果拷贝失败(比如内存不足),异常会抛出,当前对象不会被修改——这就是异常安全。
  • swap 只是交换指针,不会抛异常(noexcept)。
  • 临时对象在函数结束时自动析构,释放原来的资源。

说白了,就是把“释放旧资源”和“复制新资源”这两个操作拆开了,让拷贝构造函数去负责复制,让析构函数去负责释放。赋值运算符只做一件事——交换指针。干净利落。

个人经验: 我建议所有需要深拷贝的类都优先考虑拷贝并交换。它写起来简单,而且天然支持异常安全。不过要注意,如果类里有大量资源需要复制,传值可能会带来额外的拷贝开销。这时候可以用传统的写法,但一定要小心异常安全。

自赋值的处理

自赋值的问题,说白了就是 obj = obj 这种写法。虽然程序员一般不会这么写,但在某些场景下它确实会发生:

  • 容器操作:比如 v[i] = v[j],如果 i == j,就是自赋值。
  • 算法实现:某些排序、去重算法可能会产生自赋值。
  • 模板代码:泛型代码里,类型不确定,自赋值是可能发生的。

在拷贝并交换的写法里,自赋值是安全的。因为传值会调用拷贝构造函数,创建一个临时对象,然后交换指针。即使 this == &other,临时对象也是独立的拷贝,交换后当前对象拿到的是自己的拷贝,临时对象析构时释放的是原来的资源——没问题。

但在传统写法里,自赋值检查是必须的。否则 delete[] m_data 会释放自己的资源,然后 strcpy 从已经释放的内存里读数据——未定义行为,程序随时可能崩溃。

知识结构图

下面这张图总结了赋值运算符重载的核心逻辑:

赋值运算符重载核心逻辑 operator= 被调用 自赋值? 返回 *this 选择实现方式 传统写法 1. 释放当前资源 2. 分配新内存并复制 拷贝并交换 1. 传值调用拷贝构造 2. 交换指针 返回 *this

总结

赋值运算符重载,说白了就三件事:

  • 深拷贝:复制指针指向的内容,而不是指针本身。
  • 自赋值检查:防止自己释放自己。
  • 异常安全:要么成功,要么保持原状。

我个人更推荐拷贝并交换的写法,它把这三个问题一次性解决了。当然,如果你对性能有极致要求,传统写法也可以,但一定要把异常安全考虑清楚。

嗯,赋值运算符就聊到这里。记住一句话:如果类里有指针成员,一定要自己写赋值运算符。编译器生成的默认版本,真的靠不住。


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