0%

复制消除、初始化列表与就地构造

  • 常见的复制消除(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)

复制消除 - cppreference

强制的复制/移动消除

  • 在 return 语句中,当操作数为与函数返回类型为同一类类型的纯右值(忽略 cv 限定)时。要求返回类型的析构函数必须在 return 语句位置可访问且未被删除,即使无待销毁的 T 对象。
1
2
3
4
5
T f() {
return T();
}

f(); // 仅调用一次 T 的默认构造函数
  • 在对象的初始化中,当初始化器表达式为与变量类型为同一类类型的纯右值(忽略 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
    5
    std::string sad_function(std::string s)
    {
    s += "No RVO for you!";
    return s;
    }
  • return 语句的操作数和函数返回值类型不同。在下面的例子中,操作数类型为std::string&&,函数返回值类型为std::string。大部分情况下return std::move(...)是错误的。

1
2
3
4
5
std::string sad_function(std::string s)
{
s += "No RVO for you!";
return std::move(s);
}
  • 因为分支语句,导致编译器获得的信息不足。在下面的例子中,因为不知道该就地构造happy还是sad,因此无法进行RVO
1
2
3
4
5
6
7
8
9
10
11
12
13
std::string sad_function(std::string s)
{
auto happy = "happy"s;
auto sad = "sad"s;
if(get_happiness() > 0.5)
{
return happy;
}
else
{
return sad;
}
}
  • constexpr函数不可进行NRVO,但强制进行RVO

以下情况是否能进行RVO?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// example 1
const S will_it_rvo_1()
{
return S{1};
}

//example 2
S will_it_rvo_2()
{
if(b)
{
return S{1};
}
else
{
return S{0};
}
}
1
2
3
4
5
6
7
8
9
//example 3
S will_it_rvo(bool b, S s)
{
if(b)
{
s = S{1};
}
return s;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
//example 4
S get_S()
{
return S{1};
}

S will_it_rvo_4(bool b)
{
if(b)
{
return get_S{};
}
return S{0};
}
1
2
3
4
5
6
7
8
9
10
//example 5
S will_it_rvo_5(bool b)
{
if(b)
{
S s{1};
return s;
}
return S{0};
}
1
2
3
4
5
6
7
8
9
S will_it_rvo_6(bool b)
{
S s{1};
if(b)
{
return s;
}
return S{0};
}
1
2
3
4
5
S will_it_rvo_7(bool b)
{
S s{1};
return b ? s : S{0};
}
1
2
3
4
5
6
7
8
9
S get_S()
{
return S{1};
}

S will_it_rvo_8(bool b)
{
return b ? get_S() : S{0};
}
1
2
3
4
5
6
S will_it_rvo_9()
{
S s{1};
s = S{2};
return s;
}
1
2
3
4
5
S will_it_rvo_10()
{
S s{1};
return (s);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct P
{
constexpr P() : x{0}{}
constexpr P(P&&): x{1}{}
int x;
}

constexpr auto will_this_rvo_11()
{
P p;
return p;
}

int main()
{
const auto = will_this_rvo_11();
return p.x;
}

std::vector: push_back or emplace_back

想要在std::vector最后放入一个元素,是选择push_back还是emplace_back

1
2
3
4
5
6
7
void push_back(const T& x);
void push_back(T&& x);

template<typename... Args>
// returns a reference since C++ 17
reference
emplace_back(Args&&... args);
  • push_back提供对于rvalue的重载,对于rvalue,可以放心使用。
  • 何时使用emplace_back
    • emplace_back可以返回一个reference
    • emplace_back可以默认构造,使用explicit构造函数,和针对pairpiecewise_construct
  • 避免在emplace_back中传入一个显示调用构造函数产生的临时对象。

1
2
3
4
5
6
// example 1
std::vector<std::string> v{};
const char* s = "Hello";

v.push_back(s); // char* is first used in the construction of parameter
v.emplace_back(s); // char* is forwarded directly into the string.

在这个例子当中,使用emplace_back是更高效的。s被直接转发到该vector末尾元素的构造函数中。


1
2
3
4
5
6
// S has an 'explicit' ctor from 'int'
std::vector<S> v{};

v.push_back(1); // cannot compile --> push_back cannot be used in explicit construction

auto& s = v.emplace_back(1); // explicit is good for emplace_back

在这个例子中,S有一个限定为explicit的单参数构造函数。我们无法使用push_back来构造,必须使用emplace_back


1
2
3
4
5
6
// S has a ctor from Arg
std::array<Arg, 3> = {Arg{}, Arg{}, Arg{}};

std::vector<S> v{};
v.reserve(a.size());
std::copy(a.cbegin(), a.cend(), std::back_inserter(v));

在这个例子中,back_inserter会不断调用push_back,不会被就地构造在v的末尾。我们用Arg去构造S,产生一个临时对象,该临时对象被std::move后调用push_back的针对右值的重载。


1
2
3
4
5
std::array<int, 3> = {1, 2, 3};

std::vector<S> v{};
v.reserve(a.size());
std::copy(a.cbegin(), a.cend(), std::back_inserter(v));

无法编译,使用int的构造函数被限定为explicit。用transform修复。

1
2
3
4
5
6
7
8
std::array<int, 3> = {1, 2, 3};

std::vector<S> v{};
v.reserve(a.size());
std::transform(a.cbegin(), a.cend(), std::back_inserter(v), [](auto i)
{
return S{i};
});

1
2
3
4
5
// do not do this
m_headers.emplace_back(std::string(headerData, numBytes));

// forward args directly into the ctor
m_headers.emplace_back(headerData, numBytes);

不要在emplace_back中显式调用构造函数。


1
2
3
4
5
6
7
8
9
10
11
12
13
struct Value
{
Value(int, std::string, double);
};

// the second argument of pair needs a multi-args ctor
std::vector<std::pair<int, Value>> v{};

// 1 - this is vert common
v.push_back(std::make_pair(1, Value{42, "hello"s, 3.14}));

// 2 - this is no better
v.emplace_back(std::make_pair(1, Value{42, "hello"s, 3.14}));

std::pair的第二个模板参数的构造需要一个多参数的构造函数。1和2都会导致产生额外的临时对象。我们可以使用std::piecewise_construct_tstd::forward_as_tuple来解决。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <utility>
#include <tuple>

inline constexpr std::piecewise_construct_t piecewise_construct{};

template< class... Args1, class... Args2 >
pair( std::piecewise_construct_t,
std::tuple<Args1...> first_args,
std::tuple<Args2...> second_args );

template< class... Types >
constexpr tuple<Types&&...> forward_as_tuple( Types&&... args ) noexcept;


v.emplace_back(
std::piecewise_construct,
std::forward_as_tuple(1),
std::forward_as_tuple(42, "hello"s, 3.14));

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
2
const int a[] = {1, 2, 3};
std::vector<int> v = std::initializer_list<int>(a, a + 3);

下面这个例子可以证明这点:在函数f()直接返回了一个initializer_listinitializer_list作为一个borrowed type然而其对应的数组早已被销毁,造成了悬垂引用。

gcc甚至给出了warning而不允许通过编译。

1
2
3
4
warning: returning temporary 'initializer_list' does not extend the lifetime of the underlying array [-Winit-list-lifetime]

9 | return std::initializer_list<int>{Is...};
|

1
std::vector<S> v = { S{1}, S{2}, S{3}};

vs

1
2
3
4
5
std::vector<S> v;
v.reserve(3);
v.emplace_back(1);
v.emplace_back(2);
v.emplace_back(3);

如果S的构造代价较高,当使用std::initializer_list将会产生临时对象,导致vector的构造变得十分缓慢。下面的例子就是一个反面典型。每一个字符串常量都被用来构造一个临时的std::string

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
std::unordered_set<std::string> keywords = 
{
"asm", "else", "new", "this",
"auto", "enum", "operator", "throw",
"bool", "explicit", "private", "true",
"break", "export", "protected", "try",
"case", "extern", "public", "typedef",
"catch", "false", "register", "typeid",
"char", "float", "reinterpret_cast", "typename",
"class", "for", "return", "union",
"const", "friend", "short", "unsigned",
"const_cast", "goto", "signed", "using",
"continue", "if", "sizeof", "virtual",
"default", "inline", "static", "void",
"delete", "int", "static_cast", "volatile",
"do", "long", "struct", "wchar_t",
"double", "mutable", "switch", "while",
"dynamic_cast", "namespace", "template",
"And", "bitor", "not_eq", "xor",
"and_eq", "compl", "or", "xor_eq",
"bitand", "not", "or_eq"
};

std::map

使用std::initializer_list构造std::map会导致多余的临时变量和复制构造。

1
2
using 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
2
3
4
5
6
7
8
using M = std::map<int, S>;
M m{};
m[0] = S{1}; // explicit construct from int
m[1] = Arg{}; // implicit construct from Arg

m.insert(std::make_pair(1, S{1}));
m.insert(std::pair<int, S&&>(0, S{1})); // save a move
m.insert(std::make_pair(0, 1));

正确的方法是使用emplace,避免move

1
m.emplace(0, 1);


但是如果我们想要使用emplace默认构造被映射值,却会产生编译错误。

正确的做法应该是使用operator[]来默认构造。

1
2
3
using M = std::map<int, S>;
M m{};
m[0];

或者如果你必须要用emplace,那就使用piecewise_construct

1
2
3
4
5
using M = std::map<int, S>;
M m{};
m.emplace(std::piecewise_construct,
std::forward_as_tuple(0),
std::forward_as_tuple());

一个生产中的例子:

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
5
using 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
5
using 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));