(原文链接:https://abseil.io/tips/166 译者:clangpp@gmail.com)
每周贴士 #166: 你不是真正的复制
- 最初发布于:2019-08-28
- 作者:Richard Smith
- 更新于:2020-04-06
- 短链接:abseil.io/tips/166
Entia non sunt multiplicanda praeter necessitatem." (“Entities should not be multiplied without necessity”) – William of Ockham
如无必要,勿增实体——奥卡姆
If you don’t know where you’re going, you’re probably going wrong.” – Terry Pratchett
如果你不知道要去哪儿,那你可能是走错了——特里·普拉切特
概述
从C++17开始,对象会尽可能在“原地”被创建。
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
)是直接被运行初始化器所得到的施工图所初始化的。如果该名字是一个引用,那么一个临时对象会被实现出来以承载该值。
结果就是,对象在正确的位置被直接构造,而不是在别处构造再复制过来。该行为有时被称作“保证执行的复制省略”,但那是不准确的:从来就没有过复制。
你需要知道的就是:对象在被首次被取名前不会被复制。以值返回没有额外代价。
(就算是被取名以后,函数本地变量被返回时也不会被复制,因为有命名返回值优化。详情请参考Tip 11。)
繁琐细节:匿名对象何时被复制
在两个边缘情况下,使用匿名对象还是会导致复制:
-
构造基类:在基类构造函数初始化列表中,即使是以基类类型的未命名表达式来构造,还是会发生复制。这是因为类型作为基类时,可能有稍微不同的布局和表现(因为虚基类和虚函数指针值),所以直接初始化基类也许不会得到正确的表现。
class DerivedThing : public BigExpensiveThing { public: DerivedThing() : BigExpensiveThing(MakeAThing()) {} // 可能复制data_ };
-
传递或返回小而简单的对象:如果一个足够小且可以平凡复制(trivially copyable)的对象,被传递进或返回出函数,那么它可能会被传递进寄存器,因此在传递前后可能会有不同的地址。
struct Strange { int n; int *p = &n; }; void f(Strange s) { CHECK(s.p == &s.n); // 可能炸锅 } void g() { f(Strange{0}); }
繁琐细节:值类别
C++中有两种风格的表达式:
- 得到一个值,例如
1
,或MakeAThing()
——你可能会认为拥有非引用类型的表达式。 - 得到一个指向存在的对象的位置,例如
s
或thing.data_[5]
——你可能会认为拥有引用类型的表达式。
这个分割被称为“值类别”;前者是 纯右值(prvalues) 而后者是 泛左值(glvalues)。当我们前面探讨匿名对象的时候,我们实际上指的是纯右值表达式。
所有的纯右值表达式都在决定值存储位置的上下文中被求值,而纯右值表达式的执行过程则被用以在该位置初始化该值。
例如,在
BigExpensiveThing thing = MakeAThing();
中,纯右值表达式MakeAThing()
作为变量thing
的初始化器被求值,所以MakeAThing()
会直接初始化thing
。构造函数把指向thing
的指针传递给MakeAThing()
,然后MakeAThing()
中的return
语句初始化该指针指向的玩意儿。相似地,在
return BigExpensiveThing();
中,编译器有个指针指向待初始化的对象,并且直接调用BigExpensiveThing
的构造函数来初始化该对象。