Skip to content

移动构造与移动赋值 ​

上一篇结尾说好了,这一篇咱们亲手给管理资源的类写移动构造和移动赋值。笔者头一回亲手写它们的时候,错得可不少:源对象的指针忘了置空,自赋值的检查也漏了,noexcept 该不该加也拿不准……把当时犯过的错一并摊开讲给您,帮您写的时候一次避开。

咱们从一个简单但足够真实的场景入手:自己动手实现一个动态缓冲区类,再一步步把移动构造、移动赋值的机制弄懂。规则五和 copy-and-swap 的配套写法,留给下一篇专门讲。

为什么需要移动——从拷贝的代价说起 ​

假设您在写一个文本处理工具,需要频繁地在函数之间传递大块文本数据。咱们从最朴素的动态缓冲区实现看起:

展开代码收起代码共 51 行
C++
class Buffer {
    char* data_;
    std::size_t size_;
    std::size_t capacity_;

public:
    explicit Buffer(std::size_t capacity)
        : data_(new char[capacity])
        , size_(0)
        , capacity_(capacity)
    {
    }

    // 拷贝构造:深拷贝
    Buffer(const Buffer& other)
        : data_(new char[other.capacity_])
        , size_(other.size_)
        , capacity_(other.capacity_)
    {
        std::memcpy(data_, other.data_, size_); // 直接平凡的拷贝数据
    }

    // 拷贝赋值:深拷贝
    Buffer& operator=(const Buffer& other)
    {
        if (this != &other) {
            delete[] data_;
            data_ = new char[other.capacity_];
            size_ = other.size_;
            capacity_ = other.capacity_;
            std::memcpy(data_, other.data_, size_);
        }
        return *this;
    }

    ~Buffer()
    {
        delete[] data_;
    }

    void append(const char* str, std::size_t len)
    {
        if (size_ + len <= capacity_) {
            std::memcpy(data_ + size_, str, len);
            size_ += len;
        }
    }

    const char* data() const { return data_; }
    std::size_t size() const { return size_; }
};

现在咱们来做一个实验:创建一个 1MB 的缓冲区,然后您把它传进一个函数。

C++
#include <iostream>

Buffer process_buffer(Buffer buf)
{
    std::cout << "处理中,大小: " << buf.size() << " 字节\n";
    return buf;
}

int main()
{
    Buffer large(1024 * 1024);  // 1MB
    large.append("Hello, World!", 13);

    Buffer result = process_buffer(large);  // 拷贝!
    return 0;
}

咱们调用 process_buffer(large) 的时候,到底发生了什么?参数 buf 是按值传递的,编译器创建它靠的就是 Buffer 的拷贝构造函数,代价也就跟着来了:分配 1MB 新内存,再把 large 里的数据逐字节拷贝过去。函数返回的时候,return buf; 又触发了一次拷贝构造,这才有了 result。等到函数收尾的时候,buf 也跟着析构了一次。咱们把整个过程算下来,做了两次 1MB 的内存分配,两次 1MB 的内存拷贝,外加一次 1MB 的内存释放。而咱们真正需要的,只是把数据从 main 里的 large 转移到 result 里。

笔者估计,写惯了老 C++ 的朋友看到这样写已经满面红光了。相信屏幕前的您,也会一样绷不住的。

拷贝语义的问题,到这里就露出来了:您明明不再需要源对象了,拷贝构造函数还是忠实地复制每一个字节。等源对象析构的时候,它又老老实实地把那块内存释放掉。资源分配了又释放,数据拷贝了又丢弃——纯粹的浪费。

移动构造函数——资源所有权的转移 ​

轮到移动构造函数出场了。它要做的事情,一句话就能说清:一个字节的数据都不复制,只把资源的所有权转过去 [1] cppreference Move constructor — transfer instead of copy。落到管理动态内存的类上,动作就是把源对象的指针"偷"过来,再把源对象置空了事。咱们直接看代码:

C++
class Buffer {
    char* data_;
    std::size_t size_;
    std::size_t capacity_;

public:
    // ... 前面的构造函数和析构函数不变 ...

    // 移动构造函数
    Buffer(Buffer&& other) noexcept
        : data_(other.data_)
        , size_(other.size_)
        , capacity_(other.capacity_)
    {
        other.data_ = nullptr;
        other.size_ = 0;
        other.capacity_ = 0;
    }
};

咱们逐行来看这个移动构造函数。签名 Buffer(Buffer&& other) 里的 && 表明它只接受右值参数。函数体里干的事情不多:把 other 的三个成员直接搬过来,也就是三个指针/整数的赋值,代价低到咱们几乎可以忽略。剩下的一步,是把 other 的成员清零。置空的这一步,需要咱们多看一眼。假如咱们不把 other.data_ 置空,other 析构的时候,delete[] other.data_ 会把刚转过来的那块内存释放掉。这下 this 手里捏着的,就成了悬空指针,再去访问它的时候,等来的就是崩溃。

现在咱们用 std::move 来触发移动构造:

C++
Buffer large(1024 * 1024);
large.append("Hello, World!", 13);

Buffer moved_to = std::move(large);  // 调用移动构造函数
// large.data_ 现在是 nullptr,但 large 仍然可以安全析构
// moved_to 持有了原来那 1MB 的内存

这一趟下来发生了什么?咱们数一数:把 other 的成员搬过来、置空,几个指针/整数的赋值就完事。没有 new 的分配,没有 memcpy 的复制,也没有 delete 的释放。O(n) 的拷贝,就这么变成了 O(1) 的指针转移。对 1MB 的缓冲区来说,一边要分配 1MB 的内存、再拷贝 1MB 的数据,另一边咱们只搬几个寄存器,您说差距大不大。

移动赋值运算符——比移动构造多一步 ​

移动赋值运算符比移动构造函数多了一步麻烦。构造的时候,目标对象的初始化还没发生,谈不上持有什么旧资源。赋值的时候,目标对象已经在了,手里可能还攥着一份现成的资源。所以咱们得把旧的放掉,才好去接管新的 [2] cppreference Move assignment operator — release current resource, take over the source。

展开代码收起代码共 23 行
C++
class Buffer {
    // ... 前面的代码不变 ...

    // 移动赋值运算符
    Buffer& operator=(Buffer&& other) noexcept
    {
        if (this != &other) {
            // 第一步:释放当前持有的资源
            delete[] data_;

            // 第二步:接管 other 的资源
            data_ = other.data_;
            size_ = other.size_;
            capacity_ = other.capacity_;

            // 第三步:置空 other
            other.data_ = nullptr;
            other.size_ = 0;
            other.capacity_ = 0;
        }
        return *this;
    }
};

咱们把镜头对准函数体开头的 delete[] data_。这一行放掉的,就是上一段里目标对象正攥着的现成资源。咱们要是不放掉它,内存就直接泄漏了。if (this != &other) 的自赋值检查,咱们也得提一嘴。x = std::move(x) 这样的代码,正常开发的时候几乎不会有人写。可真写出来了,坏起事来不含糊:delete[] data_ 把自己的资源放掉了,接下来又从已经悬空的 other(其实就是它自己)里取指针,这就直接构成了 UAF(use-after-free,释放后的使用)。咱们多加一道检查,用几行代码换一个确定的结局,是值得的。

咱们来看移动赋值在实际代码里的效果:

C++
Buffer a(1024);
a.append("Hello", 5);

Buffer b(2048);
b.append("World", 5);

a = std::move(b);  // 移动赋值
// a 原来的 1KB 缓冲区被 delete[] 释放
// a 接管了 b 的 2KB 缓冲区
// b.data_ 变为 nullptr

被移动过的那个源对象,它的状态成了"有效但未指定",英文的说法是 valid but unspecified [3] cppreference std::move — Notes: moved-from standard-library objects are valid but unspecified。您可以让它安全地析构,也可以接着给它赋新的值,但别去读它的值——被移动过的标准库类型,size() 这样的调用可能给您 0,也可能给您原来的值,全看库的具体实现。笔者的建议是:移动之后,要么让源对象马上离开作用域,要么给它赋一个明确的新值。在它拿到明确的新值之前,您别再去读它。

noexcept——移动操作的安全承诺 ​

您可能已经注意到,两个移动操作的身上都挂着 noexcept。它不是可有可无的装饰,背后连着实实在在的性能。

原因得从 std::vector 的扩容行为里找。容量不够用了,vector 得把现有元素转移到新的内存块。走到这一步的时候,它就要掂量元素的移动构造函数了:标了 noexcept 的,vector 放心地用移动。要是移动构造可能抛异常呢,vector 就退回去改用拷贝构造了 [4] cppreference std::move_if_noexcept — how vector reallocation picks move vs copy。道理咱们想一下就通——移动挪到一半抛了异常,已经搬走一半的状态很难恢复。拷贝途中抛异常就不一样了,原来的数据还完好无损。

C++
// vector 内部逻辑的简化版本
if constexpr (std::is_nothrow_move_constructible_v<T>) {
    // 使用移动构造——快速且安全
} else {
    // 退化为拷贝构造——慢但异常安全
}

您可以用 static_assert 来验证自己的类是否真的满足 noexcept 移动:

C++
static_assert(std::is_nothrow_move_constructible_v<Buffer>,
              "Buffer should be nothrow move constructible");
static_assert(std::is_nothrow_move_assignable_v<Buffer>,
              "Buffer should be nothrow move assignable");

光讲道理就是纸上谈兵了,咱们真跑一个实验,看看 vector 实际怎么选。准备两个结构一模一样的类,唯一的差别是移动构造函数带不带 noexcept,然后咱们让 vector 扩容。最省事的做法,是用一个模板参数 NoexceptMove 切换 noexcept 的标记,其余的代码完全相同:

展开代码收起代码共 80 行
C++
// noexcept_vector_realloc.cpp -- noexcept 移动 vs 非 noexcept 移动 在 vector 扩容时的差异
// Standard: C++17

#include <iostream>
#include <string>
#include <type_traits>
#include <utility>
#include <vector>

// 用模板参数 NoexceptMove 切换移动构造是否标 noexcept,其余代码完全相同
template <bool NoexceptMove>
class TrackedBuffer
{
    char* data_;
    std::size_t capacity_;
    std::string tag_;

public:
    explicit TrackedBuffer(std::size_t cap, std::string tag)
        : data_(new char[cap])
        , capacity_(cap)
        , tag_(std::move(tag))
    {
    }

    ~TrackedBuffer() { delete[] data_; }

    TrackedBuffer(const TrackedBuffer& other)
        : data_(new char[other.capacity_])
        , capacity_(other.capacity_)
        , tag_(other.tag_)
    {
        std::cout << "  [" << tag_ << "] 拷贝构造\n";
    }

    // 唯一的区别:noexcept 标记
    TrackedBuffer(TrackedBuffer&& other) noexcept(NoexceptMove)
        : data_(other.data_)
        , capacity_(other.capacity_)
        , tag_(std::move(other.tag_))
    {
        other.data_ = nullptr;
        other.capacity_ = 0;
        std::cout << "  [" << tag_ << "] 移动构造\n";
    }

    TrackedBuffer& operator=(const TrackedBuffer&) = delete;
    TrackedBuffer& operator=(TrackedBuffer&&) = delete;
};

int main()
{
    using NB = TrackedBuffer<true>;   // 移动构造标了 noexcept
    using TB = TrackedBuffer<false>;  // 移动构造没标 noexcept

    static_assert(std::is_nothrow_move_constructible_v<NB>,
                  "NB 的移动构造是 noexcept");
    static_assert(!std::is_nothrow_move_constructible_v<TB>,
                  "TB 的移动构造不是 noexcept");

    std::cout << "=== noexcept 移动 + vector 扩容 ===\n";
    {
        std::vector<NB> v;
        v.reserve(1);                       // 先预留 1 个槽位
        v.emplace_back(64, "Noexcept版");   // 占住唯一的槽位
        std::cout << "--- 触发扩容 ---\n";
        v.emplace_back(64, "Noexcept版");   // 超出容量,必须扩容搬运
    }

    std::cout << "\n=== 非 noexcept 移动 + vector 扩容 ===\n";
    {
        std::vector<TB> v;
        v.reserve(1);
        v.emplace_back(64, "Throwing版");
        std::cout << "--- 触发扩容 ---\n";
        v.emplace_back(64, "Throwing版");   // 扩容时 vector 不敢用移动,退回拷贝
    }

    return 0;
}

实验程序就挂在下面的演示里,您点「动手试一试」直接跑:

Compiler Explorer

动手验证:noexcept_vector_realloc.cpp

在线验证 vector 扩容时的选择,跑起来对照两段输出各自触发的是哪种构造。

code/examples/vol2/noexcept_vector_realloc.cpp

两段输出各自只打印了一行,咱们对着看。为什么各只有一行?类里会打印的只有拷贝构造和移动构造,两次 emplace_back 的原地构造都不吭声,真正打印出来的,是扩容搬运的那一步。挂 [Noexcept版] 标签的行写着移动构造,挂 [Throwing版] 标签的行写着拷贝构造。

动手实验——move_semantics_demo.cpp ​

咱们写一个完整的程序,把移动语义的关键行为都验证一遍。

展开代码收起代码共 129 行
C++
// move_semantics_demo.cpp -- 移动构造与移动赋值演示
// Standard: C++17

#include <cstring>
#include <iostream>
#include <string>
#include <utility>
#include <vector>

class Buffer
{
    char* data_;
    std::size_t size_;
    std::size_t capacity_;

public:
    explicit Buffer(std::size_t capacity)
        : data_(new char[capacity])
        , size_(0)
        , capacity_(capacity)
    {
        std::cout << "  [Buffer] 分配 " << capacity << " 字节\n";
    }

    ~Buffer()
    {
        if (data_) {
            std::cout << "  [Buffer] 释放 " << capacity_ << " 字节\n";
            delete[] data_;
        }
    }

    Buffer(const Buffer& other)
        : data_(new char[other.capacity_])
        , size_(other.size_)
        , capacity_(other.capacity_)
    {
        std::memcpy(data_, other.data_, size_);
        std::cout << "  [Buffer] 拷贝构造 " << capacity_ << " 字节\n";
    }

    Buffer(Buffer&& other) noexcept
        : data_(other.data_)
        , size_(other.size_)
        , capacity_(other.capacity_)
    {
        other.data_ = nullptr;
        other.size_ = 0;
        other.capacity_ = 0;
        std::cout << "  [Buffer] 移动构造(指针转移)\n";
    }

    Buffer& operator=(const Buffer& other)
    {
        if (this != &other) {
            delete[] data_;
            data_ = new char[other.capacity_];
            size_ = other.size_;
            capacity_ = other.capacity_;
            std::memcpy(data_, other.data_, size_);
            std::cout << "  [Buffer] 拷贝赋值 " << capacity_ << " 字节\n";
        }
        return *this;
    }

    Buffer& operator=(Buffer&& other) noexcept
    {
        if (this != &other) {
            delete[] data_;
            data_ = other.data_;
            size_ = other.size_;
            capacity_ = other.capacity_;
            other.data_ = nullptr;
            other.size_ = 0;
            other.capacity_ = 0;
            std::cout << "  [Buffer] 移动赋值(指针转移)\n";
        }
        return *this;
    }

    void append(const char* str, std::size_t len)
    {
        if (size_ + len <= capacity_) {
            std::memcpy(data_ + size_, str, len);
            size_ += len;
        }
    }

    std::size_t size() const { return size_; }
    std::size_t capacity() const { return capacity_; }
};

int main()
{
    std::cout << "=== 1. 创建两个缓冲区 ===\n";
    Buffer a(1024);
    a.append("Hello", 5);
    Buffer b(2048);
    b.append("World", 5);
    std::cout << '\n';

    std::cout << "=== 2. 拷贝构造 ===\n";
    Buffer c = a;
    std::cout << "  c.size() = " << c.size() << "\n\n";

    std::cout << "=== 3. 移动构造 ===\n";
    Buffer d = std::move(b);
    std::cout << "  d.size() = " << d.size() << "\n";
    std::cout << "  b.capacity() = " << b.capacity() << "\n\n";

    std::cout << "=== 4. 移动赋值 ===\n";
    a = std::move(d);
    std::cout << "  a.size() = " << a.size() << "\n";
    std::cout << "  d.capacity() = " << d.capacity() << "\n\n";

    std::cout << "=== 5. vector 中的移动 ===\n";
    std::vector<Buffer> buffers;
    buffers.reserve(4);
    std::cout << "  push_back 左值:\n";
    buffers.push_back(c);             // 拷贝
    std::cout << "  push_back std::move:\n";
    buffers.push_back(std::move(c));  // 移动
    std::cout << "  emplace_back 原位构造:\n";
    buffers.emplace_back(512);        // 直接在 vector 中构造
    std::cout << '\n';

    std::cout << "=== 6. 程序结束 ===\n";
    return 0;
}

下面的演示里就是完整代码,您点「动手试一试」把它跑起来,咱们还可以切到汇编视图,看移动构造的指针转移:

Compiler Explorer

动手实验:move_semantics_demo.cpp

在线运行并对比 Buffer 的拷贝构造 vs 移动构造,以及在 vector 中的行为差异。

code/examples/vol2/02_move_semantics.cpp

把第 2、3 步在内存里的动作做成了动画,您可以播放、暂停,也可以按步进键一步一步地看,把指针交接的那一步看个清楚:

0.0s / 53.5s
STEP 01开场

咱们把"移动构造(指针转移)"和"拷贝构造 X 字节"摆在一起,对比一目了然:拷贝要分配内存再复制数据,移动只是三个指针的赋值。第 5 步的 vector 操作还有看头。push_back 收到左值的时候,发生的是拷贝。传 std::move 包出来的右值,发生的是移动。emplace_back 更进一步、直接在 vector 的内存里原位构造,连移动都省了。数据量大了之后,三种写法的性能差距会非常明显。

咱们还会注意到,析构的时候没有"释放 0 字节"的输出。那些就是被移动过的对象,它们的 data_ 已经是 nullptr,析构函数里的 if (data_) 检查跳过了 delete[]。vector 里的三个元素各自独立析构:头一个是 c 的拷贝(1024 字节),第二个是 c 移动过来的(1024 字节),第三个是 emplace_back 原位构造的(512 字节)。

移动构造和移动赋值到这里就写齐了。可一个管理资源的类,光有移动还不够——析构函数、拷贝构造、拷贝赋值和移动操作怎么联动,漏掉一个会出什么事?下一篇咱们把“规则五”(Rule of Five)完整过一遍,再看两个配套的写法。

参考文献
1
cppreference.comMove Constructor
2
cppreference.comMove Assignment Operator
3
cppreference.comstd::moveNotes: moved-from state
4
cppreference.comstd::move_if_noexcept
5
Howard E. Hinnant, Peter Dimov, Dave AbrahamsA Proposal to Add Move Semantics Support to the C++ Language (N1377)WG21 / ISO C++ Committee, 2002

pdf-latest-4-g85128cc · 85128cc · 2026-10-05