右值引用和移动语义
本节解决什么问题
考虑这样的场景:
std::vector<int> create_large_vector()
{
std::vector<int> v(1000000);
// ... 填充数据 ...
return v; // C++98 中会拷贝整个 vector!(非常慢)
}
在 C++98 中,函数返回大的对象时会发生深拷贝,性能很差。C++11 引入了移动语义,把"拷贝别人的数据"变成"偷走别人的数据",大幅提高性能。
这个特性是什么
- 左值:有名字、可以取地址的对象。如变量
x、arr[0]。 - 右值:临时的、即将销毁的对象。如
42、x + y、std::move(x)。 - 右值引用
T&&:绑定到右值的引用。 - 移动语义:通过"偷"右值的资源而不是拷贝,来避免不必要的深拷贝。
std::move:将左值转为右值引用,告诉编译器"这个对象我以后不再用了,你可以偷它的资源"。
C++ 标准版本
C++11
需要的头文件
#include <utility> // for std::move, std::forward
基本语法
int&& rref = 42; // 右值引用绑定到临时值
std::string&& sr = s1 + s2; // 绑定到表达式结果
// std::move:把左值转为右值
std::vector<int> v1 = {1, 2, 3};
std::vector<int> v2 = std::move(v1); // v1 的数据被"偷"到 v2,v1 变成空
左值 vs 右值速查表
| 表达式 | 左值/右值 | 说明 |
|---|---|---|
int x = 5; | x 是左值 | 有名字,可取地址 |
42 | 右值 | 字面量 |
x + y | 右值 | 临时结果 |
f() 返回非引用 | 右值 | 临时对象 |
f() 返回引用 | 左值 | 引用是别名 |
std::move(x) | 右值(将亡值) | 强制转为右值引用 |
示例代码
示例 1:拷贝 vs 移动——为什么需要移动语义
#include <iostream>
#include <vector>
int main()
{
std::vector<int> v1 = {1, 2, 3, 4, 5};
// 拷贝(两份独立的数据)
std::vector<int> v2 = v1;
std::cout << "after copy:\n";
std::cout << " v1 size = " << v1.size() << "\n"; // 5
std::cout << " v2 size = " << v2.size() << "\n"; // 5
// 移动(v1 的数据被"偷"到 v3,v1 变成空)
std::vector<int> v3 = std::move(v1);
std::cout << "after move:\n";
std::cout << " v1 size = " << v1.size() << "\n"; // 0
std::cout << " v3 size = " << v3.size() << "\n"; // 5
return 0;
}
运行结果:
after copy:
v1 size = 5
v2 size = 5
after move:
v1 size = 0
v3 size = 5
示例 2:在示例 1 基础上,自定义类的移动构造函数
#include <algorithm> // for std::copy
#include <iostream>
#include <string>
#include <utility> // for std::move
class Buffer
{
int* data;
size_t size;
public:
// 构造函数
Buffer(size_t n) : data(new int[n]), size(n)
{
std::cout << "Constructor: allocated " << n << " ints\n";
}
// 拷贝构造函数(深拷贝)
Buffer(const Buffer& other) : data(new int[other.size]), size(other.size)
{
std::copy(other.data, other.data + size, data);
std::cout << "Copy constructor: deep copied " << size << " ints\n";
}
// 移动构造函数(偷数据)
Buffer(Buffer&& other) noexcept
: data(other.data), size(other.size)
{
other.data = nullptr; // 让原对象安全析构
other.size = 0;
std::cout << "Move constructor: stole " << size << " ints\n";
}
~Buffer()
{
delete[] data;
std::cout << "Destructor\n";
}
size_t get_size() const { return size; }
};
int main()
{
Buffer buf1(1000);
// 拷贝:会触发深拷贝
Buffer buf2 = buf1;
std::cout << "buf1 size after copy: " << buf1.get_size() << "\n";
// 移动:数据被偷走,没有深拷贝!
Buffer buf3 = std::move(buf2);
std::cout << "buf2 size after move: " << buf2.get_size() << "\n";
std::cout << "buf3 size after move: " << buf3.get_size() << "\n";
return 0;
}
运行结果:
Constructor: allocated 1000 ints
Copy constructor: deep copied 1000 ints
buf1 size after copy: 1000
Move constructor: stole 1000 ints
buf2 size after move: 0
buf3 size after move: 1000
Destructor
Destructor
Destructor
Buffer 示例详解:std::copy、std::move 与对象初始化
示例 2 中最容易混淆的地方主要有三个:
std::copy到底复制了什么;std::move到底做了什么;- 为什么
Buffer b = a;和Buffer b(a);都会调用拷贝构造函数。
下面结合 Buffer 类逐一说明。
std::copy:真正复制一段元素
std::copy 定义在 <algorithm> 中,常见形式可以简化写成:
template<class InputIt, class OutputIt>
OutputIt std::copy(InputIt first, InputIt last, OutputIt destination);
三个参数的含义如下:
| 参数 | 含义 |
|---|---|
first | 源数据的起始位置 |
last | 源数据的结束位置,但不包含该位置 |
destination | 目标区域的起始位置 |
示例中的代码是:
std::copy(other.data, other.data + size, data);
可以理解为:
从
other.data开始,复制到other.data + size之前为止,并把结果写入data指向的内存。
假设 size == 3,它大致等价于:
data[0] = other.data[0];
data[1] = other.data[1];
data[2] = other.data[2];
也可以手写成循环:
for (size_t i = 0; i < size; ++i)
{
data[i] = other.data[i];
}
std::copy 的返回值是目标区域复制结束后的下一个位置。这里复制了 size 个元素,因此:
int* end = std::copy(other.data, other.data + size, data);
// 此时 end 等于 data + size
本例不需要继续使用这个位置,所以直接忽略了返回值。
需要注意,std::copy 不会自动为目标区域申请内存。调用它之前,data 必须已经指向足够大的有效内存。本例在成员初始化列表中已经完成了申请:
data(new int[other.size])
std::move:本身并不移动数据
std::move 定义在 <utility> 中。它的作用不是复制数据,也不是搬运数据,而是把一个表达式转换成可以绑定到右值引用的形式。
它的函数模板可以简化理解为:
template<class T>
std::remove_reference_t<T>&& std::move(T&& value) noexcept;
对于下面这句代码:
std::move(buf2)
参数是 buf2,返回结果可以简单理解为一个 Buffer&&。它相当于告诉编译器:
buf2原来的资源可以被转移,不必继续保留原来的内容。
但 std::move(buf2) 这一表达式本身不会修改 buf2。真正转移资源的是随后被调用的移动构造函数或移动赋值运算符。
例如:
Buffer buf3 = std::move(buf2);
std::move(buf2) 使右侧表达式可以匹配:
Buffer(Buffer&& other) noexcept;
于是编译器调用移动构造函数,由移动构造函数真正接管指针:
Buffer(Buffer&& other) noexcept
: data(other.data), size(other.size)
{
other.data = nullptr;
other.size = 0;
}
因此,可以把它们的职责记成:
std::copy:真正复制元素
std::move:把对象转换成“允许被移动”的表达式
移动构造函数:真正负责转移资源
单独写下面这句通常没有实际效果:
std::move(buf2);
因为返回的右值引用没有被用于构造、赋值或函数传参,移动构造函数也就不会被调用。
拷贝构造为什么是“深拷贝”
下面这句会调用拷贝构造函数:
Buffer buf2 = buf1;
对应的构造函数是:
Buffer(const Buffer& other)
: data(new int[other.size]), size(other.size)
{
std::copy(other.data, other.data + size, data);
}
它做了两件事:
- 为
buf2重新申请一块独立的数组内存; - 把
buf1数组中的每个元素复制到新内存中。
拷贝完成后,两者保存的内容相同,但管理的是不同的内存:
buf1.data ──> [buf1 自己的 1000 个 int]
buf2.data ──> [buf2 自己的 1000 个 int]
所以销毁或修改其中一个对象,不会直接影响另一个对象,这就是深拷贝。
移动构造为什么要清空原对象
移动构造函数没有重新申请数组,也没有复制 1000 个元素,而是直接接管指针:
data(other.data), size(other.size)
接管后必须执行:
other.data = nullptr;
other.size = 0;
否则新对象和原对象会同时保存同一个指针:
buf2.data ─┐
├──> [同一块数组内存]
buf3.data ─┘
两个对象析构时都会执行:
delete[] data;
这样就可能对同一块内存释放两次,产生未定义行为。
把原对象的指针设为 nullptr 后,所有权关系变成:
buf2.data = nullptr
buf3.data ──> [原来由 buf2 管理的数组内存]
而下面的操作是安全的:
delete[] nullptr;
所以移动后的 buf2 仍然可以正常析构。在这个自定义实现中,我们还明确把它的 size 设成了 0。
为什么 Buffer b = a; 等同于 Buffer b(a);
下面两种写法都在创建一个新的对象 b:
Buffer b = a; // 拷贝初始化
Buffer b(a); // 直接初始化
虽然第一种写法中出现了等号,但这里的 = 不是赋值运算符,因为 b 在这条语句执行前还不存在,它正在被创建。
两种写法都会寻找一个能够使用 a 来构造 b 的构造函数。由于 a 的类型是 Buffer,所以都会匹配:
Buffer(const Buffer& other);
也就是拷贝构造函数。
可以把它们理解成:
Buffer b = a; // 创建 b,并用 a 初始化
Buffer b(a); // 创建 b,并用 a 初始化
对于本例,这两种写法的效果相同。
构造和赋值的判断方法
判断调用的是构造函数还是赋值运算符,关键不在于有没有等号,而在于左侧对象是否正在被创建。
Buffer a(1000);
Buffer b = a; // b 正在创建:调用拷贝构造函数
Buffer c(a); // c 正在创建:调用拷贝构造函数
Buffer d = std::move(a); // d 正在创建:调用移动构造函数
Buffer e(std::move(b)); // e 正在创建:调用移动构造函数
如果对象已经存在,再使用等号,才是赋值:
Buffer x(100);
Buffer y(200);
x = y; // x 已存在:调用拷贝赋值运算符
x = std::move(y); // x 已存在:调用移动赋值运算符
可以用一句话记忆:
对象正在“出生”时调用构造函数;对象已经存在,只是更换内容时调用赋值运算符。
对应关系如下:
| 写法 | 对象状态 | 调用的函数 |
|---|---|---|
Buffer b = a; | b 正在创建 | 拷贝构造函数 |
Buffer b(a); | b 正在创建 | 拷贝构造函数 |
Buffer b = std::move(a); | b 正在创建 | 移动构造函数 |
Buffer b(std::move(a)); | b 正在创建 | 移动构造函数 |
b = a; | b 已经存在 | 拷贝赋值运算符 |
b = std::move(a); | b 已经存在 | 移动赋值运算符 |
拷贝初始化和直接初始化并非永远相同
从语法分类上看:
Buffer b = a; // copy-initialization,拷贝初始化
Buffer b(a); // direct-initialization,直接初始化
在当前 Buffer 示例中,它们都会调用普通的拷贝构造函数,因此效果相同。但面对带有 explicit 的转换构造函数时,两者会出现区别。
例如:
class Number
{
public:
explicit Number(int value)
{
}
};
直接初始化可以显式调用这个构造函数:
Number n1(10); // 正确
拷贝初始化则不允许隐式使用 explicit 构造函数:
Number n2 = 10; // 错误
因此,更准确的说法是:
对于本例中的同类型对象拷贝,
Buffer b = a;和Buffer b(a);都调用拷贝构造函数;但“拷贝初始化”和“直接初始化”在所有场景下并不完全等价。
补充: 当前
Buffer类手动管理动态内存,但只实现了析构函数、拷贝构造函数和移动构造函数。若还要安全支持b = a和b = std::move(a),还应实现拷贝赋值运算符与移动赋值运算符,这通常称为 Rule of Five(五法则)。
示例 3:在示例 2 基础上,移动语义让函数返回大对象高效
#include <iostream>
#include <vector>
#include <string>
// 返回大 vector(C++11 起自动启用移动语义,不需要手动 std::move)
std::vector<int> make_data(int n)
{
std::vector<int> v(n);
for (int i = 0; i < n; ++i)
{
v[i] = i * 10;
}
return v; // ✅ 编译器自动移动(或 RVO 优化),不拷贝
}
int main()
{
auto data = make_data(5);
std::cout << "data: ";
for (int n : data)
{
std::cout << n << " ";
}
std::cout << "\n";
std::cout << "size = " << data.size() << "\n";
// ⚠️ 错误做法:不要对返回值用 std::move!
// auto data2 = std::move(make_data(5)); // ❌ 不要这样写!破坏 RVO 优化
// ✅ 正确做法:赋值给已有变量时用 = std::move(source)
std::vector<int> old = {100, 200};
std::vector<int> fresh = std::move(old); // 把 old 的内容移给 fresh
std::cout << "old size = " << old.size() << "\n"; // 0
std::cout << "fresh size = " << fresh.size() << "\n"; // 2
return 0;
}
运行结果:
data: 0 10 20 30 40
size = 5
old size = 0
fresh size = 2
运行结果
见上方每个示例的"运行结果"。
示例中的关键语法解释
| 示例 | 讲了什么 | 新出现的语法 | 为什么这样写 | 注意事项 |
|---|---|---|---|---|
| 示例 1 | 拷贝 vs 移动对比 | std::move()、移动后 size=0 | move 后源对象被"掏空",变成有效但未指定状态 | move 后不要再使用源对象(除非重新赋值) |
| 示例 2 | 自定义移动构造 | Buffer(Buffer&&)、noexcept、other.data=nullptr | 移动构造直接偷指针,不分配新内存 | 移动后必须把 other.data 置 null,否则重复 delete |
| 示例 3 | 返回值和赋值优化 | RVO、return v 不加 move | 编译器会优化返回值为移动/RVO,加了 move 反而阻止 RVO | 局部变量 return 时不要加 std::move |
为什么移动构造要写 noexcept
std::vector 扩容时要把旧元素搬到新内存。如果元素类型的移动构造可能抛异常,vector 为了保证异常安全,可能宁愿走拷贝构造。也就是说:你明明写了移动构造,但如果没写 noexcept,容器扩容时不一定用它。
class Buffer
{
public:
Buffer(Buffer&& other) noexcept
{
// 偷资源,不抛异常
}
};
这个差异在单独移动一个对象时看不明显;当 vector<Buffer> 大量 push_back 触发扩容时,是否 noexcept 才会影响容器选择移动还是拷贝。
std::move 什么时候该用,什么时候不该用
| 场景 | 是否用 std::move | 原因 |
|---|---|---|
| 把已有对象的资源转给另一个对象 | ✅ | 明确转移所有权或内容 |
把 unique_ptr 传给接收所有权的函数 | ✅ | unique_ptr 不能拷贝,只能移动 |
return 局部变量 | ❌ | 让编译器做 RVO / NRVO |
对 const 对象 | ❌ | const 对象不能被真正移动,通常变成拷贝 |
| move 后还想继续读原对象内容 | ❌ | moved-from 对象只保证可析构、可重新赋值 |
常见错误
错误 1:move 之后继续使用源对象
std::vector<int> a = {1, 2, 3};
std::vector<int> b = std::move(a);
std::cout << a[0]; // ❌ a 已经被"掏空",行为未定义
正确做法:move 后不要再使用源对象,或者先检查是否为空。
错误 2:对 const 对象用 move
const std::vector<int> a = {1, 2, 3};
std::vector<int> b = std::move(a); // ❌ 实际执行的是拷贝!const 对象不能移动
正确做法:move 不要用于 const 对象。
错误 3:给返回值加 std::move
std::vector<int> func()
{
std::vector<int> v(1000);
return std::move(v); // ❌ 阻止了 RVO 优化!
}
正确做法:直接 return v;,编译器会自动优化。
错误 4:移动构造函数没有标记 noexcept
MyClass(MyClass&& other) { ... } // 缺少 noexcept
正确做法:MyClass(MyClass&& other) noexcept { ... } — 不标记 noexcept 会导致 vector 等容器在扩容时退化为拷贝。
使用建议
- return 时不要加 std::move:让编译器自动优化(RVO/NRVO)。
- 赋值给已有对象时用
std::move(source):用target = std::move(source)避免拷贝。 - move 后不要再用源对象(除非重新赋值或重置)。
- 移动构造函数必须标记
noexcept:否则 STL 容器在扩容时不会调用它。 std::move不移动任何东西:它只是类型转换,把左值变成右值引用。真正的移动发生在移动构造函数或移动赋值运算符中。
小结
- 左值有名字、可取地址;右值是临时的(字面量、临时对象)。
- 右值引用
T&&绑定到右值。 - 移动语义"偷"右值的资源,避免深拷贝。
std::move(x)把左值转为右值,告诉编译器"x 可以被偷"。- return 局部变量时不要加 std::move(让编译器自己优化)。
- 移动构造函数必须标记
noexcept。
工程拓展
在 ROS2 中,发布消息、传输大量传感器数据(如点云、图像)时,移动语义能避免大数据拷贝。在 Boost.Asio 中,异步操作的回调参数大量使用移动语义来高效传递数据缓冲区。