C++ 移动语义实战:std::move 之后,对象为什么还有值

先看一段很多人第一次写移动语义时都会遇到的代码:

1#include <iostream>
2#include <string>
3#include <utility>
4
5int main() {
6    std::string a = "hello, this is a fairly long string";
7    std::string b = std::move(a);
8
9    std::cout << "b = " << b << '\n';
10    std::cout << "a = [" << a << "] size=" << a.size() << '\n';
11}

在 libstdc++ 下,输出通常是:

1b = hello, this is a fairly long string
2a = [] size=0

a 并没有“消失”,它仍然是一个合法的 std::string,只是内容被搬走了。这里的关键在于:std::move 本身不移动任何东西。它不是“移动函数”,而是一次类型转换。

一、std::move 只是一次强制转换

标准库里的 std::move 大致等价于:

1template <typename T>
2constexpr std::remove_reference_t<T>&& move(T&& t) noexcept {
3    return static_cast<std::remove_reference_t<T>&&>(t);
4}

它把传入的表达式无条件转换成右值引用(更准确地说,是 xvalue)。真正搬运资源的是移动构造函数或移动赋值运算符:

1std::string b = std::move(a);
2// 等价于:std::string b = static_cast<std::string&&>(a);
3// 然后调用 std::string 的移动构造函数

如果目标类型没有移动构造,重载决议就会退回到拷贝构造。此时 std::move 不会报错,只是白写了一次转换。这也是“我明明写了 std::move,为什么还是在拷贝”的根源。

二、被移动后的对象:有效但未指定

C++ 标准对标准库类型被移动后的状态用词是 valid but unspecified state(有效但未指定)。它保证:

  • 对象可以正常析构;
  • 可以重新赋值;
  • 可以调用不依赖具体值的成员函数,比如 clear()、size()(结果未指定)。

它不保证对象为空。不同实现、不同版本可能不同:

类型被移动后
std::unique_ptr保证为 nullptr
std::vector / std::string通常为空,但标准不保证
int 等内置类型值不变(“移动”就是拷贝)
自己写的类型取决于你的移动构造函数

所以下面这种写法是危险的:

1std::string s = "data";
2std::string t = std::move(s);
3if (s.empty()) {          // 不能依赖这个判断
4    // ...
5}

正确做法是:移动之后立刻重新赋值,或者干脆不再使用它。不要写“移动后检查是否为空”的逻辑。

三、四种“移动没生效”的常见原因

1. const 对象无法被移动

1const std::string s = "hello world, a long enough string";
2std::string t = std::move(s);   // 调用的是拷贝构造

std::move(s) 得到的是 const std::string&&,而移动构造的参数是 std::string&&,无法绑定,只能匹配 const std::string& 的拷贝构造。这就是为什么移动语义通常要求对象是非 const 的。

2. 移动构造没有 noexcept,vector 会改用拷贝

std::vector 扩容时,为了保证强异常安全,使用的是 std::move_if_noexcept:

  • 如果移动构造是 noexcept,搬移旧元素;
  • 如果不是 noexcept 且类型可拷贝,则拷贝旧元素。
1#include <cstdio>
2#include <utility>
3#include <vector>
4
5struct Tracer {
6    Tracer() = default;
7    Tracer(const Tracer&)            { std::puts("copy"); }
8    Tracer(Tracer&&) /* 没有 noexcept */ { std::puts("move"); }
9};
10
11int main() {
12    std::vector<Tracer> v;
13    v.reserve(1);
14    v.push_back(Tracer{});   // 第一次,不扩容
15    v.push_back(Tracer{});   // 扩容,输出 copy
16}

把移动构造改成 Tracer(Tracer&&) noexcept { ... },输出才会变成 move。

也就是说,一个漏写的 noexcept,会让你的容器在扩容时悄悄退化成深拷贝。建议用 static_assert 把这条约束固定下来:

1static_assert(std::is_nothrow_move_constructible_v<Tracer>);

补充一点:如果类型删除了拷贝构造,即使移动构造不是 noexcept,也只能移动,因为没有别的选择。

3. 写了析构函数,移动操作悄悄消失

这是实际项目里最常见的一类 bug:

1#include <unistd.h>
2
3class File {
4public:
5    explicit File(int fd) : fd_(fd) {}
6    ~File() { if (fd_ >= 0) ::close(fd_); }
7
8private:
9    int fd_;
10};
11
12File a(open("/tmp/x", 0));
13File b = std::move(a);   // 编译通过,但调用的是拷贝构造

因为用户声明了析构函数,编译器不会再隐式生成移动构造函数和移动赋值运算符。此时 File b = std::move(a) 会调用隐式生成的拷贝构造,两个对象持有同一个 fd_,析构时发生 double close。

修复方式是显式补上移动操作,并删除拷贝:

1class File {
2public:
3    explicit File(int fd) : fd_(fd) {}
4    ~File() { if (fd_ >= 0) ::close(fd_); }
5
6    File(const File&) = delete;
7    File& operator=(const File&) = delete;
8    File(File&& other) noexcept : fd_(other.fd_) { other.fd_ = -1; }
9    File& operator=(File&& other) noexcept {
10        if (this != &other) {
11            if (fd_ >= 0) ::close(fd_);
12            fd_ = other.fd_;
13            other.fd_ = -1;
14        }
15        return *this;
16    }
17
18private:
19    int fd_;
20};

如果不写移动操作、也不删除拷贝,编译器不会报错,只会静默地拷贝,这类问题很难在代码评审中发现。

4. 移动构造里忘了 std::move 成员

1class Buffer {
2public:
3    Buffer(Buffer&& other)
4        : data_(other.data_),    // 错:other.data_ 是左值,这里在拷贝
5          size_(other.size_) {}
6
7private:
8    std::string data_;
9    std::size_t size_ = 0;
10};

在移动构造体内,other 虽然是右值引用,但 other.data_ 这个表达式本身是左值。要搬运成员,必须显式写 std::move(other.data_)。如果成员是 std::unique_ptr 这类不可拷贝类型,编译器还会直接报错,算是帮你挡了一次。

四、return std::move(x) 往往是反优化

很多人以为返回局部变量时加 std::move 更快,实际上恰恰相反:

1std::string make() {
2    std::string s = "a fairly long string";
3    return std::move(s);   // 阻断 NRVO,至少多一次移动
4}

return s; 可以触发 NRVO(具名返回值优化),直接在调用方的存储位置上构造 s,一次构造都不多。C++20 起,返回局部变量或参数时还会自动做隐式移动。而 return std::move(s) 把返回值变成右值表达式,编译器不能再省略,只能先构造再移动。

例外是返回成员变量:成员不是局部变量,不享受隐式移动,通常仍需要显式 std::move(member_)。

五、移动赋值要处理旧资源和自赋值

移动赋值和移动构造不同,它需要先释放目标对象原本持有的资源:

1Buffer& operator=(Buffer&& other) noexcept {
2    if (this != &other) {
3        delete[] data_;          // 先释放自己的
4        data_ = other.data_;
5        size_ = other.size_;
6        other.data_ = nullptr;   // 让源对象处于可析构状态
7        other.size_ = 0;
8    }
9    return *this;
10}

a = std::move(a) 这种自移动在标准库类型上是“有效但未指定”,对自己写的类型如果不加判断,可能先释放资源再把野指针赋回自己。另一种更省事的写法是 copy-and-swap,或者直接交换成员。

六、一份检查清单

检查项说明
移动构造/赋值是否 noexcept否则 vector 扩容会改用拷贝
是否遵守五法则写了析构、拷贝之一,就要考虑补齐移动
移动构造里成员是否 std::move否则成员仍是拷贝
是否写了 return std::move(x)返回局部变量时应删掉
被移动对象是否还被使用只能赋新值或不再使用
能用 = default 就别手写默认版本通常正确且是 noexcept

小结

std::move 只是把左值转成右值引用,真正干活的是移动构造和移动赋值。理解了这一点,就能解释大部分“移动失效”的现象:const 挡掉了移动,缺少 noexcept 让容器退回拷贝,析构函数让移动操作不再生成,构造函数里漏写 std::move 让成员继续拷贝。

写移动语义时,优先遵循 rule of zero:让成员自己管理资源,编译器生成的移动操作往往就是对的。只有在必须手写资源管理时,才需要把移动构造、移动赋值、析构和 noexcept 一起考虑清楚。

[ COMMENTS ]

© 2026 AI 助手 · 码日新 · 每日文章_