C++移动语义、万能引用、引用折叠、完美转发
600 Words | Read in about 3 Min | View times
Overview
现代C++提供了移动语义相关的操作,通过移动语义可以将资源的所有权转移到新的对象上,从而避免数据拷贝带来的性能开销。移动语义实现了从一个对象到另一个对象的资源转移的过程。标准库为移动语义提供了辅助函数std::move()
,用于执行一个无条件的对rvalue
的转换,对于函数本身而言,并不移动任何内容,只是强制转化左值引用为右值引用,从而通过这个右值引用使用该值,以用于移动语义。与之类似的还有一个辅助函数std::forward()
,它和std::move()
类似,但只有在参数被一个lvalue
初始化时,才会转换为rvalue
。std::forward()
还能将一组参数原封不动地传递给另一个函数,包括参数的属性(左值/右值和const
/非const
),这个过程叫完美转发。
提到移动语义和完美转发,就不得不谈及右值引用。右值引用的基本形式是T&&
,这需要和模板编程中的万能引用T&&
有所区别。在模板编程中,当传递不同属性的参数到T&&
中时进行的类型推导需要遵循引用折叠原则。
本节内容将就移动语义、万能引用、引用折叠和完美转发一一展开。
本系列文章将包括以下领域:
本章其他内容请见 《现代C++》
拷贝
在聊移动语义之前,需要借由右值引用的话题展开。而在介绍右值引用之前,还得先认识一下拷贝。
拷贝分为浅拷贝(shallow copy)和深拷贝(deep copy)。
浅拷贝,是指按位拷贝对象,创建的新对象有着原有对象属性值的一份精确拷贝,但不不包括指针指向的内存。
深拷贝,是指拷贝所有属性,包括属性指向的动态分配的内存。即当对象和它所引用的对象一起拷贝时就是深拷贝。
可以看出来,深拷贝的开销往往比浅拷贝要大,除非对象没有指向动态分配的内存,所以在拷贝时我们尽量执行浅拷贝。但是浅拷贝的缺点很明显,就是当有指向动态分配内存的属性时,会造成多个对象共用同一块动态内存,从而可能导致冲突。一个可行的办法是,每次浅拷贝后,必须保证原始对象不再访问这块内存,即转移所有权给新对象,这样就保证这块动态内存永远只被一个对象使用。
那么,什么对象在被拷贝之后可以保证不再访问这块内存呢?答案是临时对象。
再谈左值&右值,左值引用&右值引用
我们在《C++左值&右值,左值引用&右值引用》这篇文章中讲述过左值和右值、左值引用和右值引用的概念。
有了左值和右值的概念,我们就能认识到,临时对象其实可以作为右值。
我们再来回顾一下关于左值和右值、左值引用和右值引用的一些结论:
- 左值引用,使用
T&
,只能绑定左值- 右值引用,使用
T&&
,只能绑定右值- 常量左值,使用
const T&
,既可以绑定左值,又可以绑定右值- 具名的右值引用,本质上是一个左值
有了右值引用的概念,我们就能重载深浅拷贝函数,参数改用右值引用,就可以实现资源所有权的转移而不再使用深拷贝或浅拷贝了。实现资源所有权的转移,就是我们所谓的“移动语义”。由于临时对象可以作为右值,临时对象释放后就不再持有属性的所有权,因此相当于转移资源所有权的行为。
接下来后文会有以下一些表述,其实都是等价的:
左值引用接受一个左值,右值引用接受一个右值
左值引用匹配一个左值,右值引用匹配一个右值
左值引用通过左值来初始化,右值引用通过右值来初始化
好了,熟悉了这些概念之后,我们就可以引出移动语义了。
移动语义
移动语义指的是,我们可以从右值中直接拿数据来初始化或修改左值,⽽不需要重新构造一个临时变量后再析构该临时变量。简而言之,就是解决各种情况下的资源所有权的转移问题。
C++11提供了标准库函数std::move()
,无条件地将原本的左值强制转换为右值,以匹配右值引用类型的参数或变量,从而方便应对移动语义。
std::move
函数原型如下:
1template<typename _Tp>
2constexpr typename std::remove_reference<_Tp>::type&&
3move(_Tp&& __t) noexcept
4{
5 return static_cast<typename std::remove_reference<_Tp>::type&&>(__t);
6}
其中remove_reference<T>
将T
中的引用移除,再将t
转换为目标类型,即移除了引用后的T
再加上&&
,保证是一个右值。
另外注意到参数列表中形参t
的类型是声明为T&&
,这里并不是右值引用,而是万能引用!在模板中涉及到类型推导的T&&
都是万能引用,而不是右值引用。类型推导的过程有需要用到引用折叠原则。这里我们先按住不表,后面的内容继续展开。
重新审视一下右值和右值引用的关系:
1void foo(A& o) {
2 std::cout << "foo(A&)" << std::endl;
3}
4
5void foo(A&& o) {
6 std::cout << "foo(A&&)" << std::endl;
7}
8
9A a;
10A&& b = A();
11
12foo(a); //foo(A&)
13foo(std::move(a)); //foo(A&&)
14foo(b); //foo(A&) -> 注意这里
15foo(std::move(b)); //foo(A&&)
从这个例子可以看出,b
虽然声明为右值引用类型,但因为具有名字,编译器认为这是一个左值,这是我们前面回顾的结论4。
所以,右值引用类型只是匹配右值,但并不代表右值引用就是一个右值,如果右值引用被赋给了一个具名变量,那么它就是一个左值。尽量不要声明右值引用类型的变量,而是只应该把右值引用用在函数中作为形参以匹配右值。
当我们把右值引用作为形参写到构造函数中,那么这个构造函数就叫移动构造函数;同理写到重载的赋值操作符上,这个赋值操作就叫移动赋值操作符。如果要匹配调用动构造函数或移动赋值操作符,就需要使用std::move()
强制把左值转换为右值,才可能匹配上移动构造函数或移动赋值操作符,否则左值只能匹配拷贝构造函数或拷贝赋值操作符。
当类中同时包含拷贝构造函数和移动构造函数时,如果使用临时对象初始化当前类的对象,编译器会优先调用移动构造函数来完成此操作。
只有当类中没有合适的移动构造函数时,编译器才会退而求其次,调用拷贝构造函数。此时std::move()
会失效但不会发生错误,编译器找不到匹配的移动版本,就会去匹配拷贝版本,因此最终调用的是拷贝构造函数或拷贝赋值操作符。这也是拷贝构造函数和拷贝赋值操作符的参数经常是const T&
的原因(这是我们前面回顾的结论3)。
C++11的所有容器都实现了移动语义,避免了对含义资源的对象发生无谓的拷贝。std::move()
对于拥有内存、文件句柄等资源的成员的对象有效,但对基本类型,如int
和char[10]
等无效,使用std::move()
依然发生拷贝,因为没有对应的移动构造函数。
我们来看下面这个例子,讨论一下在函数返回值中,std::move()
函数是否起到优化作用:
1//当没有定义移动构造函数时
2class B {
3public:
4 B() { std::cout << "B::B()" << std::endl; }
5 B(const B&) { std::cout << "B::B(const&)" << std::endl; }
6};
7
8B bar() {
9 B b;
10 return b;
11}
12
13B baz() {
14 B b;
15 return std::move(b);
16}
17
18B&& qux() {
19 B b;
20 return std::move(b);
21}
22
23int main() {
24 B b1 = bar();
25 std::cout << "------------" << std::endl;
26 B b2 = baz();
27 std::cout << "------------" << std::endl;
28 B b3 = qux();
29 return 0;
30}
在默认编译条件的情况下,编译器开启了返回值优化,会有RVO和NRVO的优化机制存在。bar()
中虽然直接返回一个对象,直觉上应该是经历了拷贝构造,但是实际运行结果为:
1B::B()
2------------
3B::B()
4B::B(const&)
5------------
6B::B()
7B::B(const&)
结果表明bar()
并没有经历拷贝构造,而是被编译器优化掉了,全程只经历了一次构造。而baz()
中,由于没有移动构造函数,调用std::move()
之后通过右值构造对象,编译器会选择使用拷贝构造函数进行。baz()
也同理。
当我们关闭编译器优化,即在gcc编译指令中加上编译选项-fno-elide-constructors
,即
1g++ -std=c++11 -g main.cpp -o main -fno-elide-constructors
编译后运行结果为:
1B::B() #<-- B b;
2B::B(const&) #<--- return b;
3B::B(const&) #B b1 = bar();
4------------
5B::B() #<-- B b;
6B::B(const&) #<-- return std::move(b);
7B::B(const&) #<-- B b2 = baz();
8------------
9B::B() #<-- B b;
10B::B(const&) #<-- B b3 = qux();
可以看到,这种结果才符合我们的直觉。注意最后一个函数的调用,其函数的返回值就是右值引用,所以return std::move(b);
是没有开销的,只是返回main()
函数后把右值引用赋值给了一个变量,才产生了一次拷贝构造。但是函数返回右值引用在编译时已经报了警告warning: reference to local variable ‘b’ returned [-Wreturn-local-addr]
,其实这种写法是危险的,因为局部变量释放后,函数返回值仍持有它的右值引用。
当我们定义了移动构造函数之后,看看有什么不同。
1//当定义移动构造函数时
2class B {
3public:
4 B() { std::cout << "B::B()" << std::endl; }
5 B(const B&) { std::cout << "B::B(const&)" << std::endl; }
6 B(B&&) { std::cout << "B::B(B&&)" << std::endl; }
7};
8...
编译器开启优化的返回结果:
1B::B()
2------------
3B::B()
4B::B(B&&)
5------------
6B::B()
7B::B(B&&)
编译器关闭优化的返回结果:
1B::B()
2B::B(B&&)
3B::B(B&&)
4------------
5B::B()
6B::B(B&&)
7B::B(B&&)
8------------
9B::B()
10B::B(B&&)
从两个运行结果可以看出,原来调用拷贝构造的地方全部变成了移动构造。
- 结论:
-
我们应该把右值引用类型用在函数参数定义上,包括普通函数、构造函数和赋值函数上。从源头上出发,在编写其它代码时会自然而然享受到了移动构造、移动赋值的优化效果。
-
函数返回值不要使用右值引用,除非有特殊用途。
-
多数编译器已经实现了RVO和NRVO,函数返回值非必要不要出现
return std::move(xx);
而是直接return xx;
,编译器会自动优化掉拷贝的过程,否则反而会增加一次移动构造或拷贝构造的调用。 -
从优化的角度上看,若形参是支持移动构造或移动赋值的类型,应提供左值引用和右值引用的重载版本。移动开销很低时,只提供一个非引用类型的版本也是可以接受的。
关于RVO和NRVO,我们会在这篇文章中讨论。
万能引用
接下来的话题都是关于模板的内容了。
当表示形式&&
与模板相结合时,并不一定表示右值引用,它既可能是个左值引用,又可能是个右值引用。当发生类型推导时(如模板和auto
),&&
是一个万能引用(Universal Reference),否则才是右值引用。这里的&&
是一个未定义的引用类型,它必须被初始化,当它被一个左值初始化时它就是一个左值引用,当它被一个右值初始化时它就是一个右值引用。
换句话说,编写模板函数时,只提供万能引用形参版本就够了,不必写多个重载版本:
1template<typename T>
2void foo(T&& t) { } //这里&&需要类型推导,T&&是一个万能引用类型
3
4template<typename T>
5class Test {
6public:
7 Test(Test&&); //这里&&不涉及类型推导,Test&&是一个右值引用类型
8};
9
10template<typename T>
11void bar(std::vector<T>&& param); //调用这个函数前,vector<T>的类型推导已经完成,调用这个函数时不再需要类型推导,所以是右值引用,况且不是推导T&&的类型
12
13template<typename T>
14void baz(const T&& param); //任何对T&&的修饰,都不再是万能引用,所以是右值引用
要注意,万能引用仅发生在T&&
下,任何修饰都会使之失效。
在发生万能引用的类型推导时,如果T
被推导为string
,那么T&&
就是string&&
,是个右值引用;如果T
被推导为string&
,那么T&&
就是string& &&
,又是什么呢?如果T
被推导成string&&
,那么T&&
就是string&& &&
,又是什么呢?
接下来就是要引用折叠规则上场了。
引用折叠
C++不允许对引用再进行引用,为了让模板参数正确传递引用性质,C++定义了一套用于推导类型的引用折叠(Reference Collapse)规则,所有的折叠引用最终都代表一个引用,要么是左值引用,要么是右值引用。
- T& & <=> T&
- T&& & <=> T&
- T& && <=> T&
- T&& && <=> T&&
简单来说,所有的右值引用叠加到右值引用上仍然使一个右值引用,所有的其他引用类型之间的叠加都将变成左值引用。
完美转发
当我们使用万能引用时,既可以匹配左值,又可以匹配右值。但是当需要转发参数给其他函数时,就会引起引用性质的丢失。因为在函数中,形参是个左值(具命变量是左值),因此无法判断原始传入的实参是左值还是右值。
当然我们可以不使用万能引用,而是分开定义左值引用版本和右值引用版本,但这失去我们使用万能引用的初衷:
1template<typename T>
2void foo(T& t) {
3 bar(t);
4}
5
6template<typename T>
7void foo(T&& t) {
8 bar(std::move(t));
9}
所谓转发,就是通过一个函数将参数继续转交给另一个函数进行处理,原参数可能是右值,可能是左值,如果还能继续保持参数的原有特征,那么它就是完美的。
完美转发,Perfect Forwarding,就能够帮助解决在模板函数中转发参数时,可以保持传入参数原有的性质和状态。如果形参推导出是右值引用,则作为右值引用传入新函数;如果形参推导出是左值引用,则作为左值引用传入新函数。
C++11提供了标准库函数std:forward<T>
来配合万能引用,从而实现完美转发。
1template<typename _Tp>
2constexpr _Tp&& forward(typename std::remove_reference<_Tp>::type& __t) noexcept
3{
4 return static_cast<_Tp&&>(__t);
5}
6
7template<typename _Tp>
8constexpr _Tp&& forward(typename std::remove_reference<_Tp>::type&& __t) noexcept
9{
10 static_assert(!std::is_lvalue_reference<_Tp>::value, "template argument"
11 " substituting _Tp is an lvalue reference type");
12 return static_cast<_Tp&&>(__t);
13}
std::forward
分别针对左值引用和右值引用两种参数进行了函数定义,参数通过remove_reference<_Tp>
移除了所有引用性质得到一个纯类型,姑且叫T2
,再加上&
或&&
,明确地声明了形参分别是左值引用T2&
和右值引用T2&&
,两个函数分别可以接收左值和右值。然后强制转化为万能引用static_cast<_Tp&&>(__t)
,根据引用折叠规则,左值引用加上&&
最终推导为左值引用,右值引用加上&&
最终推导为右值引用。通过这一技巧,有效地保持了原来参数的引用性质:参数是左值引用,返回的还是左值引用;参数是右值引用,返回的就是右值引用。因此实现了完美的转发。
所以上述例子可以直接这样写:
1template<typename T>
2void f(T&& t) {
3 bar(std::forwrad<T>(t));
4}
不使用std::forward<T>()
时,无论t
被推导为左值引用还是右值引用,因为形参有了名字,都会被视为左值。
我们不妨回头对比一下std::move()
的实现:
1template<typename _Tp>
2constexpr typename std::remove_reference<_Tp>::type&&
3move(_Tp&& __t) noexcept
4{
5 return static_cast<typename std::remove_reference<_Tp>::type&&>(__t);
6}
在这里,形参t
是一个万能引用类型的左值,先执行remove_reference<T>
移除了所有引用性质得到一个纯类型,姑且叫T2
。然后再return static_cast<T2&&>(t)
,这样一来保证不会发生引用折叠,而是直接作为右值引用类型进行返回。
总结
再次梳理一下本文的思路:
-
我们从浅拷贝和深拷贝出发,引出了临时对象的概念。
-
通过临时对象的性质区分了左值和右值,再引出左值引用和右值引用。
-
移动语义的实现和右值引用息息相关,通过
std::move()
标准库函数强制将左值转为右值,来辅助实现移动语义。移动语义的目的是实现资源所有权的转移。 -
右值引用表达形式为
T&&
,容易与万能引用搞混。万能引用只发生在T&&
需要进行类型推导的情况下,既可以推导为左值引用,又可以推导为右值引用。 -
万能引用的类型推导过程,需要借助引用折叠规则,才可以最终确定推导的结果是左值引用,还是右值引用。
-
当万能引用作为函数参数,且需要转发参数给其他函数时,会引起引用性质的丢失。需要通过
std::forward()
标准库函数来实现完美转发,保持参数的引用性质。
每个环节息息相关,建议按照顺序阅读理解。