- 常见的复制消除(copy elision)、RVO 与 NRVO
- initializer_list是borrowed type
- emplace_back和push_back,选那个?
CppCon 2019: Ben Deane “Everyday Efficiency: In-Place Construction (Back to Basics?)”
本文章适用于C++17(及后续)标准。
常见的复制消除(copy elision)
强制的复制/移动消除
- 在 return 语句中,当操作数为与函数返回类型为同一类类型的纯右值(忽略 cv 限定)时。要求返回类型的析构函数必须在 return 语句位置可访问且未被删除,即使无待销毁的 T 对象。
1 | T f() { |
- 在对象的初始化中,当初始化器表达式为与变量类型为同一类类型的纯右值(忽略 cv 限定)时:
1 | T x = T(T(f())); // 仅调用一次 T 的默认构造函数以初始化 x |
非强制的复制/移动消除(RVO, NRVO)
return
语句中,当操作数是拥有自动存储期的非volatile
对象的名字,其并非函数形参或catch
子句形参,且其具有与函数返回类型相同的类类型(忽略 cv 限定)时。这种复制消除的变体被称为 NRVO,“具名返回值优化 (named return value optimization)”。在协程中,可以消除将形参向协程状态内的复制/移动,只要除了对形参的构造函数与析构函数的调用被忽略以外,不改变程序的行为即可。若在暂停点后始终不使用形参,或者整个协程状态本就始终不在堆上分配,则可出现此情形。
无法进行RVO的常见情况
return 语句的操作数为函数的形参。因为s不是由该函数构造的,无法进行RVO。
1
2
3
4
5std::string sad_function(std::string s)
{
s += "No RVO for you!";
return s;
}return 语句的操作数和函数返回值类型不同。在下面的例子中,操作数类型为
std::string&&
,函数返回值类型为std::string
。大部分情况下return std::move(...)
是错误的。
1 | std::string sad_function(std::string s) |
- 因为分支语句,导致编译器获得的信息不足。在下面的例子中,因为不知道该就地构造
happy
还是sad
,因此无法进行RVO
1 | std::string sad_function(std::string s) |
constexpr
函数不可进行NRVO,但强制进行RVO
以下情况是否能进行RVO?
1 | // example 1 |
1 | //example 3 |
1 | //example 4 |
1 | //example 5 |
1 | S will_it_rvo_6(bool b) |
1 | S will_it_rvo_7(bool b) |
1 | S get_S() |
1 | S will_it_rvo_9() |
1 | S will_it_rvo_10() |
1 | struct P |
- return 操作数为纯右值,忽略cv限定后与函数返回值一致。C++17标准要求进行复制消除。
- return 操作数为纯右值,忽略cv限定后与函数返回值一致。C++17标准要求进行复制消除。即使在debug编译模式,仍然进行RVO。
- 不能进行RVO,return 操作数为形参。
- 函数返回值为纯右值。return 操作数为纯右值,忽略cv限定后与函数返回值一致。C++17标准要求进行复制消除。
- clang会进行RVO,MSVC和gcc不会RVO。(?)
- ?
- 涉及到三目运算符的value category。或许因为违反”the name of a stack variable”而不能RVO。
- 该三目运算符的value category为prvalue。强制复制消除。
- NRVO
- NRVO 标准规定:The Standard, [class.copy.elision]/(3.1)
If the expression in a return or co_return statement is a (possibly parenthesized) idexpression that names an implicitly movable entity declared in the body or parameter-declaration-clause of the innermost enclosing function or lambda-expression, or …
constexpr
不可进行NRVO
std::vector: push_back or emplace_back
想要在std::vector
最后放入一个元素,是选择push_back
还是emplace_back
?
1 | void push_back(const T& x); |
push_back
提供对于rvalue的重载,对于rvalue,可以放心使用。- 何时使用
emplace_back
:emplace_back
可以返回一个reference
emplace_back
可以默认构造,使用explicit
构造函数,和针对pair
的piecewise_construct
。
- 避免在
emplace_back
中传入一个显示调用构造函数产生的临时对象。
1 | // example 1 |
在这个例子当中,使用emplace_back
是更高效的。s
被直接转发到该vector
末尾元素的构造函数中。
1 | // S has an 'explicit' ctor from 'int' |
在这个例子中,S
有一个限定为explicit
的单参数构造函数。我们无法使用push_back
来构造,必须使用emplace_back
。
1 | // S has a ctor from Arg |
在这个例子中,back_inserter
会不断调用push_back
,不会被就地构造在v
的末尾。我们用Arg
去构造S
,产生一个临时对象,该临时对象被std::move
后调用push_back
的针对右值的重载。
1 | std::array<int, 3> = {1, 2, 3}; |
无法编译,使用int
的构造函数被限定为explicit
。用transform修复。
1 | std::array<int, 3> = {1, 2, 3}; |
1 | // do not do this |
不要在emplace_back
中显式调用构造函数。
1 | struct Value |
std::pair
的第二个模板参数的构造需要一个多参数的构造函数。1和2都会导致产生额外的临时对象。我们可以使用std::piecewise_construct_t
和std::forward_as_tuple
来解决。
1 |
|
std::initializer_list
不可以将move-only type放入std::initializer_list
。因为你不能从std::initializer_list
中将元素move
出去。
当你使用std::initializer_list
时1
std::vector<int> v{1, 2, 3};
相当与构造了一个const
的array,然后以std::initializer_list
作为该array的view。1
2const int a[] = {1, 2, 3};
std::vector<int> v = std::initializer_list<int>(a, a + 3);
下面这个例子可以证明这点:在函数f()
直接返回了一个initializer_list
,initializer_list
作为一个borrowed type然而其对应的数组早已被销毁,造成了悬垂引用。
gcc甚至给出了warning
而不允许通过编译。
1 | warning: returning temporary 'initializer_list' does not extend the lifetime of the underlying array [-Winit-list-lifetime] |
1 | std::vector<S> v = { S{1}, S{2}, S{3}}; |
vs
1 | std::vector<S> v; |
如果S
的构造代价较高,当使用std::initializer_list
将会产生临时对象,导致vector
的构造变得十分缓慢。下面的例子就是一个反面典型。每一个字符串常量都被用来构造一个临时的std::string
。
1 | std::unordered_set<std::string> keywords = |
std::map
使用std::initializer_list
构造std::map
会导致多余的临时变量和复制构造。1
2using M = std::map<int, S>;
auto m = M{ {0, Arg{} } };
当operator[]
所访问的元素已经存在时,这样做不会造成多余的临时变量和移动构造。然而当该元素不存在时,该元素会先被默认构造,再从Arg{}构造产生的临时对象移动复制。使用insert
也不会让情况变得更好。
若键不存在,则插入从 std::piecewise_construct, std::forward_as_tuple(std::move(key)), std::tuple<>() 原位构造的 value_type 对象。使用默认分配器时,这导致从 key 移动构造关键,并值初始化被映射值。
1 | using M = std::map<int, S>; |
正确的方法是使用emplace
,避免move
1
m.emplace(0, 1);
但是如果我们想要使用emplace
默认构造被映射值,却会产生编译错误。
正确的做法应该是使用operator[]
来默认构造。
1 | using M = std::map<int, S>; |
或者如果你必须要用emplace
,那就使用piecewise_construct
1 | using M = std::map<int, S>; |
一个生产中的例子:1
2
3
4
5
6
7// explicit ClientRecord(const string& clientId,
// const ProcessId& clientProcess,
// const MachineId& clientMachine);
using Storage = std::unordered_set<ClientRecord>;
Storage m_storage;
m_storage.emplace(clientId, processId, machineId);
如果我们现在想把这个set
升级成客户id做key对应value的map
这样做会导致额外的临时对象和移动构造:1
2
3
4
5using Storage = std::unordered_set<ClientRecord>;
Storage m_storage;
m_storage.emplace(
std::make_pair(clientId, ClientRecord(clientId, processId, machineId))
);
使用std::piecewise_construct
来避免:1
2
3
4
5using Storage = std::unordered_set<ClientRecord>;
Storage m_storage;
m_storage.emplace(std::piecewise_construct,
std::forward_as_tuple(clientId),
std::forward_as_tuple(clientId, processId, machineId));