一、从 Rule of Three 到 Rule of Five

我记得刚学 C++ 那会儿,老工程师跟我说:「你写类的时候,只要涉及动态资源,就得记住 Rule of Three。」那时候我还不太理解,直到自己踩了坑才明白——析构函数、拷贝构造函数、拷贝赋值运算符,这三兄弟缺一不可。

但 C++11 引入了移动语义之后,事情变了。现在我们有五件套:

  • 析构函数
  • 拷贝构造函数
  • 拷贝赋值运算符
  • 移动构造函数
  • 移动赋值运算符

这就是所谓的 Rule of Five(五法则)。说白了,只要你定义了析构函数(意味着你在管理资源),那你就应该把这五个函数都考虑清楚。

核心原则:要么一个都不定义(让编译器帮你生成),要么五个都定义清楚。别只定义其中两三个,那样容易出问题。

二、为自定义类实现移动操作

咱们直接看代码。假设我有一个 StringBuffer 类,管理一块动态内存:

class StringBuffer {
public:
    // 构造函数
    StringBuffer(size_t size = 0) 
        : size_(size), data_(size ? new char[size] : nullptr) {}

    // 析构函数
    ~StringBuffer() { delete[] data_; }

    // 拷贝构造函数
    StringBuffer(const StringBuffer& other) 
        : size_(other.size_), 
          data_(other.size_ ? new char[other.size_] : nullptr) {
        if (data_) {
            memcpy(data_, other.data_, size_);
        }
    }

    // 拷贝赋值运算符
    StringBuffer& operator=(const StringBuffer& other) {
        if (this != &other) {
            delete[] data_;
            size_ = other.size_;
            data_ = size_ ? new char[size_] : nullptr;
            if (data_) {
                memcpy(data_, other.data_, size_);
            }
        }
        return *this;
    }

    // 移动构造函数 —— 重点来了
    StringBuffer(StringBuffer&& other) noexcept 
        : size_(other.size_), data_(other.data_) {
        // 把源对象置为「空」状态
        other.size_ = 0;
        other.data_ = nullptr;
    }

    // 移动赋值运算符
    StringBuffer& operator=(StringBuffer&& other) noexcept {
        if (this != &other) {
            // 释放当前资源
            delete[] data_;
            // 窃取源对象的资源
            size_ = other.size_;
            data_ = other.data_;
            // 把源对象置为空
            other.size_ = 0;
            other.data_ = nullptr;
        }
        return *this;
    }

private:
    size_t size_;
    char* data_;
};

你注意看移动构造函数——它做的事情其实很简单:偷走别人的资源,然后让别人变成空壳。没有内存分配,没有深拷贝,就是指针的搬运工。我在项目中遇到过,一个包含大数组的类,从拷贝改成移动后,性能提升了将近 10 倍。

小技巧:移动操作记得加 noexcept。为什么?因为标准库容器(比如 std::vector)在重新分配内存时,如果移动操作是 noexcept 的,它会优先用移动而不是拷贝。否则,它只能老老实实走拷贝路径。

三、移动语义与 Pimpl 惯用法

Pimpl(Pointer to Implementation)是个老套路了——把实现细节藏到指针后面,减少编译依赖。但移动语义出现后,Pimpl 用起来更顺手了。

我以前写 Pimpl 类,最头疼的就是拷贝。每次拷贝都得 new 一个实现对象,然后深拷贝所有成员。有了移动语义,事情简单多了:

// widget.h
class Widget {
public:
    Widget();
    ~Widget();

    // 移动操作 —— 只需要移动指针
    Widget(Widget&& other) noexcept;
    Widget& operator=(Widget&& other) noexcept;

    // 拷贝操作 —— 需要深拷贝实现对象
    Widget(const Widget& other);
    Widget& operator=(const Widget& other);

    void doSomething();

private:
    struct Impl;
    std::unique_ptr<Impl> pImpl_;
};

// widget.cpp
struct Widget::Impl {
    std::string name;
    std::vector<int> data;
    // ... 其他复杂成员
};

Widget::Widget() : pImpl_(std::make_unique<Impl>()) {}

Widget::~Widget() = default;  // unique_ptr 自动处理

// 移动操作 —— 简单到令人发指
Widget::Widget(Widget&& other) noexcept 
    : pImpl_(std::move(other.pImpl_)) {}

Widget& Widget::operator=(Widget&& other) noexcept {
    pImpl_ = std::move(other.pImpl_);
    return *this;
}

// 拷贝操作 —— 需要自己实现深拷贝
Widget::Widget(const Widget& other) 
    : pImpl_(std::make_unique<Impl>(*other.pImpl_)) {}

Widget& Widget::operator=(const Widget& other) {
    *pImpl_ = *other.pImpl_;
    return *this;
}

你看,移动操作就是 std::move 一下指针的事。而拷贝操作,得老老实实 new 一个 Impl 出来。这就是移动语义给 Pimpl 带来的好处——移动几乎零成本,拷贝该多重还是多重

注意:std::unique_ptr 管理 Pimpl 指针时,析构函数不能在头文件里定义(因为 unique_ptr 的析构需要看到完整类型)。要么在 .cpp 里定义析构函数,要么用 ~Widget() = default; 声明在 .cpp 中。

四、知识体系总览

下面这张图帮你理清本章的核心脉络:

自定义类型的移动语义 — 知识体系 Rule of Five 移动构造函数 & 移动赋值运算符 资源窃取(零拷贝) 源对象置为空状态 标记 noexcept Pimpl 惯用法 + 移动 unique_ptr 管理实现 移动 = 指针转移 拷贝构造函数 & 赋值 深拷贝(有性能开销) 异常安全需注意

五、避坑指南

我这些年写移动语义,踩过不少坑。挑几个典型的说说:

  • 移动后对象还能用吗? 能,但只能做「安全」的操作——比如赋值、析构。别假设它还有有效数据。
  • 自移动赋值 —— 就是 a = std::move(a)。虽然很少见,但万一出现呢?所以移动赋值运算符里最好加个 if (this != &other) 判断。
  • 忘记 noexcept —— 我之前有个类没加 noexcept,结果 std::vector 扩容时硬是走了拷贝路径,性能直接崩了。排查了半天才发现是这个问题。
  • Pimpl 的析构位置 —— 前面说了,unique_ptr 析构需要完整类型。如果你在头文件里写 ~Widget() = default;,编译器会报错。得在 .cpp 里写。

一句话总结:Rule of Five 不是让你盲目写五个函数,而是让你想清楚——你的类到底需不需要自定义这些操作。如果需要,那就五个都考虑到位。移动操作的核心就是「偷资源、留空壳」,配合 Pimpl 用起来特别顺手。


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