C++移动语义、万能引用、引用折叠、完美转发

Share on:
600 Words | Read in about 3 Min | View times

Overview

现代C++提供了移动语义相关的操作,通过移动语义可以将资源的所有权转移到新的对象上,从而避免数据拷贝带来的性能开销。移动语义实现了从一个对象到另一个对象的资源转移的过程。标准库为移动语义提供了辅助函数std::move(),用于执行一个无条件的对rvalue的转换,对于函数本身而言,并不移动任何内容,只是强制转化左值引用为右值引用,从而通过这个右值引用使用该值,以用于移动语义。与之类似的还有一个辅助函数std::forward(),它和std::move()类似,但只有在参数被一个lvalue初始化时,才会转换为rvaluestd::forward()还能将一组参数原封不动地传递给另一个函数,包括参数的属性(左值/右值和const/非const),这个过程叫完美转发。

提到移动语义和完美转发,就不得不谈及右值引用。右值引用的基本形式是T&&,这需要和模板编程中的万能引用T&&有所区别。在模板编程中,当传递不同属性的参数到T&&中时进行的类型推导需要遵循引用折叠原则。

本节内容将就移动语义、万能引用、引用折叠和完美转发一一展开。

本系列文章将包括以下领域:

本章其他内容请见 《现代C++》

拷贝

在聊移动语义之前,需要借由右值引用的话题展开。而在介绍右值引用之前,还得先认识一下拷贝。

拷贝分为浅拷贝(shallow copy)和深拷贝(deep copy)。

浅拷贝,是指按位拷贝对象,创建的新对象有着原有对象属性值的一份精确拷贝,但不不包括指针指向的内存。

深拷贝,是指拷贝所有属性,包括属性指向的动态分配的内存。即当对象和它所引用的对象一起拷贝时就是深拷贝。

可以看出来,深拷贝的开销往往比浅拷贝要大,除非对象没有指向动态分配的内存,所以在拷贝时我们尽量执行浅拷贝。但是浅拷贝的缺点很明显,就是当有指向动态分配内存的属性时,会造成多个对象共用同一块动态内存,从而可能导致冲突。一个可行的办法是,每次浅拷贝后,必须保证原始对象不再访问这块内存,即转移所有权给新对象,这样就保证这块动态内存永远只被一个对象使用。

那么,什么对象在被拷贝之后可以保证不再访问这块内存呢?答案是临时对象。

再谈左值&右值,左值引用&右值引用

我们在《C++左值&右值,左值引用&右值引用》这篇文章中讲述过左值和右值、左值引用和右值引用的概念。

有了左值和右值的概念,我们就能认识到,临时对象其实可以作为右值。

我们再来回顾一下关于左值和右值、左值引用和右值引用的一些结论:

  1. 左值引用,使用T&,只能绑定左值
  2. 右值引用,使用T&&,只能绑定右值
  3. 常量左值,使用const T&,既可以绑定左值,又可以绑定右值
  4. 具名的右值引用,本质上是一个左值

有了右值引用的概念,我们就能重载深浅拷贝函数,参数改用右值引用,就可以实现资源所有权的转移而不再使用深拷贝或浅拷贝了。实现资源所有权的转移,就是我们所谓的“移动语义”。由于临时对象可以作为右值,临时对象释放后就不再持有属性的所有权,因此相当于转移资源所有权的行为。

接下来后文会有以下一些表述,其实都是等价的:

左值引用接受一个左值,右值引用接受一个右值

左值引用匹配一个左值,右值引用匹配一个右值

左值引用通过左值来初始化,右值引用通过右值来初始化

好了,熟悉了这些概念之后,我们就可以引出移动语义了。

移动语义

移动语义指的是,我们可以从右值中直接拿数据来初始化或修改左值,⽽不需要重新构造一个临时变量后再析构该临时变量。简而言之,就是解决各种情况下的资源所有权的转移问题。

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()对于拥有内存、文件句柄等资源的成员的对象有效,但对基本类型,如intchar[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&&)

从两个运行结果可以看出,原来调用拷贝构造的地方全部变成了移动构造。

  • 结论:
  1. 我们应该把右值引用类型用在函数参数定义上,包括普通函数、构造函数和赋值函数上。从源头上出发,在编写其它代码时会自然而然享受到了移动构造、移动赋值的优化效果。

  2. 函数返回值不要使用右值引用,除非有特殊用途。

  3. 多数编译器已经实现了RVO和NRVO,函数返回值非必要不要出现return std::move(xx);而是直接return xx;,编译器会自动优化掉拷贝的过程,否则反而会增加一次移动构造或拷贝构造的调用。

  4. 从优化的角度上看,若形参是支持移动构造或移动赋值的类型,应提供左值引用和右值引用的重载版本。移动开销很低时,只提供一个非引用类型的版本也是可以接受的。

关于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)规则,所有的折叠引用最终都代表一个引用,要么是左值引用,要么是右值引用。

  1. T& & <=> T&
  2. T&& & <=> T&
  3. T& && <=> T&
  4. 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()标准库函数来实现完美转发,保持参数的引用性质。

每个环节息息相关,建议按照顺序阅读理解。

Prev Post: 『C++ NULL与nullptr』
Next Post: 『C++引用包装』