本周小贴士 #77: 临时、移动和复制

最初以 totw/77 发布于 2014-07-09

作者:泰特斯-温特斯 (titus@google.com)

已更新 2017-10-20

快速链接:abseil.io/tips/77

在不断尝试如何向非语言律师解释 C++11 如何改变事物的过程中,我们推出了 "副本何时制作?"系列中的另一个条目。这是我们试图简化 C++ 中围绕副本的微妙规则,并用一套更简单的规则取而代之的总体尝试的一部分。

你能数到 2 吗?


你会吗?棒极了。请记住,"名称规则 "意味着你能为某个资源分配的每一个唯一名称都会影响该对象的流通副本数量。(如需复习,请参阅 TotW 55 中的 "名称计数")。

名称计数简介


如果你担心拷贝被创建,那么你可能特别担心某行代码。那么,请看看这一点。你认为被复制的数据有多少个名称?只有三种情况需要考虑:

两个名称: 是复制


这种情况很简单:如果你给相同的数据起了第二个名字,那就是复制。

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),因此返回是最容易识别的情况。

std::vector<int> GetSomeInts() {
  std::vector<int> ret = {1, 2, 3, 4};
  返回 ret;
}

// 只是一个移动:"ret "或 "foo "都有数据,但绝不会同时有两个。
std::vector<int> foo = GetSomeInts();

另一种告诉编译器你已经用完一个名称的方法(TotW 55 中的 "名称清除器")是调用 std::move()。

std::vector<int> foo = GetSomeInts();
// 不是复制,move 允许编译器将 foo 作为一个
// 临时的,所以这是为
// std::vector<int>.
// 请注意,不是调用 std::move 进行移动,而是调用构造函数、
// 是构造函数。调用 std::move 只是让 foo
// 被视为临时对象(而不是有名称的对象)。
std::vector<int> bar = std::move(foo);


零名称: 这是一个临时对象


临时变量也很特殊:如果你想避免复制,就不要为变量命名。

void OperatesOnVector(const std::vector<int>& v);

// 没有拷贝:GetSomeInts() 返回的向量中的值 // 将被移动(O(1))。
// 将被移动(O(1))到这些调用之间的临时构造中,并通过引用传递到 OperatesOnVector(
// 调用,并以引用方式传递给 OperatesOnVector()。
OperatesOnVector(GetSomeInts());


当心僵尸


以上内容(std::move()本身除外)希望是非常直观的,只是在 C++11 之前的几年中,我们都建立了关于副本的奇怪概念。 对于一种没有垃圾回收的语言来说,这种类型的记账方式为我们带来了性能和清晰度的完美结合。不过,它也不是没有危险,其中最大的危险就是:一个值被移动后,它还剩下什么?

T bar = std::move(foo);
CHECK(foo.empty()); // 这样有效吗?也许有效,但不要指望它。


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

Clang-tidy 提供了一些静态检查,通过 misc-use-after-move 检查来捕捉移动后使用。不过,静态分析并不能捕捉到所有这些情况,因此要提高警惕。在代码审查中指出这些问题,并在自己的代码中加以避免。远离僵尸。

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


是的,还有一点需要注意的是,调用 std::move() 其实本身并不是移动,它只是一个 r 值引用的转换。只有在移动构造函数或移动赋值中使用该引用时才会起作用。

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


这种情况几乎不应该发生,你也不应该为此浪费大量的精神存储空间。只有当 std::move() 和移动构造函数之间的联系让你感到困惑时,我才会提到它。

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


首先:其实没那么糟糕。既然我们的大多数值类型(包括 protobufs)都有移动操作,我们就可以不必讨论 "这是复制吗?这有效吗?"的讨论,而只需依靠名称计数:两个名称,一个副本。少于两个:无副本。


忽略副本的问题,值的语义更清晰,推理也更简单。请看下面两个操作

void Foo(std::vector<string>* paths) {
  ExpandGlob(GenerateGlob(), paths);
}

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


这些相同吗?如果 *paths 中已有数据呢?如何判断?对于读者来说,值语义比输入/输出参数更容易推理,因为在输入/输出参数中,你需要考虑(并记录)现有数据会发生什么变化,以及是否可能发生指针所有权转移。

由于在处理值(而不是指针)时,对生命周期和使用的保证更为简单,因此编译器的优化程序更容易对这种风格的代码进行优化。管理良好的值语义还能最大限度地减少对分配器的占用(分配器虽然便宜,但不是免费的)。一旦我们理解了移动语义是如何帮助我们摆脱拷贝的,编译器的优化程序就能更好地推理对象类型、生命周期、虚拟调度和其他一系列问题,从而帮助生成更高效的机器代码。

既然大多数实用程序代码现在都具有移动感知能力,我们就不应该再担心副本和指针语义,而应专注于编写简单易懂的代码。请务必理解新规则:并非所有你遇到的传统接口都会更新为按值返回(而不是按输出参数返回),因此总会有各种风格的混合。重要的是,您要明白什么时候一种比另一种更合适。

  • 11
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值