每周技巧 #77:临时对象、移动和拷贝
本节阅读量:本文翻译自 Abseil 官网的 Tip of the Week #77: Temporaries, Moves, and Copies。
原文最初作为 totw/77 发布于 2014 年 7 月 9 日。
更新于 2017 年 10 月 20 日。
在持续尝试向非语言律师解释 C++11 如何改变事情的过程中,我们带来“什么时候会发生拷贝?”系列的又一篇。这是一个更大努力的一部分:简化 C++ 中围绕拷贝的微妙规则,并用一组更简单的规则替代它们。
你会数到 2 吗?
会?太好了。记住,“名字规则”意味着:你能给某个资源分配的每一个唯一名字,都会影响流通中的对象副本数量。(如果需要复习名称计数,见 TotW 55。)
简要名称计数
如果你担心会创建拷贝,想必你担心的是某一行具体代码。所以,就看那个点。你认为正在被复制的数据有多少个名字?只有 3 种情况需要考虑:
两个名字:这是拷贝
这一点很简单:如果你给同一份数据第二个名字,那就是拷贝。
|
|
一个名字:这是移动
这一点有点令人惊讶:C++11 认识到,如果你已经不能再引用某个名字,那么你也就不再关心那份数据了。语言必须小心,不要破坏那些依赖析构函数的情况(比如 absl::MutexLock),所以 return 是容易识别的情况。
|
|
告诉编译器你已经用完某个名字的另一种方式(TotW 55 中的“名字擦除器”)是调用 std::move()。
|
|
零个名字:这是临时对象
临时对象也很特殊:如果你想避免拷贝,就避免给变量提供名字。
|
|
小心:僵尸
上面的内容(除了 std::move() 本身)希望都相当直观,只是我们在 C++11 之前的多年里建立了很多关于拷贝的奇怪观念。对一门没有垃圾回收的语言来说,这种记账方式很好地结合了性能和清晰性。不过,它并非没有危险。最大的危险是:一个值被移动走之后,里面还剩下什么?
|
|
这是主要难点之一:我们能对这些剩余值说什么?对大多数标准库类型来说,这样的值会处于“有效但未指定的状态”。非标准类型通常也遵循同样规则。安全做法是远离这些对象:你可以重新给它们赋值,或者让它们离开作用域,但不要对它们的状态作任何其他假设。
Clang-tidy 通过 bugprone-use-after-move 检查提供了一些静态检查,用于捕获 move 后使用。不过,静态分析永远无法捕获所有这类问题,请保持警惕。在代码评审中指出它们,并在自己的代码中避免它们。远离这些僵尸。
等等,std::move 不会移动?
是的,另一个需要注意的点是:调用 std::move() 本身实际上并不会移动,它只是转换成右值引用。只有当移动构造函数或移动赋值使用这个引用时,真正的工作才会发生。
|
|
这种情况几乎不应该发生,你也大概不该为它占用太多脑内空间。我提它,只是为了防止你对 std::move() 和移动构造函数之间的关系感到困惑。
啊啊啊!这都太复杂了!为什么!?!
首先:其实没那么糟。既然大多数值类型(包括 protobuf)都有移动操作,我们就可以摆脱所有“这是拷贝吗?这高效吗?”的讨论,转而依赖名称计数:两个名字,就是拷贝。少于两个,就不是拷贝。
先不管拷贝问题,值语义更清晰,也更容易推理。考虑下面两个操作:
|
|
它们一样吗?如果 *paths 中已经有数据呢?你怎么判断?对读者来说,值语义比输入/输出参数更容易推理;后者需要思考(并记录)现有数据会发生什么,还可能需要思考是否存在指针所有权转移。
处理值(而不是指针)时,生命周期和使用方式有更简单的保证,因此编译器优化器更容易优化这种风格的代码。管理良好的值语义也能尽量减少对分配器的冲击(分配器便宜,但并非免费)。一旦我们理解移动语义如何帮助消除拷贝,编译器优化器就能更好地推理对象类型、生命周期、虚分派,以及许多有助于生成更高效机器码的问题。
既然大多数工具代码现在都能感知移动,我们就应该停止担心拷贝和指针语义,转而专注于编写简单、容易跟随的代码。请确保你理解这些新规则:你遇到的并非所有遗留接口都会更新成按值返回(而不是通过输出参数返回),所以不同风格总会混在一起。重要的是,你要理解什么时候哪一种更合适。