每周技巧 #166:当拷贝不是拷贝
本节阅读量:本文翻译自 Abseil 官网的 Tip of the Week #166: When a Copy is not a Copy。
原文最初作为 TotW #166 发布于 2019 年 8 月 28 日。
更新于 2020 年 4 月 6 日。
快捷链接:abseil.io/tips/166
“如无必要,勿增实体。” —— William of Ockham
“如果你不知道自己要去哪里,很可能正在走错路。” —— Terry Pratchett
概览
从 C++17 开始,只要可能,对象都会“就地”创建。
|
|
这会拷贝或移动 BigExpensiveThing 多少次?
C++17 之前,答案是最多三次:每个 return 语句一次,初始化 thing 时再一次。这有一定道理:每个函数都可能把 BigExpensiveThing 放在不同位置,因此可能需要一次移动,才能把值放到最终调用方想要的位置。不过实践中,对象总是直接在变量 thing 中“就地”构造,不执行移动;C++ 语言规则允许“省略”这些移动操作,以促成这种优化。
从 C++17 开始,这段代码保证执行零次拷贝或移动。事实上,即使 BigExpensiveThing 不可移动,上面的代码也是有效的。BigExpensiveThing::Make 中的构造函数调用会直接构造 UseTheThing 中的局部变量 thing。
那么发生了什么?
当编译器看到像 BigExpensiveThing() 这样的表达式时,它不会立即创建临时对象。相反,它把该表达式视为如何初始化某个最终对象的指令,并尽可能推迟创建(正式地说,“物化”)临时对象。
一般来说,对象创建会被推迟到该对象获得名称时。具名对象(上例中的 thing)会使用求值初始化器得到的指令直接初始化。如果这个名称是引用,则会物化一个临时对象来保存该值。
因此,对象会直接在正确位置构造,而不是在别处构造后再复制。这种行为有时称为“保证拷贝消除”,但这并不准确:一开始就没有拷贝。
你只需要知道:对象在第一次获得名称之前不会被拷贝。按值返回没有额外成本。
(即使获得名称之后,局部变量从函数返回时也可能因为具名返回值优化而仍然不被拷贝。详情见 技巧 11。)
细节:未命名对象何时会被拷贝
有两个角落情况中,使用没有名称的对象仍会导致拷贝:
- 构造基类:在构造函数的基类初始化列表中,即使从基类类型的未命名表达式构造,也会发生拷贝。这是因为类作为基类使用时,可能具有略微不同的布局和表示(由于虚基类和 vpointer 值),所以直接初始化基类可能无法得到正确表示。
|
|
- 传递或返回小型平凡对象:当足够小且可平凡复制的对象传给函数或从函数返回时,它可能通过寄存器传递,因此传递前后的地址可能不同。
|
|
细节:值类别
C++ 中有两类表达式:
- 产生值的表达式,例如
1或MakeAThing(),你可能认为它们具有非引用类型。 - 产生某个已有对象位置的表达式,例如
s或thing.data_[5],你可能认为它们具有引用类型。
这种划分称为“值类别”;前者是 prvalue,后者是 glvalue。前面谈到没有名称的对象时,我们真正指的是 prvalue 表达式。
所有 prvalue 表达式都会在某个上下文中求值,该上下文决定它们把值放在哪里;prvalue 表达式的执行会用其值初始化那个位置。
例如,在:
|
|
中,prvalue 表达式 MakeAThing() 作为变量 thing 的初始化器求值,因此 MakeAThing() 会直接初始化 thing。构造函数把指向 thing 的指针传入 MakeAThing(),而 MakeAThing() 中的 return 语句会初始化该指针指向的对象。类似地,在:
|
|
中,编译器有一个指向待初始化对象的指针,并通过调用 BigExpensiveThing 构造函数直接初始化该对象。
相关阅读