背景
最近同学想到一个问题,有关C++的右值引用。我本以为自己读了两遍C++ Primer,总该得心应手,却发现越解释,越迷糊。
这篇文章不会介绍有关右值引用和移动构造函数的概念,而是直接通过描述同学的问题并深入探讨,来尝试解释一些右值引用、移动构造和完美转发的具体用途。
问题
STL里面的std::map<Key, T, Compare, Allocator>::insert,有两个insert
方法的重载:
-
自C++11
template <class P> std::pair<iterator, bool> insert(P&& value);
-
自C++17
std::pair<iterator, bool> insert(value_type&& value);
这两个方法有什么不同用途?为什么在C++17时,又增加了第二个方法重载?
一些知识点
-
std::move
只是一个到右值的类型转换,并不做任何其他实质性的事情。 -
右值引用的右值引用可以合成为右值引用,其他的引用组合都只能合称为左值引用:
typedef int& lref; typedef int&& rref; int n; lref& r1 = n; // type of r1 is int& lref&& r2 = n; // type of r2 is int& rref& r3 = n; // type of r3 is int& rref&& r4 = 1; // type of r4 is int&&
-
右值引用变量在表达式中是左值:
int&& x = 1; f(x); // calls f(int& x) f(std::move(x)); // calls f(int&& x)
-
转发引用是一种特殊的引用,可以保留函数参数的原始类别。
比如,模板函数参数声明为没有const/volatile修饰符的右值引用类型:template<class T> int f(T&& x) { // x is a forwarding reference return g(std::forward<T>(x)); // and so can be forwarded } int main() { int i; f(i); // argument is lvalue, calls f<int&>(int&), std::forward<int&>(x) is lvalue f(0); // argument is rvalue, calls f<int>(int&&), std::forward<int>(x) is rvalue }
实验 1
想搞清楚那两个方法的用途,最好的办法就是用一下他们。
不过map::insert
方法过于复杂,我们写一个简单的程序,有类似的方法:
这里可以简单浏览代码,后面会详细展开解释。
#include <iostream>
#include <string>
#include <list>
using namespace std;
struct A
{
string str;
A(const A& a) : str(a.str)
{
cout << "copy A" << endl;
}
A(A&& a) noexcept : str(move(a.str))
{
cout << "move A" << endl;
}
A(const string& str) : str(str)
{
cout << "init A (const string&)" << endl;
}
A(string&& str) : str(move(str))
{
cout << "init A (string&&)" << endl;
}
A(int n) : str(to_string(n))
{
cout << "init A (int)" << endl;
}
A& operator=(const A& rhs)
{
str = rhs.str;
cout << "copy assign A" << endl;
return *this;
}
A& operator=(A&& rhs) noexcept
{
str = move(rhs.str);
cout << "move assign A" << endl;
return *this;
}
};
struct Container
{
list<A> aList;
void Add(A&& a)
{
cout << "Add(A&&)" << endl;
aList.push_back(move(a));
cout << aList.back().str << endl;
cout << "---" << endl;
}
void Add(const A& a)
{
cout << "Add(const A&)" << endl;
aList.push_back(a);
cout << aList.back().str << endl;
cout << "---" << endl;
}
template <class U, class = enable_if_t<is_constructible_v<A, U>>>
void Add(U&& v)
{
cout << "Add(U&&)" << endl;
aList.emplace_back(forward<U>(v));
cout << aList.back().str << endl;
cout << "---" << endl;
}
};
int main()
{
Container c;
// 传入`A`
{
// pass in `A` rvalue
// 构造自`string` rvalue
// 构造自`const char *`
c.Add(A("1"));
// 传入`A` rvalue
// 构造自`string` rvalue
// 构造自`const char *`
c.Add({ "2" });
// 传入`A` rvalue
// 构造自`string` rvalue
// 构造自`const char *`
c.Add({ {"3"} });
// 传入`A` rvalue
// 构造自`string` lvalue
{
string x("4");
c.Add(A(x));
}
// 传入`A` lvalue
{
A a("5");
c.Add(a);
}
}
// 传入`string`
{
// 传入`string` rvalue
// 构造自`const char *`
c.Add(string("6"));
// 传入`string` rvalue
// moved from`string` lvalue
{
string x("7");
c.Add(move(x));
}
// 传入`string` lvalue
{
string x("8");
c.Add(x);
}
}
// 传入`int`
{
// 传入`int` rvalue
c.Add(9);
// 传入`int` lvalue
{
int n = 10;
c.Add(n);
}
}
// 传入`const char *`
{
// This is only applicable to Add(T&&).
c.Add("11");
}
return 0;
}
程序说明
这段程序很简单,但是有点长,所以在这里一段段地解释一下。
Struct A
A
有拷贝构造
、移动构造
、拷贝赋值
、移动赋值
,还有3个类型转换构造
。
A(const A& a);
A(A&& a) noexcept;
A(const string& str);
A(string&& str);
A(int n);
A& operator=(const A& rhs);
A& operator=(A&& rhs) noexcept;
Struct Container
(对list
简单的包装)
Container
里面有一个list<A>
成员变量,以及3个重载方法可以向这个列表增加A
类型元素。
下面的代码是简化后的Container
的实现(删减了输出等语句)。
list<A> aList;
void Add(A&& a)
{
aList.push_back(move(a));
}
void Add(const A& a)
{
aList.push_back(a);
}
template <class U, class = enable_if_t<is_constructible_v<A, U>>>
void Add(U&& v)
{
aList.emplace_back(forward<U>(v));
}
Main
在主函数,我用通过不同构造函数构造的A
调用了不同的Add
方法。
后面我会一一分析解释他们。
输出
分析之前,我们先看一下程序输出的结果,以供参考和理解。
init A (string&&)
Add(A&&)
move A
1
---
init A (string&&)
Add(A&&)
move A
2
---
init A (string&&)
Add(A&&)
move A
3
---
init A (const string&)
Add(A&&)
move A
4
---
init A (string&&)
Add(U&&)
copy A
5
---
Add(U&&)
init A (string&&)
6
---
Add(U&&)
init A (string&&)
7
---
Add(U&&)
init A (const string&)
8
---
Add(U&&)
init A (int)
9
---
Add(U&&)
init A (int)
10
---
Add(U&&)
init A (string&&)
11
---
分析
Case 1
// 传入`A` rvalue 构造自`string` rvalue 构造自`const char *`
c.Add(A("1"));
init A (string&&)
Add(A&&)
move A
因为"1"
是一个string
右值,A(...)
也是一个右值,所以我们看到A
是通过string&&
构造的,然后Add(A&&)
被调用。
void Add(A&& a)
{
aList.push_back(move(a));
}
在Add(A&&)
里面,A
右值要被移动(move
)到aList
里面。
Case 2
// 传入`A` rvalue 构造自`string` rvalue 构造自`const char *`
c.Add({ "2" });
init A (string&&)
Add(A&&)
move A
实际上,除了这里用到了列表初始化,和Case 1没有什么不同。
这里的列表初始化会产生一个右值。所以他要匹配一个移动构造函数,同时接收const char *
类型的参数。因为const char *
可以隐式转换为string
,所以最终A(string&&)
被调用。
后面的事情和Case 1完全一样。
Case 3
// 传入`A` rvalue 构造自`string` rvalue 构造自`const char *`
c.Add({ {"3"} });
init A (string&&)
Add(A&&)
move A
这个Case也完全与Case 1和2一样。
内部的列表初始化构造string
右值,外部的列表初始化构造A
右值。
Case 4
// 传入`A` rvalue 构造自`string` lvalue
string x("4");
c.Add(A(x));
init A (const string&)
Add(A&&)
move A
这个Case和前面三个Case的唯一区别在于这里传入A
构造函数的是一个string
左值。
所以不同的A
构造函数,即A(const string&)
被调用。后面仍然一样。
Case 5
// 传入`A` lvalue
A a("5");
c.Add(a);
init A (string&&)
Add(U&&)
copy A
这个Case,一个A
左值被传入Add
,所以它匹配到Add(U&&)
,此时U = A&
。
你也许会问Add(A& &&)
是啥?记得前面在一些知识点里讲过转发引用嘛?
那么,既然传入的是A
左值,为什么不调用A(const A&)
呢?
为什么要调用呢?如果调用A(const A&)
,需要额外的const转换。但模板方法是直接匹配的。
你可以试试在A a("5")
前面加一个const
,然后看看怎么样。
template <class U, class = enable_if_t<is_constructible_v<A, U>>>
void Add(U&& v)
{
aList.emplace_back(forward<U>(v));
}
// when U = A&, the equivalent method is:
void Add(A& v)
{
aList.emplace_back(v);
}
emplace
会直接转发参数到构造函数,并在需要的内存位置中直接构造相应的对象。
因为v
是A&
类型,emplace_back
就调用了A
的拷贝构造来使其就位。
Case 6
// 传入`string` rvalue 构造自`const char *`
c.Add(string("6"));
Add(U&&)
init A (string&&)
显然这里U = string
。
所以,在调用Add
之前,A
并不需要被构造出来。
在Add
内部,list<A>::emplace_back
被调用,它会将参数直接转发到A
的构造函数。
template <class U, class = enable_if_t<is_constructible_v<A, U>>>
void Add(U&& v)
{
aList.emplace_back(forward<U>(v));
}
// when U = string, the equivalent method is:
void Add(string&& v)
{
aList.emplace_back(move(v));
}
这里,U = string
,v
是一个string
右值,A
通过v
来构造,所以A(string&&)
构造函数被调用。
可以看到,A
构造后,并没有额外的移动或拷贝。这还是因为emplace
会直接转发参数,将A
构造在所需的内存位置。既然它已经就位,就没有必要在移动或拷贝了。
本例和Case 5不同,因为Case 5传入的是左值。
Case 7
// 传入`string` rvalue moved from`string` lvalue
string x("7");
c.Add(move(x));
Add(U&&)
init A (string&&)
本例与Case 6完全一样,因为我们move
了一个左值,即转换为了右值。
Case 8
// 传入`string` lvalue
string x("8");
c.Add(x);
Add(U&&)
init A (const string&)
这里U = string&
,所以A
在调用Add
前并不需要被构造。
template <class U, class = enable_if_t<is_constructible_v<A, U>>>
void Add(U&& v)
{
aList.emplace_back(forward<U>(v));
}
// when U = string&, the equivalent method is:
void Add(string& v)
{
aList.emplace_back(v);
}
在Add
内部,A
通过emplace_back
由一个string
左值构造。所以A(const string&)
构造函数被调用。
A
已就位,无需后续操作。
Case 9
// 传入`int` rvalue
c.Add(9);
Add(U&&)
init A (int)
这里U = int
。
template <class U, class = enable_if_t<is_constructible_v<A, U>>>
void Add(U&& v)
{
aList.emplace_back(forward<U>(v));
}
// when U = int, the equivalent method is:
void Add(int&& v)
{
aList.emplace_back(move(v));
}
A
只有一个能接受int
右值的构造函数,也就是A(int)
。
Case 10
// 传入`int` lvalue
int n = 10;
c.Add(n);
Add(U&&)
init A (int)
本例与Case 9唯一不同是传入了int
左值,即U = int&
。
template <class U, class = enable_if_t<is_constructible_v<A, U>>>
void Add(U&& v)
{
aList.emplace_back(forward<U>(v));
}
// when U = int&, the equivalent method is:
void Add(int& v)
{
aList.emplace_back(v);
}
但A
的构造函数能接受int
左值的还是只有A(int)
。
Case 11
// 传入`const char *`
c.Add("11");
Add(U&&)
init A (string&&)
这里U = const char &[3]
,或者也可以当作是const char *&
。
template <class U, class = enable_if_t<is_constructible_v<A, U>>>
void Add(U&& v)
{
aList.emplace_back(forward<U>(v));
}
// when U = const char *&, the equivalent method is:
void Add(const char*& v)
{
aList.emplace_back(v);
}
const char *& v
在被emplace_back
转发至构造函数时,被隐式转换为string
,所以A(string&)
被调用。
实验 2
上面的实验和分析解释了不同的构造函数和Add
方法具有怎样的行为。但现在我们更想知道为什么我们需要他们。
所以,实验2中,我会移除一个Add
方法,看看会发生什么。
移除Add(U&&)
本实验中,我将模板方法Add
注释掉了。
void Add(A&& a)
{
aList.push_back(move(a));
}
void Add(const A& a)
{
aList.push_back(a);
}
/*
template <class U, class = enable_if_t<is_constructible_v<A, U>>>
void Add(U&& v)
{
aList.emplace_back(forward<U>(v));
}
*/
立刻会发现,程序编译不通过。错误来自:
c.Add("11");
因为现在它无法匹配到一个合适的Add
重载方法,毕竟没有A
可以直接从const char *
构造。
于是干脆暂时删掉这个Case 11,其他的Case都还能编译通过。
我们来看一下变化!
Case 5 变化
// 传入`A` lvalue
A a("5");
c.Add(a);
因为现在没有模板方法Add
,所以Add(const A&)
被调用。没有什么性能上的差异。
Case 6 变化
// 传入`string` rvalue 构造自`const char *`
c.Add(string("6"));
现在没有Add
方法直接接受string
,所以,Add(A&&)
被调用。在这之前,string
右值必须被转换构造为A
右值。
可以看到构造和Add
的调用顺序发生了变化,也证实了前面的说法。
最后,A
需要被移动到aList
中,也带来了额外的运行代价。
也许你会问,为什么不在这里用emplace_back
来避免移动这一运行代价。
其实emplace
确实可以在这里使用,但代价是去除不掉的。因为emplace
也只是转发传入的A
右值,仍然会调用A
的移动构造函数。这和前一实验中的emplace
不同,之前是传入的string
右值,直接通过它构造A
,构造被推迟到了emplace
这里;而这里我们在emplace
之前已经有一个A
,是不就位的,移动是不可避免的。
Case 7-10 变化
在这几例中,A
的构造过程也提前了,正如Case 6一样。不过,这几例会通过不同的构造函数构造A
的右值。
后面A
右值传入Add(A&&)
后的过程,与Case 6完全一样。
实验 2 总结
可以看到,没有模板方法Add
,我们就没有办法利用好emplace
方法,也就避免不了一些本可避免的额外移动或拷贝的代价。
那么,Add(A&&)
又有什么用呢?它是否能被模板方法Add
替代?
让我们继续实验!
实验 3
移除Add(A&&)
这里,我注释掉了Add(A&&)
方法。
/*
void Add(A&& a)
{
aList.push_back(move(a));
}
*/
void Add(const A& a)
{
aList.push_back(a);
}
template <class U, class = enable_if_t<is_constructible_v<A, U>>>
void Add(U&& v)
{
aList.emplace_back(forward<U>(v));
}
让我们再来看看变化!
Case 1,4 变化
// 传入`A` rvalue 构造自`string` rvalue 构造自`const char *`
c.Add(A("1"));
// 传入`A` rvalue 构造自`string` lvalue
string x("4");
c.Add(A(x));
因为没有Add(A&&)
匹配,他们会转而匹配模板方法,U = A
。没有什么其他运行代价的变化。
Case 2-3 Changes
// 传入`A` rvalue 构造自`string` rvalue 构造自`const char *`
c.Add({ "2" });
// 传入`A` rvalue 构造自`string` rvalue 构造自`const char *`
c.Add({ {"3"} });
因为花括号初始化(列表初始化)也是调用构造函数,需要知道具体的构造类型。模板方法是无法提供一个具体类型的,所以Add(const A&)
就被调用了。
当然,后果就是之前的移动变成了现在的拷贝,增加了运行代价。
实验 3 总结
Add(A&&)
对于花括号(列表)初始化很有用,因为编译器需要匹配类型才能正确调用构造函数,但模板方法无法提供具体的类型。
你可以试试把Add(A&&)
和Add(const A&)
都删掉,那么Case 2和3就无法编译通过了。
回到问题
-
自C++11
template <class P> std::pair<iterator, bool> insert(P&& value);
-
自C++17
std::pair<iterator, bool> insert(value_type&& value);
首先,我们要知道map<Key, T>
的value_type
是pair<const Key, T>
。而pair
拥有非常多的构造函数。
所以,为了能够利用emplace
的好处,以及尽可能多地使用移动构造而非拷贝构造,同时提供上述两种方法是必要的。
gcc 7.1 和 gcc 9.1 比较
请放大网页或右键图片从新标签页打开查看。
请忽略编译器参数上的–std=c++14,看起来对有没有
map::insert(value_type&&)
这个库函数没啥用。
新问题
然而我现在很困惑Add(const A&)
。
在实验1中,他根本没被调用。
实际上,想调用它,除非传入的正好是const A
左值。但单独去处理const左值的意义是什么呢?
尤其是在map
中,也提供了:
std::pair<iterator, bool> insert(const value_type& value);
这是有必要的吗?