---
title: "C++中的移动语义"
date: 2026-09-07
category: "C++"
tags: ["C++", "类", "STL"]
summary: "c++中的移动语义和std::move"
series:
  name: "C++特性及用法"
  order: 0
---

## 引言

在上一篇RAII的文章里，我给资源类补上了拷贝构造和copy and swap的赋值，每次复制都要重新new一片内存，再把内容原样抄一遍。可是有些对象明明马上就要销毁了，比如函数返回的临时对象，把它的资源原样偷过来岂不是更省事。

C++11的移动语义就是干这个的。

在此篇文章中，我将记录下自己对移动语义的理解，以供思考，如有不足之处欢迎指正。

## 正文

### 左值与右值

移动语义的入口，是左值与右值的区分。

简单来说，左值是有名字、能取地址的对象，右值是临时的、即将消亡的值。

```cpp
int a = 10;      // a是左值，有名字，能对它取地址
int b = a + 1;   // a+1是右值，运算产生的临时值，本行结束即销毁

Book book1{1, "first book"};  // book1是左值
createBook();                 // 函数按值返回的临时对象是右值
```

C++11为此引入了右值引用`T&&`，专门用来绑定右值。参数写成右值引用，等于调用者向被调用方承诺：这个对象我不要了，你可以随意掏空它。

> 严格的值类别划分是lvalue、xvalue、prvalue三类，右值是后两类的统称。日常记不住也没关系，"能取地址的是左值，临时的是右值"足够应付绝大多数场景。

### 拷贝构造与移动构造

Book类里两个构造函数并排放着，区别一目了然：

```cpp
Book(const Book &s)          // 拷贝构造：const引用，不许动源对象
    : m_id(s.m_id)
{
    copyName(s.m_name);      // 重新new一片内存，把字符串抄一遍
}

Book(Book &&s) noexcept      // 移动构造：右值引用，可以掏空源对象
    : m_id(s.m_id), m_name(s.m_name)  // 直接把指针偷过来
{
    s.m_name = nullptr;      // 源对象指针置空，防止析构时二次释放
}
```

***移动不是复制，移动是把资源的所有权从源对象转让给新对象，全部代价只是几次指针赋值。***

最容易被漏掉的是`s.m_name = nullptr;`这一行。移动之后源对象并不会凭空消失，它还要走自己的析构函数，如果指针仍指向被偷走的内存，析构时就会把同一块内存delete两次。而`delete nullptr`是安全的空操作，所以置空即可。

> 移动后的对象处于"有效但未指定"的状态：可以给它赋新值，也可以安全析构，但不要再假设它保有原来的内容。标准库容器里被移动过的string、vector都是这个待遇。

### std::move与std::move_if_noexcept

移动构造只接受右值，那想移动一个左值怎么办？使用std::move：

```cpp
Book temp{std::move(first)};  // 把first强转成右值引用，随后选中移动构造
```

**std::move本身不移动任何东西，它只是一次到右值引用的强制转换**，真正的移动发生在随后被选中的移动构造或移动赋值里。这个名字起得相当有误导性，叫std::rvalue_cast或许更名副其实。

不过测试代码里三处用的都是std::move_if_noexcept，它是std::move的审慎版本：

```cpp
Book temp{std::move_if_noexcept(first)};  // 移动构造是noexcept，才真正走移动
first  = std::move_if_noexcept(second);
second = std::move_if_noexcept(temp);
```

***只有当移动构造被标记为noexcept时，move_if_noexcept才返回右值引用，否则退化为const引用，老老实实走拷贝。***
它把"移动是否安全"的判断交还给了类型系统。

### noexcept与异常安全

这就引出一个问题：为什么移动构造非要标noexcept不可？

拷贝构造只读源对象，new失败抛出异常，源对象毫发无损，一切可以当作没发生过。移动构造不同，它先掏空源对象再干活，一旦中途抛异常，新对象没构造出来，源对象的数据也残缺了，两头都保不住。

所以对移动构造、移动赋值、swap这类函数，要么保证内部不抛异常并标上noexcept，要么就别标，让move_if_noexcept和调用方自己去决定。

```cpp
Book(Book &&s) noexcept;                              // 移动构造，承诺不抛异常
Book &operator=(Book &&s) noexcept;                   // 移动赋值同理
void swap_move(Book &first, Book &second) noexcept;   // 交换函数也尽量noexcept
```

> 一个现实的后果：vector扩容搬移元素时，靠的正是move_if_noexcept这套判断。移动构造不是noexcept，它宁可逐个拷贝也不肯移动，因为拷贝失败还能回滚，移动失败就是数据丢失。noexcept不是免费的注释，它是一份写给编译器和标准库的承诺。

### 复制省略与移动交换

main里还有一个测试：

```cpp
Book book2{createBook()};  // 复制省略：直接在book2的内存上构造
Book book3;
book3 = createBook();      // 临时对象移动赋值给book3
```

`Book book2{createBook()}`按老标准想，返回值要先构造临时对象，再移动构造进book2。但实际输出里只有一次constructed，连移动构造都没调用。这就是复制省略（copy elision），C++17起按值返回纯右值是保证省略的，临时对象压根不存在，直接在book2的位置上构造。

而`book3 = createBook()`里book3已经存在，无法省略，于是临时对象把资源移动赋值给book3，自己随即析构。

两种swap的对比，是移动语义收益最直观的地方：

```cpp
void swap(Book &first, Book &second) noexcept  // 复制版交换
{
    const Book temp{first};   // 拷贝构造：new一次
    first = second;           // 拷贝赋值：delete一次，new一次
    second = temp;            // 拷贝赋值：delete一次，new一次
}

void swap_move(Book &first, Book &second) noexcept  // 移动版交换
{
    Book temp{std::move_if_noexcept(first)};  // 偷指针
    first = std::move_if_noexcept(second);    // 偷指针
    second = std::move_if_noexcept(temp);     // 偷指针
}
```

复制版三次深拷贝，三次new三次delete；移动版只是三次指针转让，一次堆都不碰。跑起来的输出里，前者刷出一串Copied name，后者一行都没有。

### 补充要点

写齐了移动构造和移动赋值，Book这个类就凑够了三/五法则（rule of five）：析构函数、拷贝构造、拷贝赋值、移动构造、移动赋值，五者要么都不写交给编译器生成，要么就得一起考虑。类里手动管理了资源，只写拷贝不写移动，性能白白损失；只写移动不写析构，就是内存泄漏。

移动赋值里的自赋值检查也值得留意：

```cpp
Book &operator=(Book &&s) noexcept
{
    if (this == &s) return *this;  // 防止book1 = std::move(book1)把自己掏空
    m_id = s.m_id;
    m_name = s.m_name;             // 偷指针
    s.m_name = nullptr;            // 置空源指针
    return *this;
}
```

没有这行检查的话，先偷指针再置空源指针，最后this和s的指针都变成nullptr，资源直接丢了。

> 有意思的是，上一篇RAII文章里copy and swap的赋值天然自赋值安全，并不需要这个检查。写法各有取舍，但每种写法的风险点必须心里有数。

另外，与std::move相对的还有std::forward，它用在模板转发时保持实参原本的左右值属性，属于完美转发的话题，等以后写模板相关的内容再展开。

## 小结

移动语义的本质，是把"即将销毁的对象"和"还要继续使用的对象"区分对待：后者复制保平安，前者掏空捡便宜。

拷贝构造保证两个对象各自独立，移动构造只保证接管方拿到资源，用一份"源对象不再可信"的代价，换来了零堆操作的搬运。写完这个Book类再回头看，std::swap、vector扩容、unique_ptr的所有权转移，底层全是同一套"偷指针、置空源"的把戏。

本文所用的测试代码收录于[Learncpp](https://github.com/ManJin03/Learncpp)，如有不妥之处欢迎指正。
