右值引用详解

问题

  1. 临时对象非必要的昂贵的拷贝操作
  2. 在模板函数中如何按照参数的实际类型进行转发
  • 关键字:右值、纯右值、将亡值、universal references、引用折叠、移动语义、move语义、完美转发
  • 以下用四条代码来阐述C++的右值引用及其思想

1. 第一行代码

1
int i = getVal();
  • 上式代码会产生一个左值和纯右值,右值是不具名的,判断左值和右值的办法就是看能否取地址
  • 在C++11中所有的值必属于左值、将亡值、纯右值三者之一。比如,非引用返回的临时变量、运算表达式产生的临时变量、原始字面量和lambda表达式等都是纯右值。而将亡值是C++11新增的、与右值引用相关的表达式,比如,将要被移动的对象、T&&函数返回值、std::move返回值和转换为T&&的类型的转换函数的返回值等。

2. 第二行代码

2.1 特点1:重获新生

1
2
3
4
5
T getVal(){
return T();
}
T k = getVal(); // F1
T&& k = getVal(); // F2
  • F1:调用一次默认构造、两次拷贝构造(一次函数内到函数外的临时值,一次临时值到k)
  • F2:调用一次默认构造、一次拷贝构造(一次函数内到函数外的临时值,并且临时值通过右值引用重获新生
  • 现代编译器进行了优化,可能仅仅调用一次默认构造,但这不是C++标准
  • 当然在C++98/03年代,为了相同的目的,可以用常量左值引用这种万能引用:const T& k = getVal();,也能达到减少一次拷贝构造的目的,但是k不能再改变了。

2.2 特点2:右值引用“二相性”

  • 右值引用独立于左值和右值,即,右值引用类型的变量可能是左值也可能是右值,例如:
    1
    int&& val = 1;
  • val类型为右值引用,但val本身是左值,所有具名变量都是左值
    1
    2
    3
    4
    5
    6
    7
    template<typename T>
    void f(T&& t){}

    f(10); // t是右值 T&& t = 10

    int x = 10;
    f(x); // t是左值 (T&)&& t = x,折叠后变为T& t = x;

2.3 特点3:通用引用(universal references)

  • T&& t在发生自动类型推断的时候,它是通用引用类型
  • 通用引用是需要初始化的,如果是左值,那就归为左值引用,如果是右值,那就归为右值引用。
    1
    2
    3
    4
    // 注意上述关键词:"发生自动类型推断"时
    int a = 1;
    auto&& b = a; // b类型为左值引用 (int&)&& b -> int& b = a
    auto&& c = 10; // c类型为右值引用 int&& b = 10

引用折叠

  1. 所有的右值引用叠加到右值引用上仍然还是一个右值引用
    • 类型 T&& && 折叠成 T&&
  2. 所有的其他引用类型之间的叠加都将变成左值引用
    • T& &、T& && 和 T&& & 都会折叠成类型 T&

3. 第三行代码

1
MyClass(MyClass&& a) : m_val(a.m_val) { a.m_val=nullptr; }
  • 即,移动构造函数(move constructor),采用浅拷贝的方式,因为某些临时变量如果没有移动构造函数,则会频繁发生拷贝构造(深拷贝),如果对象内部堆空间很大的话,代价会非常大
  • 这里介绍move语句,move语句会将一个左值变为一个右值类型。move(val)后,并不会对val本身做出改变。类似上面的代码,将move(val)交给一个构造函数或者一个赋值函数,那么会按照右值类型匹配对应的移动构造函数和移动赋值函数,在移动函数里会将val的资源指针交给别人,val自身资源指针指向nullptr,这个时候val才会发生改变(你在代码里move(val)一百次,但是不交给对应函数匹配处理,那么val就不会有一丁点的变化)。当然,这些操作别人已经实现好了,如果是你自己的类,要自己实现!

4. 第四行代码

1
2
3
4
template <typename T>
void f(T&& val){
foo(std::forward<T>(val));
}

4.1 C++11前,调用模板函数的问题

1
2
3
4
template <typename T>
void forwardValue(T&& val){
processValue(val); // 右值参数会变成左值传递给processValue
}
  • 因此引入了完美转发std::forward,他会按照参数的实际类型进行转发
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    void processValue(int& a){ cout << "lvalue" << endl; }
    void processValue(int&& a){ cout << "rvalue" << endl; }
    template <typename T>
    void forwardValue(T&& val){
    // 照参数本来的类型进行转发。
    processValue(std::forward<T>(val));
    }
    void Testdelcl(){
    int i = 0;
    forwardValue(i); // 传入左值 输出:lvaue
    forwardValue(0); // 传入右值 暑促和:rvalue
    }
  • T&&是一个通用引用(universal references),可以接受左值或者右值,正是这个特性让他适合作为一个参数的路由,然后再通过std::forward按照参数的实际类型去匹配对应的重载函数,最终实现完美转发。

4.2 进一步体会完美转发

4.2.1 问题

  • func函数接收的v2是引用,但是引用的却是middle里面的一个局部变量t2,而非main中的t2,应该怎么办?
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    template <typename F, typename T1, typename T2>
    void middle(F f, T1 t1, T2 t2) {
    f(t1, t2);
    }

    void func(int v1, int& v2) { // v2 是一个引用
    ++v1;
    ++v2;
    }

    int main(int argc, char* argv[]) {
    int i = 0;
    func(42, i);
    // here i = 1
    middle(func, 42, i);
    // here i = 1
    }

4.2.2 尝试

  • middlet1类型是int&&t2类型是int& && -> int&i的值也如预期般变化
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    template <typename F, typename T1, typename T2>
    void middle(F f, T1&& t1, T2&& t2) {
    f(t1, t2);
    }

    void func(int v1, int& v2) { // v2 是一个引用
    ++v1;
    ++v2;
    }

    int main(int argc, char* argv[]) {
    int i = 0;
    func(42, i);
    // here i = 1
    middle(func, 42, i);
    // here i = 2
    }
  • 虽然看似没毛病,但是我们改一下func,就会暴露问题,修改func如下:
    1
    2
    3
    4
    void func(int&& v1, int& v2) {
    ++v1;
    ++v2;
    }
  • 修改func后再用4.2.2的代码运行就会报错,提示:“无法将一个右值引用绑定到左值上”,why?因为,在main中42虽然是右值,传入到middlet1的类型也确实是int&&,但是t1本身作为具名变量,它是一个左值!而左值是无法与右值进行绑定的。

4.2.3 解决

1
2
3
4
template <typename F, typename T1, typename T2>
void middle(F f, T1&& t1, T2&& t2) {
f(std::forward<T1>(t1), std::forward<T2>(t2));
}
  • forward就是解决以上问题的关键
  • 42传入middle后绑定到t1,此时t1类型是int&&(其中T1int)。通过forward<T1>(t1) -> forward<int>(t1),将返回int&&成功传给func函数。这里你肯定有疑问,刚才不就是int&&?你绕一大圈子是不是耍人?其实不然,之前的那个啊叫named rvalue,有名字,传参时被当成左值!现在通过forward返回的是无名字的真正的右值,从而右值得到了保留!
  • i传入middle后绑定到t2,此时t2类型为int& &&(其中T2int&,why?因为T2如果是int,那么就变成int&& t2 = i,会导致右值绑定一个左值从而报错!)经过引用折叠变为int&.通过forward<T2>(t2) -> forward<int&>(t2),将返回一个int& &,折叠后变为int&,左值也得到了保留!

4.3 应用:泛型工厂函数

利用forward可以实现一个泛型的工厂函数,这个工厂函数可以创建所有类型的对象。具体实现如下:

1
2
3
4
template<typename…  Args>
T* Instance(Args&&… args){
return new T(std::forward<Args >(args)…);
}
- 这个工厂函数的参数是右值引用类型,内部使用std::forward按照参数的实际类型进行转发,如果参数的实际类型是右值,那么创建的时候会自动匹配移动构造,如果是左值则会匹配拷贝构造。

参考资料