Effective C++ 条款11:在 operator= 中处理自我赋值

确保当对象自我赋值时 operator= 有良好行为。其中技术包括比较"来源对象"和"目标对象"的地址、精心周到的语句顺序、以及 copy-and-swap。

一、什么是自我赋值?

自我赋值(self-assignment)发生在对象被赋值给自己时。最直观的例子是:

Widget w;
w = w;  // 明显的自我赋值

但在实际开发中,自我赋值往往以更隐蔽的形式出现:

Widget w1, w2;
Widget* pw1 = &w1;
Widget* pw2 = &w1;  // pw1 和 pw2 指向同一对象
*pw1 = *pw2;        // 隐式自我赋值!

// 更隐蔽的情况
Widget& rw1 = w1;
Widget& rw2 = w1;
rw1 = rw2;          // 同样是自我赋值

// 数组中的元素
Widget array[10];
array[i] = array[j]; // 当 i == j 时,自我赋值发生

自我赋值并不总是肉眼可见的 x = x,通过指针、引用或别名访问时同样可能发生。

二、自我赋值带来的问题

让我们看一个典型的资源管理类,假设它管理一个位图(Bitmap):

class Bitmap {
public:
    Bitmap(size_t width, size_t height);
    ~Bitmap();
    // ...
private:
    size_t width_, height_;
    int* pixels_;
};

class Widget {
public:
    Widget& operator=(const Widget& rhs) {
        delete pb_;              // 删除当前持有的 Bitmap
        pb_ = new Bitmap(*rhs.pb_); // 复制 rhs 的 Bitmap
        return *this;
    }
private:
    Bitmap* pb_;
};

问题分析

当发生自我赋值 w = w 时:

步骤 操作 结果
1 delete pb_ 删除了 w 自己的 Bitmap
2 new Bitmap(*rhs.pb_) rhs.pb_ 指向的内存已被释放!悬空指针访问!

最终结果是:w 持有一个指向已删除对象的指针,程序行为未定义(UB)。

三、解决方案

方案一:证同测试(Identity Test)

最直观的方法是在赋值前检查是否是同一个对象:

Widget& Widget::operator=(const Widget& rhs) {
    if (this == &rhs) {   // 证同测试
        return *this;     // 如果是自我赋值,直接返回
    }
    
    delete pb_;
    pb_ = new Bitmap(*rhs.pb_);
    return *this;
}

优点:

  • 简单直观,容易理解
  • 能正确处理自我赋值

缺点:

  • 仅在自我赋值时有效,异常安全性不足
  • 如果 new Bitmap 抛出异常,pb_ 将指向已删除的内存

方案二:精心安排语句顺序(保证异常安全)

更好的做法是先复制新资源,再释放旧资源:

Widget& Widget::operator=(const Widget& rhs) {
    Bitmap* pOrig = pb_;           // 记住原来的 pb_
    pb_ = new Bitmap(*rhs.pb_);    // 先复制 rhs 的 Bitmap
    delete pOrig;                  // 再删除原来的 Bitmap
    return *this;
}

分析:

场景 行为
正常赋值 先复制新资源,成功后释放旧资源
自我赋值 *rhs.pb_ 就是 *pb_,但在 delete 前已完成复制,安全
new 异常 pb_ 仍指向原来的资源,对象状态保持一致(异常安全)

这个版本同时解决了自我赋值和异常安全问题,是工业级代码的常用写法。

方案三:copy-and-swap 惯用法(现代 C++ 推荐)

最优雅、最现代的解决方案:

class Widget {
public:
    Widget& operator=(const Widget& rhs) {
        Widget temp(rhs);    // 复制构造临时对象
        swap(temp);          // 与临时对象交换资源
        return *this;        // 临时对象析构,自动释放旧资源
    }
    
    void swap(Widget& other) noexcept {
        using std::swap;
        swap(pb_, other.pb_);
    }
private:
    Bitmap* pb_;
};

// C++11 更简洁的版本
Widget& Widget::operator=(Widget rhs) {  // 按值传递,已复制
    swap(rhs);                            // 与副本交换
    return *this;                         // 副本析构释放旧资源
}

copy-and-swap 的优势:

  1. 异常安全:复制在函数外部完成,若复制失败原对象不受影响
  2. 自我赋值安全:交换操作天然安全
  3. 代码复用:复用了拷贝构造函数和析构函数的逻辑
  4. 强异常安全保证:要么完全成功,要么保持原状态不变

四、三种方案对比

方案 自我赋值安全 异常安全 代码复杂度 推荐程度
证同测试 否(基本保证) 不推荐单独使用
精心安排顺序 是(强保证) 推荐
copy-and-swap 是(强保证) 低(复用代码) 强烈推荐

五、实际应用场景

场景1:字符串类实现

class MyString {
public:
    MyString& operator=(const MyString& rhs) {
        // copy-and-swap 惯用法
        MyString temp(rhs);
        swap(temp);
        return *this;
    }
    
    MyString& operator=(const char* str) {
        MyString temp(str);
        swap(temp);
        return *this;
    }

private:
    char* data_;
    size_t len_;
    
    void swap(MyString& other) noexcept {
        using std::swap;
        swap(data_, other.data_);
        swap(len_, other.len_);
    }
};

场景2:管理文件句柄的类

class FileHandle {
public:
    FileHandle(const char* filename) 
        : fd_(fopen(filename, "r")) {
        if (!fd_) throw std::runtime_error("Failed to open file");
    }
    
    FileHandle& operator=(const FileHandle& rhs) {
        FileHandle temp(rhs);  // 复制构造打开新文件
        swap(temp);            // 交换文件描述符
        return *this;
    }
    
    ~FileHandle() { 
        if (fd_) fclose(fd_); 
    }

private:
    FILE* fd_;
    
    void swap(FileHandle& other) noexcept {
        using std::swap;
        swap(fd_, other.fd_);
    }
};

场景3:现代 C++ 使用智能指针

在现代 C++ 中,更推荐直接使用智能指针来避免手动管理:

#include <memory>

class ModernWidget {
public:
    ModernWidget& operator=(const ModernWidget& rhs) {
        // std::shared_ptr 自动处理引用计数
        // std::unique_ptr 需要显式处理
        pb_ = std::make_unique<Bitmap>(*rhs.pb_);
        return *this;
    }
    
    ModernWidget& operator=(ModernWidget&& rhs) noexcept = default;

private:
    std::unique_ptr<Bitmap> pb_;
};

六、更多注意事项

1. 确保任何函数操作多个对象时,即使它们是同一对象也能正确行为

// 不仅是 operator=,其他函数也要考虑
void Widget::process(const Widget& other) {
    // 如果 other 就是 *this,下面的代码安全吗?
    delete pb_;
    pb_ = new Bitmap(*other.pb_);  // 危险!
}

2. 赋值运算符的返回值

始终返回 *this 的引用,以支持链式赋值:

x = y = z = 42;  // 链式赋值需要 operator= 返回引用

3. 现代 C++ 的移动赋值

C++11 起还应实现移动赋值运算符:

Widget& Widget::operator=(Widget&& rhs) noexcept {
    if (this != &rhs) {  // 移动时证同测试仍有必要
        delete pb_;
        pb_ = rhs.pb_;
        rhs.pb_ = nullptr;
    }
    return *this;
}

七、总结

请记住:

  • 确保当对象自我赋值时 operator= 有良好行为
  • 技术包括:证同测试、精心周到的语句顺序、以及 copy-and-swap
  • 确定任何函数如果操作一个以上的对象,而其中多个对象是同一对象时,其行为仍然正确

自我赋值看似罕见,但在通过指针/引用操作的代码中屡见不鲜。处理自我赋值不仅是正确性问题,更是异常安全性的重要一环。copy-and-swap 惯用法是现代 C++ 中处理赋值操作的最佳实践,它简洁、安全、可维护,值得在每个自定义赋值运算符中优先考虑。


参考阅读:

  • 《Effective C++》第3版,Scott Meyers
  • 《C++ Primer》关于异常安全和资源管理的章节
  • C++ Core Guidelines: ES.65, C.86

更多推荐