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

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

1. 重要前提

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

  • 类成员全部为基础栈类型:intchardouble 等;
  • 使用标准库容器:std::stringstd::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 是工业级标准解法,通过临时对象隔离风险,同时实现自赋值安全与异常安全,是资源类赋值运算符的推荐实现。

相关推荐
wuminyu4 小时前
Kafka中sendfile与mmap实现机制解析
java·linux·c语言·jvm·c++
无小道9 小时前
C++——列表初始化
c++·列表初始化
blueSatchel10 小时前
c++action 编写(ros-humble)
c++·ros2
老赵的博客11 小时前
c++面试之从虚函数表到Rtti一次性讲清楚
c++·qt
傲世仙尊12 小时前
System V 进程间通信详解:共享内存、消息队列与信号量(CSDN博客)
linux·开发语言·c++
cvby13 小时前
C++11
开发语言·c++
6Hzlia13 小时前
【Classic 150 刷题计划】 LeetCode 26. 删除有序数组中的重复项 | C++ 快慢双指针经典模板
c++·算法·leetcode
夜不会漫长14 小时前
C++:类和对象(2)
java·javascript·c++
无小道14 小时前
C++ 中 `explicit` 关键字详解:彻底理解隐式类型转换
c++·explicit
蒸蒸yyyyzwd14 小时前
cpp 选手秋招学习笔记 day34
c++·八股