每周技巧 #77:临时对象、移动和拷贝

本节阅读量:

本文翻译自 Abseil 官网的 Tip of the Week #77: Temporaries, Moves, and Copies

原文最初作为 totw/77 发布于 2014 年 7 月 9 日。

作者:Titus Winters

更新于 2017 年 10 月 20 日。

在持续尝试向非语言律师解释 C++11 如何改变事情的过程中,我们带来“什么时候会发生拷贝?”系列的又一篇。这是一个更大努力的一部分:简化 C++ 中围绕拷贝的微妙规则,并用一组更简单的规则替代它们。

你会数到 2 吗?

会?太好了。记住,“名字规则”意味着:你能给某个资源分配的每一个唯一名字,都会影响流通中的对象副本数量。(如果需要复习名称计数,见 TotW 55。)

简要名称计数

如果你担心会创建拷贝,想必你担心的是某一行具体代码。所以,就看那个点。你认为正在被复制的数据有多少个名字?只有 3 种情况需要考虑:

两个名字:这是拷贝

这一点很简单:如果你给同一份数据第二个名字,那就是拷贝。

1
2
3
4
5
6
7
std::vector<int> foo;
FillAVectorOfIntsByOutputParameterSoNobodyThinksAboutCopies(&foo);
std::vector<int> bar = foo;     // 没错,这是拷贝。

std::map<int, string> my_map;
string forty_two = "42";
my_map[5] = forty_two;          // 也是拷贝:my_map[5] 也算一个名字。

一个名字:这是移动

这一点有点令人惊讶:C++11 认识到,如果你已经不能再引用某个名字,那么你也就不再关心那份数据了。语言必须小心,不要破坏那些依赖析构函数的情况(比如 absl::MutexLock),所以 return 是容易识别的情况。

1
2
3
4
5
6
7
std::vector<int> GetSomeInts() {
  std::vector<int> ret = {1, 2, 3, 4};
  return ret;
}

// 只是移动:数据要么在 "ret",要么在 "foo",但从不会同时在两者中。
std::vector<int> foo = GetSomeInts();

告诉编译器你已经用完某个名字的另一种方式(TotW 55 中的“名字擦除器”)是调用 std::move()

1
2
3
4
5
6
7
8
std::vector<int> foo = GetSomeInts();
// 不是拷贝,move 允许编译器把 foo 当作
// 临时对象处理,所以这里会调用 std::vector<int>
// 的移动构造函数。
// 注意,不是 std::move 的调用本身在移动,
// 真正执行移动的是构造函数。std::move 调用只是允许 foo
// 被当作临时对象(而不是有名字的对象)处理。
std::vector<int> bar = std::move(foo);

零个名字:这是临时对象

临时对象也很特殊:如果你想避免拷贝,就避免给变量提供名字。

1
2
3
4
5
6
void OperatesOnVector(const std::vector<int>& v);

// 没有拷贝:GetSomeInts() 返回的 vector 中的值
// 会被移动(O(1))到这些调用之间构造的临时对象中,
// 并通过引用传给 OperatesOnVector()。
OperatesOnVector(GetSomeInts());

小心:僵尸

上面的内容(除了 std::move() 本身)希望都相当直观,只是我们在 C++11 之前的多年里建立了很多关于拷贝的奇怪观念。对一门没有垃圾回收的语言来说,这种记账方式很好地结合了性能和清晰性。不过,它并非没有危险。最大的危险是:一个值被移动走之后,里面还剩下什么?

1
2
T bar = std::move(foo);
CHECK(foo.empty()); // 这合法吗?也许,但不要依赖它。

这是主要难点之一:我们能对这些剩余值说什么?对大多数标准库类型来说,这样的值会处于“有效但未指定的状态”。非标准类型通常也遵循同样规则。安全做法是远离这些对象:你可以重新给它们赋值,或者让它们离开作用域,但不要对它们的状态作任何其他假设。

Clang-tidy 通过 bugprone-use-after-move 检查提供了一些静态检查,用于捕获 move 后使用。不过,静态分析永远无法捕获所有这类问题,请保持警惕。在代码评审中指出它们,并在自己的代码中避免它们。远离这些僵尸。

等等,std::move 不会移动?

是的,另一个需要注意的点是:调用 std::move() 本身实际上并不会移动,它只是转换成右值引用。只有当移动构造函数或移动赋值使用这个引用时,真正的工作才会发生。

1
2
3
4
std::vector<int> foo = GetSomeInts();
std::move(foo); // 什么都不做。
// 调用 std::vector<int> 的移动构造函数。
std::vector<int> bar = std::move(foo);

这种情况几乎不应该发生,你也大概不该为它占用太多脑内空间。我提它,只是为了防止你对 std::move() 和移动构造函数之间的关系感到困惑。

啊啊啊!这都太复杂了!为什么!?!

首先:其实没那么糟。既然大多数值类型(包括 protobuf)都有移动操作,我们就可以摆脱所有“这是拷贝吗?这高效吗?”的讨论,转而依赖名称计数:两个名字,就是拷贝。少于两个,就不是拷贝。

先不管拷贝问题,值语义更清晰,也更容易推理。考虑下面两个操作:

1
2
3
4
5
6
7
8
9
void Foo(std::vector<string>* paths) {
  ExpandGlob(GenerateGlob(), paths);
}

std::vector<string> Bar() {
  std::vector<string> paths;
  ExpandGlob(GenerateGlob(), &paths);
  return paths;
}

它们一样吗?如果 *paths 中已经有数据呢?你怎么判断?对读者来说,值语义比输入/输出参数更容易推理;后者需要思考(并记录)现有数据会发生什么,还可能需要思考是否存在指针所有权转移。

处理值(而不是指针)时,生命周期和使用方式有更简单的保证,因此编译器优化器更容易优化这种风格的代码。管理良好的值语义也能尽量减少对分配器的冲击(分配器便宜,但并非免费)。一旦我们理解移动语义如何帮助消除拷贝,编译器优化器就能更好地推理对象类型、生命周期、虚分派,以及许多有助于生成更高效机器码的问题。

既然大多数工具代码现在都能感知移动,我们就应该停止担心拷贝和指针语义,转而专注于编写简单、容易跟随的代码。请确保你理解这些新规则:你遇到的并非所有遗留接口都会更新成按值返回(而不是通过输出参数返回),所以不同风格总会混在一起。重要的是,你要理解什么时候哪一种更合适。

每周技巧 #76:使用 `absl::Status`

上一节

每周技巧 #86:用类枚举

下一节