本文翻译自 std::move doesn't move anything: A deep dive into Value Categories
问题:当“优化”让程序变慢时
让我们从一个让经验丰富的开发者都感到困惑的问题开始。你写了一段看似完全合理的C++ 代码
struct HeavyObject {
std::string data;
HeavyObject(HeavyObject&& other) : data(std::move(other.data)) {}
HeavyObject(const HeavyObject& other) : data(other.data) {}
HeavyObject(const char* s) : data(s) {}
};
std::vector<HeavyObject> createData() {
std::vector<HeavyObject> data;
// ... populate data ...
return data;
}
void processData() {
auto result = createData();
}这段代码可以工作。它可以通过编译,也可以正常运行。但是根据上述实现的代码,它可能会执行成千上万次耗时的拷贝操作而不是你实现的轻便的移动操作
让我们来看看具体发生了什么:当你的 std::vector 当前容量无法容纳新增元素时会重新分配一块大的内存。然后将旧的元素移动到新的内存中。但是这里有一个关键点,如果你的移动构造函数没有使用 noexcept 关键字标记,编译器就不会调用移动构造,反而会退回到采用普通构造函数拷贝每一个元素
为什么呢?因为 std::vector 需要维持所谓的 "强异常保证"。这是一种比较文雅的说法,它指的是:如果在重分配的过程中发生错误,原始的vector数据完全不受影响。如果在调用拷贝构造,在拷贝到新内存时发生错误,原始的vector仍然是完整的,如果在调用移动构造过程中发生错误,某些元素因为之前调用移动构造导致了原始的vector数据不完整。
所以标准库采取了保守的策略:如果你的移动构造函数可能抛出异常(因为你没有将移动构造标记为 noexcept), 容器就会使用拷贝代替移动。你认为的“优化”并没有发生。
而这里就变得有趣了:std::move 并不会神奇的解决这个问题,甚至如果使用不当,它会使事情变的更糟糕。让我向你展示为什么会这样。
机制:什么是真正的 std::move
一个可能会让你感到惊讶的事实:std::move 并不会移动任何事物。当你调用 std::move 时,不会有任何字节的内容发生移动。它是C++ 标准库中最具有误导性命名的函数之一。
那它实际上做了什么呢?让我们看看标准库中一种真正的实现(这份实现来自 libstdc++,但是其他的标准库实现也差不多):
template<typename _Tp>
constexpr typename std::remove_reference<_Tp>::type&&
move(_Tp&& __t) noexcept
{
return static_cast<typename std::remove_reference<_Tp>::type&&>(__t);
}如果你看到这个,并想到“这就是一个强制转换!”。你这么想是对的,这就是它的全部。std::move 接收你传递给它的任何参数,去掉所有的引用限定符(通过std::remove_reference)然后添加一个 &&让它变成右值引用,然后对该类型执行 static_cast,这就是整个函数。
让我用更简单的话来说:std::move 就像在你的对象上贴了一个标签,“我处理完了,你可以拿走它的东西了”。真正的拿走动作发生在之后,由看到这个标签的其他代码执行。具体来说,这个标签(右值引用类型)告诉编译器选择调用移动构造而不是拷贝构造。
理解值类别:一切的基础
现在,为了真正理解为什么 std::move 是这样的行为,我们需要深入了解C++ 的值类别的概念。这不仅可以帮助我们理解 std::move ,还是理解一般移动语义的方法。
在现代C++种,每一个表达式都有一个值类型,它决定了表达式可以被如何使用。可以将这些类别理解为回答关于某个表达式的两个问题:
- 它有身份吗(我能取它的地址吗)?
- 我能从它那里移动过吗(我能从它内部窃取资源吗)
基于这些问题,C++ 将表达式分为几个类型,我们将重点关注其中的三类:
接下来让我来详细拆解这些概念:
- lvalue(左值 left value):这是我们最熟悉的一类。左值是指任何具有名称(标识符)且在内存中占据特定位置的对象,可以通过
&获取它的内存地址,当你写下如下代码:
int x = 5;
std::string name = "Alice";x 和 name 都是左值,它们有名称,也有地址。它们的生命周期超过了当前的表达式,你可以将它们理解为“拥有固定地址的对象”。
- prvalue(纯右值 pure right value): 这一类代表着那些不具有持久身份标识的临时值。它们通常在表达式求值期间被创建,并且会被立即使用。例如:
42;
5 + 3;
std::string("hello");
Add(x, y) ;// 译者添加:函数的返回值也是典型的纯右值这些值无法通过名称或者内存地址获取,它们仅仅存在于创造它们的表达式求值期间。它们就像"转瞬即逝的过客"。
- xvalue(将亡值 expiring value): 它是比较特殊的一类,它是由
std::move创建。xvalue 仍然有一个标识(可以引用它,它自身也有一个名称),但是我们将它视为即将销毁的对象。这就好比在说“这个对象名义上还存在,但是我已经不需要它了。所以请把它作为临时值处理”
当你写下 std::move(name) 时,并没有移动 name 。我们将 name 变量从 左值转化为了一个将亡值,你只是改变了编译器对 name 变量的解释,真实的 name 变量依然安然无恙,并没有发生移动或者销毁。真正发生的事情是,编译器现在将该表达式视为“该对象即将消亡,你可以随意窃取其内部资源”。
这就是为什么我说 std::move 仅仅是一个类型转化。它改变的是表达式的值类型,而不是对象本身。它通过从一种值类型转化为另一种值类型(既转换为将亡值)来实现这一点。资源的实际移动发生在后续阶段,即当该将亡值调用移动构造或者移动赋值运算符时。
你可以想象成这样,std::move 只是在对象上挂了一块 "免费清仓,随便拿" 的牌子。 资源的实际转出发生在那些具备移动能力的函数读取该标签并据此执行移动操作的时候。
直觉方案的陷阱:三个降低性能的常见错误
现在我们明白了 std::move 真正做了什么,接下来我们来讨论人们如何因为误用导致性能不升反降。
错误1:误用 std::move(local_var) 导致编译器无法采用优化
这可能是 std::move 最常见的误用情况
std::string createString() {
std::string result = "expensive data";
// 针对这个对象做一些事情
return std::move(result); // 千万记住不要这么做!
}你可能会这么想:“嘿,因为要返回一个本地局部变量,所以我应该使用 std::move 来避免拷贝!” 但是实际上,这么做会让事情变得更糟糕。让我们来看看为什么。
现代C++ 编译器有一种称之为 NRVO(Name Return Value Optimization) 的优化。它是这么工作的:当你返回一个局部变量,编译器被允许直接在接受返回值的内存位置直接构造对象。这就意味着这里根本不需要拷贝或者移动。这个对象被就地构造,换句话说,编译器将不会做这些事:
- 在函数栈帧种创建一个
result对象 - 将
result对象移动到用户指定的位置 - 在函数栈帧种销毁
result对象
取而代之的是,编译器只会做这件事:
- 直接在用户指定位置构建
result对象
这不仅省去了移动操作,还免除了局部对象的析构。它比移动更优雅,因为这是从“一次操作”变为“0次操作”。但是这里有一个前提,NRVO 有其适用规则。为了让这种优化能够正常运行,需要通过名称返回对象。当你写 "std::move(result);" 时,不再是通过名称返回 result 对象,实际上你返回的是一个通过 std::move 转化的将亡值。
所以编译器认为,“我无法执行 NRVO 优化操作, 因为它并没有仅仅通过名称进行值返回”。现在你只能被动执行一次移动操作,你将一次0开销的操作“优化”成了1次移动操作,这被称作反向优化。
“请先等等”,你可能会说,“移动操作不是很快的嘛”。是的,移动操作比拷贝操作更快。但是0操作比1次操作更快。对于那些移动操作并不简单的类型(例如具备短字符串优化,或者其他复杂逻辑的字符),你可能反而强迫编译器执行了额外的操作,而这些开销本可以完全避免的。
为了修复这个问题,你需要通过名称返回对象
std::string createString() {
std::string result = "expensive data";
// 针对这个对象做一些事情
return result; // 正确,这里将采用 NRVO,如果调用 std::move 则无法执行优化
}编译器将会尽一切可能执行 NRVO 操作,如果它不能执行(可能是因为复杂的控制流程),它将会把返回值转化为右值,然后执行移动操作,你不需要做任何多余的事情。
规则:切记不要在 return 语句中对局部变量使用 std::move ,编译器比你想得更聪明。错误2: const T obj; std::move(obj) 暗中的拷贝
这拷贝更为隐蔽,而且同样会损害性能
void process() {
const std::vector<int> data = getData();
consume(std::move(data)); // 注意:这里实际发生的是拷贝操作,而非移动操作
}让我们来看看为什么这是一个错误。当你使用了 const ,就相当于告诉了编译器"该对象处于不可变状态" 。但是移动的核心操作就是改变对象状态,即从一个对象中夺取资源并将其转移给另一个对象。执行移动操作后的对象状态必然会发生变化(通常来说,它会变为空或者 null)
所以如果尝试对const 对象执行移动操作时会发生什么呢?让我们看看编译器是如何思考的:
std::move(data)返回一个const std::vector<int>&&类型(const 类型的右值引用)- 移动构造的函数原型为
vector(vector&&)(传入的是非 const 的右值引用) const T&&类型无法转化为T&&(无法通过普通的类型转化消除const)- 但是
const T&&可以转化为const T&(拷贝构造函数参数类型) - 所以编译器会调用拷贝构造取而代之
你写的代码看上去会执行移动操作,但是编译器会默默回退到使用拷贝操作,因为它不能合法的从 const 对象中移动资源。你希望避免的那些耗时的拷贝操作仍然会发生。
这是最危险的bug之一,这里没有警告,也没有错误。你的代码可以正常的编译运行,但是它并没有按照你的预期执行。
规则:永远不要在 const 对象上使用std::move, 如果某个变量是const类型,你不应该从它那里移动资源,移动意味着修改源对象,而const意味着不能修改
错误3:使用移动后的对象,这就像在玩火
这是第三种最常见的错误:
std::string name = "Alice";
std::string movedName = std::move(name);
std::cout << name << std::endl; // 这里会发生什么?C++ 标准规定,从标准库对象中移动后,该对象处于"有效但是未定义的状态",让我来解释这个隐晦短语的意思:
所谓“有效”,是指该对象依然满足其类不变式(译者注:Class Invariants,即对象内部的数据结构始终保持合法且可读的状态)。对于 std::string 而言,
- 内部指针没有悬空
- 其大小与其容量一致
- 你可以安全的调用它的析构函数
- 你可以调用没有前置条件的函数
未定义意味着,你不知道它的值是什么。也许 name 的值是空的,也许它仍然包含着字符串 Alice。也许它包含着其他完全不同的值,标准中没有描述,每个编译器的实现各有不同。
以下是你可以安全的对已移动的对象执行的操作:
- 销毁它(它会正确的执行销毁操作)
- 赋值给它 (name = "Bob")
- 调用没有先决条件的函数 (
name.empty(),name.clear())
以下是你不该做的事:
- 读取它的值 (
std::cout << name) - 调用带有先决条件的方法 (
name[0]或者name.back()假设字符串不为空) - 对其状态做出任何假设
事实上,大部分标准库在实现时确实会将已移动对象置为可预测状态(std::string 通常为空, std::vector 通常为空),但是这并非要求,依赖于此的代码是不可移植的,同时也是未定义行为。
我使用的思维模型:将一个被移动的对象视为被销毁的对象,技术上它仍然存在(所以你可以对其进行赋值),但是它的值已经被销毁。这就像一个抹除心智的人,身体还在,但是构建“他”的一切都消失了。
规则:在对象上调用 std::move 后,除了给它赋予新值和销毁它之外,不要再使用该对象。将其视为已销毁。正确实现移动操作
既然我们已经讨论了不该做什么,现在让我们谈谈如何正确的实现它。如果你正在实现一个资源管理器(内存、文件、句柄、网络联结)的类,你需要实现移动语义。并且有一个成熟的模型可以正确的实现这一点。
五法则
在现代C++ 中,如果你需要实现其中一个,那么你通常需要实现全部5个:
- 析构函数
- 拷贝构造函数
- 拷贝赋值运算符重载
- 移动构造函数
- 移动赋值运算符重载
这被称之为 "无法则" (在移动语义出现以前,它通常被称之为"三法则")。让我给你展示一个完整、正确的实现,然后我们会逐个分析每个部分
class Resource {
private:
int* data;
size_t size;
public:
// 构造函数
Resource(size_t n) : data(new int[n]), size(n) {
std::cout << "Constructing Resource with " << n << " elements\n";
}
// 析构函数
~Resource() {
std::cout << "Destroying Resource\n";
delete[] data;
}
// 拷贝构造,进行深拷贝
Resource(const Resource& other)
: data(new int[other.size]), size(other.size) {
std::cout << "Copy constructing Resource\n";
std::copy(other.data, other.data + size, data);
}
// 拷贝赋值运算符重载,进行深拷贝
Resource& operator=(const Resource& other) {
std::cout << "Copy assigning Resource\n";
if (this != &other) { // 防止自赋值
// 先分配一段新的内存.
int* new_data = new int[other.size];
std::copy(other.data, other.data + other.size, new_data);
delete[] data;
// 更新状态
data = new_data;
size = other.size;
}
return *this;
}
// 移动构造,转移所有权
Resource(Resource&& other) noexcept
: data(std::exchange(other.data, nullptr)),
size(std::exchange(other.size, 0)) {
std::cout << "Move constructing Resource\n";
}
// 移动赋值运算符重载: 转移所有权
Resource& operator=(Resource&& other) noexcept {
std::cout << "Move assigning Resource\n";
if (this != &other) { // 防止自赋值
delete[] data;
data = std::exchange(other.data, nullptr);
size = std::exchange(other.size, 0);
}
return *this;
}
};让我逐一讲解这些内容,因为每一个都有其特定的用途:
构造和析构函数很简单:构造函数分配资源,析构函数释放资源。这是基本的RAII(Resource Acquisition Is Initialization)。资源的生命周期与对象的生命周期绑定。
拷贝构造和拷贝赋值运算符完全符合你的预期,它们完全创造了一个完全独立的资源副本,如果某个资源对象拥有一块内存,复制它将创建一个新的资源对象,该对象拥有一个内容相同但完全不同的内存块。复制后两个对象互不影响。
移动构造和移动赋值操作是关心的重点。它们不创造新的资源,而是窃取原始资源,让我们聚焦于移动构造函数
Resource(Resource&& other) noexcept
: data(std::exchange(other.data, nullptr)),
size(std::exchange(other.size, 0)) {
std::cout << "Move constructing Resource\n";
}理解 std::exchange: 干净的移动方式
注意,我们这里使用了 std::exchange ,它是 <utility> 中的一个工具函数,它在一次操作中完成两件事:
- 它返回第一个参数的当前值
- 它将第一个参数值设置为第二个的值
所以 std::exchange(other.data, nullptr) 表示:
- 获取
other.data(资源指针) 的当前值 - 将
other.data设置为nullptr,表示other不再拥有该资源 - 返回原始指针
这对于实现移动操作非常完美,这正是我们进行移动操作时所必要的操作:
- 从
other处接管资源 - 将
other置于一个有效的空状态(这样它的析构函数就不会释放我们刚刚接管的资源)
我们可以不使用 std::exchange 来实现这个操作:
Resource(Resource&& other) noexcept
: data(other.data), size(other.size) {
other.data = nullptr;
other.size = 0;
}这是 std::exchange 更简洁,更能说明当前发生的事,这是现代 C++ 中实现移动的惯用法。
移动操作完成后,内存本身没有变化,只是指针交换了位置,对象B获得了内存的所有权,而对象A则处于安全的空状态。
noexcept 至关重要
现在我们来谈谈为什么两个移动操作都被标记为 noexcept 。这个关键字不是可选的,它对性能至关重要。
还记得之前提到过的 std::vector 在重新分配时不会使用你提供的移动构造函数,除非它是 noexcept。原因如下:
当 std::vector 增长时它需要维持强异常保证,这意味着在分配过程中出现异常,原始向量必须保持完全不变,让我们来分析这一个场景:
情景1:使用拷贝构造函数
- 创建新的内存块
- 将元素1拷贝到新内存块中 (可能抛出异常)
- 如果它抛出异常,删除新创建的内存,原始vector 不受影响
- 拷贝元素2到新内存中 (可能抛出异常)
- 如果它抛出异常,在新内存块中删除之前拷贝的元素1,删除之前创建的新内存,原始的vector不受影响
- 继续下一步
无论合适抛出异常,我们都可以清理新创建的内存,而原始的vector保持原样
情景2:使用移动构造时可能抛出异常
- 创建新的内存块
- 将元素1移动到新内存块中 (可能抛出异常)
- 如果它抛出异常,元素1现在处于未定义状态
- 将元素2移动到新内存块中 (可能抛出异常)
- 如果此时抛出异常,元素1已经被移动了(也可能损坏),元素2现在也被损坏
- 我们无法恢复到原来的状态
如果移动操作可能抛出异常,那么如果在重新分配过程中发生异常,我们无法保证原始vector仍然有效。所以 std::vector 做了一个取舍,如果你的移动构造函数可以抛出异常 (没有打上 noexcept 标记) , 它使用拷贝构造来代替移动操作,保证发生异常时原始数据有效
实际影响:如果你忘记了在移动构造函数后面加上 noexcept , 每次你的 vector 增长时它都采用赋值来代替移动操作,对于一个包含复杂对象的 vector 这可能意味着数百万次不必要的的内存分配。
规则:始终,始终,始终标记移动构造函数和移动赋值运算符 noexcept ,除非你有不标记的例外理由(而实际上你几乎从不这样做)
std::move 与 std::forward,两种用于不同任务的工具
现在你已经理解了 std::move ,现在让我们认识一下它的表兄弟 std::forward ,它们都是处理值类型转换的函数,但是它们服务于不同的场景
std::move 是无条件的,它总是将其参数转化为右值引用,无论你传入什么内容
template<typename T>
void process(T&& arg) {
// arg 是一个左值 (它在函数里面有名称!)
consume(std::move(arg)); // 总是将值转化为一个右值引用, 用来消耗
}尽管 arg 包含了一个 && , 但是在函数中,它有了名称,它就是一个左值。这是规则:如果有名称它就是左值。所以在 process 函数内部,它是一个左值,然后我们通过 std::move 将其转化为一个右值以供消耗。
std::forward 是条件性的。它保留其参数的值类别。这在模板中用于完美转发:
template<typename T>
void wrapper(T&& arg) {
// 如果 arg 原来是一个左值,将它转化为左值
// 如果 arg 原来是一个右值, 将它转化为右值
process(std::forward<T>(arg));
}关键区别在与 std::forward 记住 arg 被传递给 wrapper 时它是一个什么值,并保持这种状态。如果你用左值 x 调用 wrapper(x), std::forward<T>(arg) 会产生左值,如果你用右值调用 wrapper(std::move(x)),std::forward<T>(arg) 会产生右值
以下是何时使用何种情况:
- 当你确定需要从某物中移动,并且确定后续不再用它时使用
std::move - 仅在模板函数中使用转发引用 (
T&&, 其中T是模板参数),当你想传递参数时,同时保留它们是左值还是右值
在 99% 的普通代码中,你会使用 std::move 。std::forward 主要用于库作者和框架代码,这些代码视图完美封装其他函数。
现代C++ 环境,移动语义的演变
移动语义在C++ 11中引入,但在后续标准中仍在不断发展。让我们看看影响现代C++编写方式的关键发展
C++14 constexpr 移动
在C++11中 std::move 仅是运行时动作。 C++ 14 通过将 std::move 标记为 constexpr 改变了这一点
这看似是个小细节,毕竟 std::move 只是一个转换。但它是实现复杂的、重度依赖移动语义的逻辑(如排序或交换)完全在编译时运行的关键第一步。
C++17 强制拷贝消除
在C++17 之前,消除拷贝(包括返回值优化 ROV 和命名返回值优化 NROV)是一种允许的优化,但不是必须的。编译器可以执行它,也可以不执行。这在C++17中发生了变化。
当你返回一个纯右值时(一个纯粹的临时值),编译器必须直接在调用者的位置构造该对象
std::string create() {
return std::string("hello"); // C++17 以后保证不会移动或者复制
}对象直接在调用者的内存中构造,始终如此,这不是一个可能发生的优化,而是标准要求必须这样做。
这与 NRVO (命名返回值优化)不同,后者仍是可选的。当你返回一个有名称的局部变量时:
std::string create() {
std::string result = "hello";
return result; // NRVO 仍然是可选的,但是编译器通常会执行优化
}编译器允许直接在调用者的内存中构建 result 但是并非必须这样做。实际上现代编译器可以可靠的执行这种优化,但是标准并没有从技术上保证这一点。
要点:在C++17 以及更高的版本中,返回临时对象是保证高效的,不要再尝试使用 std::move 来优化。
C++ 20 编译时移动堆内存
事情开始变得复杂了,虽然自C++14 起,std::move 就是 constexpr 但由于无法在编译时分配内存,你实际上无法用它来操作标准容器。
C++ 20 引入了 constexpr 动态内存分配,这意味着 std::vector 和 std::string 现在可以在 constexpr 中使用并且可以被移动
constexpr int sum_data() {
std::vector<int> data = {1, 2, 3};
std::vector<int> moved_data = std::move(data); // C++ 20 中合法
int sum = 0;
for(int i : moved_data) sum += i;
return sum;
}
// 这个 vector 的创建、移动、以及销毁都发生在编译期
constexpr int result = sum_data();这使得更复杂的编译期编程能够成为可能。你现在可以编写移动对象的 constexpr 函数,并且整个计算都是在编译期进行。
C++23 Move-Only 函数包装器
C++ 23 中引入 std::move_only_function 它类似于 std::function ,但可以持有不可拷贝的类型
// 在 C++23 之前, 这段代码无法工作,因为std::function 要求必须可拷贝:
// std::function<void()> func = [ptr = std::make_unique<int>(42)]() {
// std::cout << *ptr;
// }; // Error: unique_ptr is not copyable!
// C++23 的解决方案:
std::move_only_function<void()> func = [ptr = std::make_unique<int>(42)]() {
std::cout << *ptr;
}; // 可以工作 func 可以被移动而不需要可以拷贝这对于需要拥有独占资源的回调和处理器特别有用
未来,平凡迁移(仍在开发中)
围绕平凡迁移正在进行一项标准化工作,尽管它尚未标准化,但是仍然值得了解。它的基本思想是:对于许多类型,移动一个对象等同于仅仅复制其字节并忘记原始对象(译者注:其实就是支持使用memcpy 直接进行内存拷贝)
考虑当 std::vector<std::string> 需要增长时,当前是怎么做的:
- 对于每个字符串调用移动构造函数(复制指针,将旧指针制空)
- 对于旧内存中的每个字符串调用其析构函数(检查指针是否为空,不执行实际操作)
那有很多函数调用,但从概念上讲,我们只需要:
memcpy整个字符串块移动到新内存中- 忘掉旧内存(不需要析构函数,它们都是空的)
这被称为“平凡重定位”,类型可以通过简单的字节复制进行重定位
有两个提案正在竞争这个特性:
P1144: (由 Arhtur O'Dwyer 撰写), 与 Qt、Folly、BDE 等库的实现方式一致P2786: (由 Giuseppe D’Angelo 等人提出),在 Hagenberg 2025 会议中被合并到工作草案中,但仍然存在争议。
争议源于语义和接口的差异,尽管实现者对 P2786 的语义与现有实践不符表示担忧,但是它仍被合并,许多主要库的维护者更倾向于 P1144 的设计
这对你有什么关系?如果当平凡重定位被标准化时,对于合适的类型,例如 std::vector 重分配这样的操作可能会变的快得多,对于大型容器可能会快几个数量级,但是关于如何主动采用这一特征,以及它能提供哪些具体的行为保证,目前仍在讨论和制定中。
基准测试,正确处理带来的性能影响
让我们通过一些具体的数字来理解性能影响。我在 GCC 13.3.0 的x86_64 机器上运行了基准测试,优化级别为 -O3,测量了一个包含 10000 个自定义对象的 vector 的操作:
安全操作的代价:
移动与复制包含 10000 个自定义对象的 vector 的性能比较
| 操作 | 时间 | 加速比 | 注释 |
|---|---|---|---|
| 深度复制 | 7.82ms | 1倍 | 基础操作,分配内存并执行复制操作 |
| 移动(正确) | 1.08ms | 7倍 | 即时交换指针 |
| 在const类型上移动 | 7.50ms | 1倍 | 陷阱:如果移动构造传入的参数是const 类型,会默默退回到深度拷贝 |
测试2,返回值迷思
一个常见的优化是将返回值包装在 std::move 中,这有用吗
| 操作 | 耗时 | 结果 |
|---|---|---|
| return x; (NRVO) | 0.83ms | 最快(零拷贝构造) |
| return std::move(x); | 0.82ms | 等价 (误差范围内) |
它们的结果是相同的
测试3,重新分配的成本
| 类型实现 | 时间 | 性能损耗 |
|---|---|---|
| 大类型(带有 noexcept) | 1.63ms | 正常水平 |
| 错误类型(未带有noexcept) | 16.42ms | 慢10倍 |
结果慢了10倍
让我们对上述这些数字做一个更详细的说明:
移动与复制:对于这个场景,正确实现的移动操作大约比复制操作快7倍,这不是笔误,就是7倍,复制操作需要分配并复制10000个对象,而移动操作只用交换几个指针。
NRVO 与移动:现代编译器足够智能(如GCC 15) 可以直接在调用者的栈帧中构造返回值(命名返回值优化),在这里添加 std::move 并不能使代码更快,至多它什么都不做,最坏的情况是它阻止编译器进行最块的优化。
const 错误:从const 对象移动的性能与复制完全相同,因为这就是它的作用,编译器会默默的选择拷贝构造函数,并且你不会得到任何的警告。这就是性能分析为何如此重要,看起来最优化的代码可能在做完全不同的事
用实际例子来说明:如果你正在构建一个在各个阶段之间移动数据的数据处理管道,如果移动语义使用不当,一个毫秒级的操作可能会变成7毫秒的操作。在规模上,这可能是每秒处理1000个请求和处理140个请求的差别
理解 std::move 的心智模型
让我们把这些整合到你可以编写代码的思维模型中
把 std::move 想象成对编译器的承诺,“我已经用完这个对象了,你可以安全的掠夺它的资源”。这不是一个移动的指令,而是一个移动的许可。实际的移动发生在这个被许可的对象执行移动构造或者移动赋值运算时。
当你写 std::move(x) 时,你正在改变编译器对 x 这个值的理解。你正在将它从左值(有具体的名称,有内存地址)转换为右值(即将过期,且可以被掠夺),变量 x 本身并没有去哪里,它的内容没有被移动,它们仍然在内存中,你只是改变了它在类型系统中的类别
实际的移动操作即资源转移,发生在那个xvalue 表达式被用来构造或者给另一个对象赋值时,这时移动构造或者移动赋值运算符重载会被执行,指针会交换,所有权会转移,源对象会被置空。
你的实用检查清单
- 不要在返回值上使用
std::move,编译器会尽可能进行 RVO 和 NRVO,否则会自动执行移动操作。添加std::move会阻止编译器执行优化 - 始终标记移动构造和移动赋值运算符重载函数的
noexcept。没有这个标记,标准容器在重新分配内存时不会使用你的移动操作。它们会进行赋值从而牺牲性能 - 永远不要在
const对象上调用std::move:编译器会默默执行拷贝而不是移动,编译器也不会警告你,如果某个对象是const的,它不能被移动 - 在移动实现中使用
std::exchange,这是最干净,最惯用的移动实现方法。它使所有权转移变的明确而且显而易见 - 不要使用已移动过的对象,除了赋新值或者销毁之外,将它们视为已经死亡的对象。即使它们在技术上仍然存在,它们的值已经被转移消失了
- 仅将
std::forward用于模板,在普通代码中使用std::move,仅在实现模板函数中的完美转发时使用std::forward
实际案例,构造一个支持移动操作的容器
让我们通过一个现实的例子来整合所有内容,我们将构建一个正确实现移动语义的动态数组类,并解释其中的每个步骤
#include <iostream>
#include <algorithm>
#include <utility>
#include <stdexcept>
template<typename T>
class DynamicArray {
private:
T* data_;
size_t size_;
size_t capacity_;
// 在需要时用来实现增长的工具类
void reserve_more() {
size_t new_capacity = capacity_ == 0 ? 1 : capacity_ * 2;
T* new_data = new T[new_capacity];
// 采用移动操作来将元素移动到新内存空间
for (size_t i = 0; i < size_; ++i) {
new_data[i] = std::move(data_[i]);
}
delete[] data_;
data_ = new_data;
capacity_ = new_capacity;
}
public:
// 构造函数
DynamicArray() : data_(nullptr), size_(0), capacity_(0) {
std::cout << "Default constructor\n";
}
// 析构函数
~DynamicArray() {
std::cout << "Destructor (size=" << size_ << ")\n";
delete[] data_;
}
// 拷贝构造,深拷贝
DynamicArray(const DynamicArray& other)
: data_(new T[other.capacity_]),
size_(other.size_),
capacity_(other.capacity_) {
std::cout << "Copy constructor (copying " << size_ << " elements)\n";
std::copy(other.data_, other.data_ + size_, data_);
}
// 拷贝赋值
DynamicArray& operator=(const DynamicArray& other) {
std::cout << "Copy assignment (copying " << other.size_ << " elements)\n";
if (this != &other) {
// 创建一个新的数据内存 (强异常保证)
T* new_data = new T[other.capacity_];
std::copy(other.data_, other.data_ + other.size_, new_data);
// 只有在前面的操作成功之后才执行后面的
delete[] data_;
data_ = new_data;
size_ = other.size_;
capacity_ = other.capacity_;
}
return *this;
}
// 移动构造,只交换所有权
DynamicArray(DynamicArray&& other) noexcept
: data_(std::exchange(other.data_, nullptr)),
size_(std::exchange(other.size_, 0)),
capacity_(std::exchange(other.capacity_, 0)) {
std::cout << "Move constructor (transferred " << size_ << " elements)\n";
}
// 移动赋值
DynamicArray& operator=(DynamicArray&& other) noexcept {
std::cout << "Move assignment (transferred " << other.size_ << " elements)\n";
if (this != &other) {
// 删除原来的数据内存
delete[] data_;
// 将当前所有权转移给另外的对象
data_ = std::exchange(other.data_, nullptr);
size_ = std::exchange(other.size_, 0);
capacity_ = std::exchange(other.capacity_, 0);
}
return *this;
}
// 添加元素
void push_back(const T& value) {
if (size_ == capacity_) {
reserve_more();
}
data_[size_++] = value;
}
// 添加元素 (移动版本)
void push_back(T&& value) {
if (size_ == capacity_) {
reserve_more();
}
data_[size_++] = std::move(value);
}
// 访问
T& operator[](size_t index) {
if (index >= size_) throw std::out_of_range("Index out of range");
return data_[index];
}
const T& operator[](size_t index) const {
if (index >= size_) throw std::out_of_range("Index out of range");
return data_[index];
}
size_t size() const { return size_; }
size_t capacity() const { return capacity_; }
};现在让我们使用这个类,看看具体会发生什么
int main() {
std::cout << "=== Creating array1 ===\n";
DynamicArray<std::string> array1;
array1.push_back("Hello");
array1.push_back("World");
std::cout << "\n=== Copy construction (array2) ===\n";
DynamicArray<std::string> array2 = array1; // Calls copy constructor
std::cout << "\n=== Move construction (array3) ===\n";
DynamicArray<std::string> array3 = std::move(array1); // Calls move constructor
// array1 is now in moved-from state - don't use it!
std::cout << "\n=== Creating array4 for assignment ===\n";
DynamicArray<std::string> array4;
std::cout << "\n=== Copy assignment ===\n";
array4 = array2; // Calls copy assignment
std::cout << "\n=== Move assignment ===\n";
array4 = std::move(array3); // Calls move assignment
// array3 is now in moved-from state
std::cout << "\n=== Function returning by value ===\n";
auto make_array = []() {
DynamicArray<std::string> temp;
temp.push_back("Temporary");
return temp; // RVO or automatic move
};
DynamicArray<std::string> array5 = make_array();
std::cout << "\n=== Destructors will be called ===\n";
return 0;
}让我们逐步了解每一步发生的情况
第一步:创建 array1
Default constructor默认构造,数组以空指针、零大小、零容量开始
第二步:拷贝构造
Copy constructor (copying 2 elements)当我们写 DynamicArray<std::string> array2 = array1 我们明确想要一个拷贝操作,拷贝构造函数分配新的内存并且将每个元素拷贝到新内存中,array1 和 array2 现在拥有各自独立的资源
第三步:移动构造
Move constructor (transferred 2 elements)这就有趣了, std::move 将 array1 由左值转化为 xvalue(将亡值),移动构造函数看到这一点,不进行内存分配和对象拷贝,而是:
- 获取array1 数据的指针
- 获取array1 的大小和容量
- 将array1 的指针置空,并且将大小和容量设置为0
没有内存分配,没有对象拷贝,只是指针交换,现在 array3 拥有array1曾今拥有的资源。而array1则处于一个有效的空状态。
第四步:赋值拷贝
Copy assignment (copying 2 elements)array4 已经存在(它通过默认构造函数创建),所以这里使用赋值而不是构造。拷贝赋值会分配新的内存,赋值数据然后交换,注意我们在删除旧数据之前分配新内存并且分配新数据。这提供了强异常保证,如果分配失败,array4 内容保持不变
第五步:移动赋值
Move assignment (transferred 2 elements)类似于构造函数,但是我们需要首先清理 array4 的内部资源(如果有),然后在接管array3 的内容,同样没有内存分配,没有拷贝,只有指针交换
第六步,函数返回
Default constructor
Move constructor (transferred 1 elements) // 可能让我们来讨论为什么,最后的输出是可能出现
如果你以高级别优化策略来编译这段代码(比如 GCC/Clang 中的 -O3 或者 MSVC 中的 /O2) 你可能完全看不到 Move constructor (transferred 1 elements) 被打印出来,只会看到 Default constructor
这是由于 NRVO (命名返回值优化) 编译器足够智能,能够意识到 lambda 表达式中的 temp 和 外部额array5 实际上是一个对象,它会完全略过移动操作,直接在最终目的地构建对象
然而如果你在调试模式下(为了方便调试而关闭优化),或者函数逻辑过于复杂,编译器无法分析,NRVO 可能不会发生
在这种情况下,C++ 保证了下一个最佳的选项,移动操作。编译器隐式地将返回的对象视为右值,它看到 return temp,但是将其视为 return std::move(temp)
要点:这是一个两全其美的情况,执行 NRVO (零成本),。最坏的情况,低成本的移动操作。你在这里永远不用付出深度拷贝的代价
注意:如果你想验证在 NRVO 禁用的情况下是否会执行移动操作,尝试在 GCC/Clang 上使用 -fno-elide-constructors 编译选项,你会看到移动构造函数理解被打印出来这个示例教会我们什么
- 五法则的实践:我们实现了所有5个特殊函数。如果我们只实现其中一个可能会出现意想不到的情况。例如,如果我们实现了析构但是没有实现拷贝构造,编译器生成的拷贝函数会进行浅拷贝,我们就会得到双重删除的错误。
- 关于移动操作的
noexcept:我们注意到,两个移动函数都添加了noexcept,这是至关重要的。如果没有它,如果有人将我们的DynamicArray放到std::vector中,vector在重新分配时不会使用我们的移动操作 - 使用已移动对象:在
std::move(array1)和std::move(array3)之后,这些对象处于已移动状态,它们仍然存在(但是未被销毁),但是资源已经被转移。它们的析构函数仍然会运行,但是它们正在销毁空对象(删除 nullptr 是安全的) - 强异常保证:注意在拷贝赋值运算符中,我们在删除旧内存之前分配新内存并拷贝数据到新内存。这样在分配或者拷贝时发生异常,我们的对象保持不变。这是异常安全C++的常见模式
- 重载用于右值: 注意我们有两个版本的
push_back。 一个接受const T&用于接受左值,一个接受T&&用于接受右值,当你使用临时对象调用push_back时,会调用右值重载,我们可以将临时对象移动到数组中。当你用变量名调用它时,会选择调用左值重载,进行拷贝操作。
性能陷阱:移动语义合适出错
让我们来看看即使你认为正确使用了移动语义时也可能出现的微妙性能问题
陷阱1:小字符串优化使得移动操作并不是轻微开销
现代C++ 使用小字符串优化(SSO) 来处理字符串 std::string 。长度小于特定大小(通常为 15~23个字符) 字符串直接存储在字符串对象上而不是堆上
std::string small = "Hi"; // 存储在字符串对象上,不分配堆内存
std::string large = "This is a much longer string that definitely needs heap allocation";
std::string moved_small = std::move(small); // 拷贝字符串中的内存
std::string moved_large = std::move(large); // 交换指针当你对小字符串执行移动操作时,你实际上是在小的字符串上执行拷贝操作。这仍然比堆分配要快,但是它不像移动一个大字符串那样快,移动过的小字符串仍然是有效的(通常是空)
移动操作的开销并非总是微小的,对于栈上的小对象,移动操作本质上就是复制,这仍然是可接受的,这是设计如此,但是理解实际发生的情况非常重要
陷阱2:在循环中忘记移动
std::vector<std::string> source = getLargeStrings();
std::vector<std::string> dest;
for (const auto& s : source) { // const 引用无法移动
dest.push_back(s); // 总是执行拷贝操作
}这里的const 阻止了移动,即使你想从 source 处移动,也无法做到,因为const 引用无法绑定到右值对象。正确的版本
for (auto& s : source) { // 非 const 引用
dest.push_back(std::move(s)); // 现在可以执行移动
}但是循环结束后 source 仍然存在,并且仍然包含字符串,但是它们都处于已移动状态(通常是空的)。如果你确实不再使用它,这是没问题的,如果你后续打算使用,这就是一个错误。
若要彻底转移源资源的所有权,更优的方案如下:
std::vector<std::string> dest = std::move(source); // 直接移动整个vector
// 现在 `source` 是空的,`dest` 拥有这些字符串的所有权这会移动整个 vector(只是几个指针交换),而不是单个字符串,效率更高
陷阱3:多层移动
void process(std::string s) { // 采用值传递
consume(s); // 拷贝而非移动
}
std::string data = "important";
process(std::move(data)); // 移动到了 process 函数, 但是 process 函数中,执行了拷贝操作移动操作能高效的将资源转移到函数内部,但是随后我们又将它复制到 consume 如果我们希望移动操作能传递
void process(std::string s) {
consume(std::move(s)); // 现在我们将它移动到 consume
}教训:移动操作不会自动传递,每次函数调用都是一次新的机会,可以选择移动或者复制。当你处理一个变量时,对 std::move 要明确。
陷阱4:返回语句中的意外复制:
std::pair<std::string, std::string> getData() {
std::string a = "first";
std::string b = "second";
return {a, b}; // 执行的是拷贝而不是移动
}花括号初始化列表 {a, b} 创建了一个临时对象,将 a 和 b 复制到其中,然后该临时对象会通过移动语义或 直接消除拷贝(Copy-elision) 的方式,传递给返回值。。我们为两个不必要的临时值付出了拷贝的代价。
更好的方式
std::pair<std::string, std::string> getData() {
std::string a = "first";
std::string b = "second";
return {std::move(a), std::move(b)}; // 实现我们将
}这是少数几个在返回值中使用 std::move 正确的情形之一,因为我们不是在移动返回值本身,而是在将元素移动到我们正在构建的用于返回的对象中。
继承中的移动语义
当你有继承时,移动语义需要小心处理
class Base {
std::string base_data_;
public:
Base(Base&& other) noexcept
: base_data_(std::move(other.base_data_)) {}
};
class Derived : public Base {
std::string derived_data_;
public:
Derived(Derived&& other) noexcept
: Base(std::move(other)), // 必须明确指定移动
derived_data_(std::move(other.derived_data_)) {}
};注意 Derived 移动构造函数中的 Base(std::move(other)),尽管 other 是一个右值引用,作为表达式使用时,它是一个左值(它有名字),如果没有 std::move 我们将会调用 Base 的拷贝构造函数,而不是移动构造
关键点:在派生类的移动构造函数中,other 是一个左值,即使它的类型是 Derived&&。这是"命名右值引用是左值"的规则,一开始让很多人感到疑惑
最终的思考:移动语义的哲学
让我为你揭示移动语义背后的深层见解。在C++11 以前,C++存在一个根本性的问题,你必须在效率和安全性之间做出抉择。
效率:通过指针传递,手动管理所有权,存在内存泄漏和悬垂指针的风险
安全性:在所有地方使用深拷贝,接受性能代价
移动语义为我们提供了第三种选择,安全高效的转移所有权,它让我们能够编写即像深度拷贝一样安全(编译器跟踪所有内容,无需手动管理)又像指针一样快(无需深度复制,只需交换指针)的代码
关键在于许多对象处于行将就木的状态,它们即将被摧毁,或者我们已经不再使用它们。移动语义让我们有机会从这些本来要销毁的对象中收回对应的值,我们不是让它们的资源随着它们一起销毁,而是将这些资源转移给将会继续使用它们的对象。
std::move 是个转移的明确标记。这是你在告诉编译器:“我已经用完这个对象,它已经死了,把它的器官拿去交给其他需要的人。”
这就是心智模型比语法更重要,移动语义不仅仅是一个性能技巧,它们是我们对C++中对象生命周期和资源所有权思考方式的根本性转变,理解它们不经能让你在编写C++时更快,还能让你在任何语言中更好的进行资源管理推理。
进一步阅读的资源
官方文档:
标准提案:
文章和演讲:
-理解何时不应使用 std::move(Red Hat 开发者博客)- 聚焦于常见错误
书籍:
- 《Effective Modern C++》作者:Scott Meyers - 第 23-25 条详细介绍了移动语义
- 《C++ Move Semantics - The Complete Guide》作者:Nicolai Josuttis - 整本书都致力于这个主题
记住:移动语义是一种工具,而非目标。目标是编写正确、可维护且高效的代码。移动语义有助于实现这一目标,但只有在恰当使用时才能做到。有时你需要的是拷贝。有时编译器会完全消除该操作。懂得何时以及如何使用移动——以及何时不该使用——正是区分优秀 C++代码与卓越 C++代码的关键。
评论 (0)