每周技巧 #166:当拷贝不是拷贝

本节阅读量:

本文翻译自 Abseil 官网的 Tip of the Week #166: When a Copy is not a Copy

原文最初作为 TotW #166 发布于 2019 年 8 月 28 日。

作者:Richard Smith

更新于 2020 年 4 月 6 日。

快捷链接:abseil.io/tips/166

“如无必要,勿增实体。” —— William of Ockham

“如果你不知道自己要去哪里,很可能正在走错路。” —— Terry Pratchett

概览

从 C++17 开始,只要可能,对象都会“就地”创建。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
class BigExpensiveThing {
 public:
  static BigExpensiveThing Make() {
    // ...
    return BigExpensiveThing();
  }
  // ...
 private:
  BigExpensiveThing();
  std::array<OtherThing, 12345> data_;
};

BigExpensiveThing MakeAThing() {
  return BigExpensiveThing::Make();
}

void UseTheThing() {
  BigExpensiveThing thing = MakeAThing();
  // ...
}

这会拷贝或移动 BigExpensiveThing 多少次?

C++17 之前,答案是最多三次:每个 return 语句一次,初始化 thing 时再一次。这有一定道理:每个函数都可能把 BigExpensiveThing 放在不同位置,因此可能需要一次移动,才能把值放到最终调用方想要的位置。不过实践中,对象总是直接在变量 thing 中“就地”构造,不执行移动;C++ 语言规则允许“省略”这些移动操作,以促成这种优化。

从 C++17 开始,这段代码保证执行零次拷贝或移动。事实上,即使 BigExpensiveThing 不可移动,上面的代码也是有效的。BigExpensiveThing::Make 中的构造函数调用会直接构造 UseTheThing 中的局部变量 thing

那么发生了什么?

当编译器看到像 BigExpensiveThing() 这样的表达式时,它不会立即创建临时对象。相反,它把该表达式视为如何初始化某个最终对象的指令,并尽可能推迟创建(正式地说,“物化”)临时对象。

一般来说,对象创建会被推迟到该对象获得名称时。具名对象(上例中的 thing)会使用求值初始化器得到的指令直接初始化。如果这个名称是引用,则会物化一个临时对象来保存该值。

因此,对象会直接在正确位置构造,而不是在别处构造后再复制。这种行为有时称为“保证拷贝消除”,但这并不准确:一开始就没有拷贝。

你只需要知道:对象在第一次获得名称之前不会被拷贝。按值返回没有额外成本。

(即使获得名称之后,局部变量从函数返回时也可能因为具名返回值优化而仍然不被拷贝。详情见 技巧 11。)

细节:未命名对象何时会被拷贝

有两个角落情况中,使用没有名称的对象仍会导致拷贝:

  • 构造基类:在构造函数的基类初始化列表中,即使从基类类型的未命名表达式构造,也会发生拷贝。这是因为类作为基类使用时,可能具有略微不同的布局和表示(由于虚基类和 vpointer 值),所以直接初始化基类可能无法得到正确表示。
1
2
3
4
class DerivedThing : public BigExpensiveThing {
 public:
  DerivedThing() : BigExpensiveThing(MakeAThing()) {}  // might copy data_
};
  • 传递或返回小型平凡对象:当足够小且可平凡复制的对象传给函数或从函数返回时,它可能通过寄存器传递,因此传递前后的地址可能不同。
1
2
3
4
5
6
7
8
struct Strange {
  int n;
  int *p = &n;
};
void f(Strange s) {
  CHECK(s.p == &s.n);  // might fail
}
void g() { f(Strange{0}); }

细节:值类别

C++ 中有两类表达式:

  • 产生值的表达式,例如 1MakeAThing(),你可能认为它们具有非引用类型。
  • 产生某个已有对象位置的表达式,例如 sthing.data_[5],你可能认为它们具有引用类型。

这种划分称为“值类别”;前者是 prvalue,后者是 glvalue。前面谈到没有名称的对象时,我们真正指的是 prvalue 表达式。

所有 prvalue 表达式都会在某个上下文中求值,该上下文决定它们把值放在哪里;prvalue 表达式的执行会用其值初始化那个位置。

例如,在:

1
  BigExpensiveThing thing = MakeAThing();

中,prvalue 表达式 MakeAThing() 作为变量 thing 的初始化器求值,因此 MakeAThing() 会直接初始化 thing。构造函数把指向 thing 的指针传入 MakeAThing(),而 MakeAThing() 中的 return 语句会初始化该指针指向的对象。类似地,在:

1
  return BigExpensiveThing();

中,编译器有一个指向待初始化对象的指针,并通过调用 BigExpensiveThing 构造函数直接初始化该对象。

相关阅读

每周技巧 #165:带初始化器的 `if` 和 `switch` 语句

上一节

每周技巧 #168:`inline` 变量

下一节