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

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

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 是工业级标准解法,通过临时对象隔离风险,同时实现自赋值安全与异常安全,是资源类赋值运算符的推荐实现。

相关推荐
Escalating_xu1 小时前
【C++ vector 深度解析】从常用接口、扩容机制与迭代器失效到核心模拟实现
java·c++·面试
牛艺翔1 小时前
C++ 基础知识
开发语言·c++
兔兔兔兔12 小时前
记录C++ 11
开发语言·c++
码匠许师傅2 小时前
【C++ 面试真题】32. 聊聊 C++ 的原子变量与无锁编程
java·c++·面试
wabs6662 小时前
关于栈【力扣150.逆波兰表达式求值的思考】
数据结构·c++·算法·leetcode··代码随想录
欧特克_Glodon2 小时前
OpenCV计算机视觉开发入门与实践<十七>:点运算与灰度变换概述
c++·人工智能·opencv·计算机视觉
牛油果子哥q2 小时前
C++序列式容器深度精讲:vector/list/deque底层实现、扩容原理、迭代器失效、性能对比、工程选型避坑
开发语言·c++·list
啦啦啦啦啦zzzz2 小时前
升序链表的定时器
linux·服务器·网络·数据结构·c++·链表
WBluuue2 小时前
数据结构与算法:带权并查集
数据结构·c++·算法