C++11右值引用与完美转发

C++11右值引用与完美转发

C++11中右值引用常与完美转发相联系在一起。在详细深入之前,我们需要了解这样几条规律:

  • 1.”右值和左值”,”右值引用和左值引用”两组概念是独立的。具有右值引用型的变量可能是左值。凡是可以取地址的均为左值,凡是命名变量,均为左值。
    比如说:
    1
    2
    3
    4
    void g(int && a)
    {

    }

其中变量a虽然具有右值引用类型,但是在函数体内却是左值,有人会问,那这个右值引用有什么意义,意义就是外面在调用f的时候,必须传一个右值才能够匹配这个g,这个右值也告诉g函数内部,这个a是可以随时被强制类型转换到右值,或者move掉的。C++在实际调用g并传入a的时候,构造实参a的过程是在函数外部进行的,函数内部不再像按值调用一样需要重新初始化a,我们看到,下列代码中a只会被初始化一次,完整代码的运行结果如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <iostream>
using namespace std;
class A
{
public:
A(){
std::cout << "Constructor" << std::endl;
}

~A(){
std::cout << "Destructor" << std::endl;
}
};

void f(const A& a){
std::cout << "Calling LValue Version" << std::endl;
}

void f(A&& a){
std::cout << "Calling RValue Version" << std::endl;
}

void g(A&& e)
{
std::cout << "sentinel 3" << std::endl;
f(e);
}
int main()
{
A a;
std::cout << "sentinel 1" << std::endl;
g(std::move(a));
std::cout << "sentinel 2" << std::endl;
return 0;
}

// 运行结果
Constructor
sentinel 1
sentinel 3
Calling LValue Version
sentinel 2
Destructor

  • 2.万能引用必须涉及类型推导,带类型推导不一定是万能引用。
    带类型推导的方式有模板函数以及auto decltype组合等。例如下面的代码块是万能引用,而上面的代码块只能是右值引用。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    template <class A>
    void f(A&& a) // 万能引用
    {}

    void f1(const A&& a) // 右值引用
    {}

    template <class A>
    class TemplateClass{
    public:
    f(A&& a){} // 右值引用,因为实例化TemplateClass的时候类型已经确定,函数调用时无需进行类型推导。
    template <class B>
    f1(B&& b){} // 万能引用,因为函数调用时依然需要推导B的类型。
    };
  • 3.const T&可以接受任何实参,但是优先级不是最高的,如果存在同名的接受右值的形参,那么会优先调用右值形参的函数,否则会进行强制类型转换调用左值引用,const的存在使得即使原本是一个右值实参,也不会在函数内被改变,不会造成令调用者误解的情况,同时const的存在会延长右值的生命周期,使得至少在函数体内,右值是有效的。

  • 4.move()并不会引发实际的操作,比如下面的代码中,A a的构造析构都与move无关,move只是一种强制类型转换,或者说是一种语法糖,告诉调用者,”a现在可以被安全移走,后续不保证a是有效的”等信息。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    #include <iostream>
    using namespace std;
    class A
    {
    public:
    A(){
    std::cout << "Constructor" << std::endl;
    }

    ~A(){
    std::cout << "Destructor" << std::endl;
    }
    };

    void f(const A& a){
    std::cout << "Calling LValue Version" << std::endl;
    }

    // void f(A&& a){
    // std::cout << "Calling RValue Version" << std::endl;
    // }

    int main()
    {
    A a;
    std::cout << "sentinel 1" << std::endl;
    f(std::move(a));
    std::cout << "sentinel 2" << std::endl;
    return 0;
    }
    // 输出结果
    Constructor
    sentinel 1
    Calling LValue Version
    sentinel 2 Destructor

若最后一段稍微进行修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
int main()
{
std::cout << "sentinel 1" << std::endl;
f(A());
std::cout << "sentinel 2" << std::endl;
return 0;
}
// 输出结果
sentinel 1
Constructor
Calling LValue Version
Destructor
sentinel 2

模板的推导规则

带模板的引用参数推导规则:
要理解参数推导规则,我们先来看一下引用叠加规则,注意下面的T不一定是模板,而是实际的类:
1、T& + & = T&
2、T& + && = T&
3、T&& + & = T&
4、T或T&& + && = T&&

我们可以看到,只要存在一个&,那就按左值引用&来算,而只有只出现一个或者两个&&才是右值引用。当然,形参实参一个&都没有必然是传值调用了。于是,如果我们的函数形参定义为&&,那么不管传入的参数是啥,都能够保持它的引用特性。引用的折叠是编译器的特性,也就是编译器如果看到三个&&&,会直接用一个&来进行替代。引用的折叠只能够发生在类型需要推导的情况下,如果类型不需要推导而出现了多个引用符号那么编译器会直接报错,比如我们是无法定义int&&& i=0;这样的句子的,因为不涉及类型的推导,编译器不会自动将三个&换成一个。

我们将示例代码稍微进行修改,以下面这段代码为例分析一下带模板的引用参数推导规则:

1
2
3
4
5
6
7
8
9
10
11
12
void F(const Widget& a);
void F(Widget&& a);
template<class A>
void G(A &&a)
{
F(a);
}

Widget w;
G(w); // 传入左值,A被推导为Widget&(注意万能引用传入左值就变成左值引用), 左值,
// 调用的是void F(const Widget& a);
G(std::move(w)) // 传入右值,A被推导为Widget,a的类型是右值引用,但是a本身在函数G内是一个左值,因此调用的依然是void F(const Widget& a);

首先分析函数G引用参数的推导规则:
分为两种情况讨论(再次提醒,T是实际编程时候一个确定的类型,不是template):
1、若实参为T&,则模板参数A应被推导为引用类型T&。(由引用叠加规则第2点T& + && = T&和A&&=T&,可得出A=T&)唔,换个角度,这么理解, 传入T&,也就是A &&a = T& a, 只有当A = T&才能达成,因为T &&& = T&。

2、若实参为T&&,则模板参数A应被推导为非引用类型T。(由引用叠加规则第4点T或T&& + && = T&&和A&&=T&&,可得出A=T或T&&,强制规定A=T)同样,如果实参是T&&, 那么只有当A=T或者A=T&&才能做到,C++直接规定,这个时候,A=T而不是T&&.

这一切看起来都非常自然,然而,不能够忽略的是,上述函数G的函数体内,a实际上是一个左值,只是带有右值引用的型别,因此G(std::move(w))调用的f依然是void f(const A& a);,这实际上大部分时候会与调用者的需求不一致。

完美转发

为了解决这样的问题,需要有一种机制,使得调用实参的左右值特性能够被保留下来,于是就有了完美转发这样的概念。即std::forward<T>
将上述改为:

1
2
3
4
5
template<class A>
void G(A &&a)
{
F(std::forward<A>(a));
}

则能够将a的左右值特性和其他修饰完整转发到F函数中。和move不同的是,如果G接收的实参是左值,那么forward不会像move一样强制将实参转为右值。这是怎么做到的呢?上述例子中,如果G接收实参类型为T&, 那么A推导为T&,此时G实例化调用的是F(std::forward<T&>(a));如果实参类型是T&&, 那么A推导为T,此时G实例化调用的是F(std::forward<T>(a));,forward会执行强制类型转换,转为右值类型。也就是说,std::forward会自动对非引用类型进行强制类型转换到右值。一个非常简单实现的forward函数如下, 如果G被传入右值,那么T推导为A, 这时候static_cast param折叠后变成static_cast param, 是一个右值。如果G被传入左值,那么T被推导为A&, 这时候static_cast param折叠后变成static_cast param,是一个左值,不会被强制类型转换。

1
2
3
4
5
template<typename T>
T&& forward(typename remove_reference<T>::type& param)
{
return static_cast<T&&> param;
}

forward在不带模板推导的情况下是非常容易出错的,不带模板推导的情况下,forward常用错引用符号,将完美转发的结果搞反,所以如果明确知道要使用一个右值,应该用move而不是forward, forward主要是针对带有模板推导的参数转发。

0%