赋值运算符为什么要避免自赋值?

最近看到的部分代码中,赋值运算符重载总是忘记自赋值的处理,但是自赋值的处理又非常重要,所以我决定写这篇文章。

1. 重要前提

并不是所有的场景下自我赋值都会导致问题。自赋值风险只针对手动资源管理的类/结构体,满足如下条件的类/结构体,自我赋值完全安全:

  • 类成员全部为基础栈类型:int、char、double 等;
  • 使用标准库容器:std::string、std::vector(内部已做安全处理);
  • 使用编译器默认生成的赋值运算符(仅逐成员浅拷贝,无手动释放逻辑)。

虽然这种场景下,自赋值只会自己覆盖自己,数据不变、无崩溃、无资源泄漏。但是也不推荐,毕竟这些成员之间的赋值也是有CPU开销的。

2. 风险原因

前文提到,自赋值风险只针对手动资源管理的类/结构体 。这主要是因为,手动资源管理的类/结构体,其赋值运算符重载的常规逻辑是:先释放旧资源 → 再拷贝新资源,形如如下的代码:

cpp 复制代码
#include <cstring>
#include <iostream>

class MyStr {
public:
    // 构造函数与析构函数省略

    // 传统赋值:先释放旧资源,再拷贝新资源(自赋值时致命)
    MyStr& operator=(const MyStr& rhs) {
        delete[] data_;                                // 1. 先释放自身资源
        data_ = new char[std::strlen(rhs.data_) + 1];  // 2. 再分配新资源
        std::strcpy(data_, rhs.data_);                 // 3. 拷贝数据(此时 rhs.data_ 已被释放,野指针)
        return *this;
    }

    const char* c_str() const { return data_; }

private:
    char* data_;  // 管理堆内存
};

正是这样的代码,如果存在MyStr类型的对象发生了自赋值时,赋值函数内 this == &rhs,两个指针指向同一个对象。程序会先执行「释放自身堆内存」的逻辑,提前销毁了自己的数据;后续再读取源对象数据进行拷贝时,原内存已被释放,变成野指针,直接触发程序崩溃。 如上只是以管理内存为例,持有文件句柄、网络套接字等资源的类,也会面临类似问题。

3. 解决方案

3.1 区分自我

通常,最常用的解决方案,是在赋值函数开头增加自我赋值的判断,不允许自我赋值发生。

cpp 复制代码
MyStr& operator=(const MyStr& rhs) {
    if (this == &rhs) {  // 自赋值拦截
        return *this;
    }
    delete[] data_;
    data_ = new char[std::strlen(rhs.data_) + 1];
    std::strcpy(data_, rhs.data_);
    return *this;
}

3.2 Copy-and-Swap 机制

前文提到自赋值风险是因为先释放旧资源,再拷贝新资源 导致的,只要打破这个顺序,就可以避免风险。Copy-and-Swap 机制,就是利用了这个原理,它先拷贝、再交换、最后释放旧资源 。它从底层架构上天然规避自赋值风险,自带异常安全特性,还无需人工判断。 所谓的先拷贝、再交换、最后释放旧资源,借助临时对象,将所有风险操作(内存分配、数据拷贝)转移到临时对象,原对象全程保持安全,不会被篡改、不会提前释放资源。实现写法如下:

cpp 复制代码
#include <utility>
#include <cstring>

class MyStr {
public:
    MyStr(const char* s = "") {
        data_ = new char[std::strlen(s) + 1];
        std::strcpy(data_, s);
    }
    // 拷贝构造:用于生成临时对象
    MyStr(const MyStr& other) {
        data_ = new char[std::strlen(other.data_) + 1];
        std::strcpy(data_, other.data_);
    }
    ~MyStr() { delete[] data_; }

    // 交换内部资源,noexcept 保证不抛异常
    void swap(MyStr& other) noexcept {
        std::swap(data_, other.data_);
    }

    // 范式一:const 引用传参 + 手动临时对象
    MyStr& operator=(const MyStr& rhs) {
        MyStr(rhs).swap(*this);  // 拷贝出临时对象,交换后旧资源随临时对象析构
        return *this;
    }

    // 范式二:值传递自动生成临时对象(与范式一等价,二选一即可)
    // MyStr& operator=(MyStr rhs) noexcept {
    //     swap(rhs);
    //     return *this;
    // }

private:
    char* data_;  // 管理堆内存
};

如上代码中,范式一主动构建临时对象,范式二借助值传递自动生成临时对象。二者等价,二选一即可。

4. 总结

自我赋值的风险不在于语法违规,而在于传统赋值「先释放、后拷贝」的资源逻辑,会导致自赋值时数据提前销毁、程序崩溃。简单栈对象的自赋值是安全的,但手动管理堆资源的自定义类必须严防自赋值。传统的 if 判断只能解决自赋值崩溃,无法解决异常安全问题,工程中不推荐;Copy-and-Swap 是工业级标准解法,通过临时对象隔离风险,同时实现自赋值安全与异常安全,是资源类赋值运算符的推荐实现。

相关推荐
会编程的小孩18 小时前
C++ qt的安装
c++
小成202303202651 天前
C++~07补充
开发语言·c++
hold?fish:palm1 天前
40 二叉树的直径
c++·leetcode
解决小子1 天前
【虚幻引擎】【C++】【GAS框架】构建角色基类、属性集、数值钳制
c++·笔记·游戏引擎·虚幻·gas·能力系统
lunar_coder1 天前
【C++】如何解决智能指针的析构函数,如何实现shared_ptr的引用计数以及解决shared_ptr的循环引用
c++·1024程序员节
白白白小纯1 天前
C++进阶.多态
c++
凯瑟琳.奥古斯特1 天前
力扣1016高效解法:剪枝优化技巧
开发语言·c++·算法·leetcode·职场和发展
xiangyun611 天前
C++ priority_queue 底层:手撕二叉堆与 Top K 问题
c++·算法·面试·stl·1024程序员节
坤坤子吖1 天前
C++继承:友元、静态成员、多继承与组合
开发语言·c++·笔记·学习·1024程序员节
qz_Serene1 天前
练习算法题:双指针 -cpp
c++·算法·leetcode·1024程序员节