[原创]C++基础系列—右值引用

既然有了左值引用,看似一切都很美好了。为什么还需要右值引用这个东西呢?引入左值引用,解决了运算符重载的问题;C++11引入右值引用,则是为了追求极致性能

左值( lvalues ) 右值( rvalues )

为了了解右值引用,我们再次明确一下,左值和右值的概念。 Lvalue Persist;Rvalue Are Ephemeral《C++ Primier》,更简单的概念就是:左值可获取存储地址;右值不可获取地址,只能获取值本身。详细判断左值右值的地址:Value categories

左值引用

左值引用,只能绑定左值,注意下面例子最后一个,1是右值没错,但其实它可以分解为注释后的两句代码,因此也就能理解,它并没有超脱左值引用的定义

int foo = 1;        //foo is lvalue, 1 is rvalue
int* bar = foo;     //foo is lvalue , bar is lvalue
int* baz = 1;       //error: 1 is rvalue
const int* qux = 1; // const int temp = 1, const int* qux = temp;

右值引用

右值引用,只能绑定右值(也就是绑定的是 生命周期在下一行就会结束的临时值)

int foo = 1;
int&& baz = foo;      //error: foo is lvalue
int&& qux = 1;        //1 is rvalue
int&& quux = foo * 1; //(foo * 1) is rvalue
int&& quuz = foo++;   //foo++ return a rvalue

右值引用它能绑定临时值,本来在下一行之前就会”消失”的数据,我们能再次利用,最重要的一点:可以任意用它而不用担心会影响其它地方;可能还是一头雾水,我们通过一个例子来展示一下:

#include <iostream>
#include <vector>
using namespace std;

class Foo
{
public:

    Foo() { data = nullptr; }

    Foo(const char* str)
    {
        initdata(str,"构造");
    }

    Foo(const Foo& foo)
    {
        initdata(foo.data, "拷贝构造");
    }

    Foo& operator=(const Foo& foo)
    {
        initdata(foo.data, "拷贝赋值");
        return *this;
    }

    ~Foo()
    {
        if (data != nullptr) {  free(data); }
    }

private:

    void initdata(const char* str, const char*  des)
    {
        data = new char[strlen(str) + 1];
        memcpy(this->data, str, strlen(str));
        data[strlen(str)] = '\0';
        cout << des << data << endl;
    }

    char* data;
};

int main()
{
    Foo foo = Foo("hello");
    Foo bar = foo;

    vector<Foo> vec;
    vec.push_back(Foo("world"));
}

打印结果:
构造hello
拷贝赋值hello
构造world
拷贝构造world

每一次都有 new char[] 操作,相当于有4次内存申请操作,我们在Foo中插入以下代码;再输出:

Foo(Foo&& str)
{
    cout << "移动构造" << str.data << endl;
    data = str.data;
    str.data = nullptr;
}

Foo& operator=(Foo&& str)
{
    cout << "移动拷贝赋值" << str.data << endl;
    if (this != &str) {
        this->data = str.data;
        str.data = nullptr;
    }
    return *this;
}

打印结果:
构造hello
移动拷贝赋值hello
构造world
移动构造world

虽然也有4条输出,但实际 new char[] 只执行了2次。移动拷贝赋值 和 移动构造 里面没有 new char[] 操作,只是指针重定向。你可能会有疑问:你内部实现方式都不一样,那肯定不需要 new char[] 啊 ?很高兴,你已经Get到点了,那为什么可以直接重定向而不需要 new char[] ?

因为它的参数是右值引用。请翻到上文加粗部分,右值引用它绑定的是临时值,它不会被其它任何地方的代码引用,因此可以放心将它的 data 指向自己而不用担心会牵扯其它地方。

此刻你是不是灵机一动?想到了右值引用的核心了:就是告诉编译器,这个”值”已经没人疼没人爱,你带走随便操作吧。那是不是我可以把我认为后面我不会再用到的值,直接转成右值引用呢?是的,可以。上个示例,main函数改成如下:

int main()
{
    Foo foo = Foo("hello");             //第一行
    Foo bar = static_cast<Foo&&>(foo);  //第二行
    return 0;                           //第三行
}

在第三行断点,你可已发现,foo内的 data 值已经变为NULL,hello 从foo “转移”到了 bar 。这就引出了经常讨论的 move semantics (移动语义)

move semantics(移动语义)

其实移动语义,就是上文main函数的第二行,更改如下:

int main()
{
    Foo foo = Foo("hello");  //第一行
    Foo bar = std::move(foo);//第二行
    return 0;                //第三行
}

std::move(foo) 等于 static_cast<foo&&>(foo) ,就是将左值强转为右值引用。就是告诉编译器,这个左值绑定的值,左值再也不对它负责。因此,第二行以后就不应该再使用 foo 做一些操作,可能导致崩溃

那既然这样,为什么还要std::move() 语义?直接 static_cast<T&&>() 不就好了吗?两点原因:

  • std::move() 语法更简洁,方便代码理解
  • std::move() 不需要显示书写类型(T&&)

注意:右值引用本身是左值,这是啥意思呢?代码如下:

void func(Foo&& foo)
{
    //foo do something
}

int main()
{
    Foo foo =  Foo("hello");
    Foo bar = std::move(foo);
    func(bar); //error     //第四行
    func(Foo("hello"));    //第五行
    func(std::move(foo));  //第六行
    func(std::move(bar));  //第七行
    return 0;
}

bar 变量本身是左值,可以取地址 (虽然他绑定的是一个右值引用) ,因此第四行的代码编译报错。move 除了使用在移动构造,还可以用于智能指针。而且 std::swap就是通过move实现的,伪代码如下:

template<typename T>
void swap(T& a,T& b) noexcept
{
    T temp = std::move(a);
    a = std::move(b);
    b = std::move(temp);
}

至此,我们已经知晓左值引用,右值引用,而且语义明确,不会有任何歧义。但是在C++中,有个核弹——模板,它让情况变复杂了

万能引用

一个方法的形参,类型要么是左值引用,要么是右值引用

void func(Foo& foo) {}
void func(Foo&& foo){}

有没有同时接受左值引用和右值引用的方法呢?有的,万能的模板可以实现你的愿望

template<typename T>
void func(T&& param) 
{
    cout << param << endl;
}

int main() 
{
    int foo= 1;
    func(foo); //foo的类型:int&;(因为func只接受引用)
    func(1); //数值1的类型:int&&
    return 0;
}

是不是很神奇,将 T 替换成我们传入的类型,那么函数实际参数类型如下:

void func(int& && param) 
{
    cout << param << endl;
}
void func(int&& && param) 
{
    cout << param << endl;
}

“& &&” 和 “&& &&” 是不存在的类型,如下定义全都会报错:

(int&)&& foo;
int& &&  bar;
int&&&   baz;

那 T&& 为啥可以呢?因为C++ 11 定义了折叠引用规则

引用折叠(Universal Collapse)

func(foo) 传入的 T 推导类型:int&,因此实际类型是:int& &&,这是个不存在的类型,因此 C++ 标准强行规定了一个规则:

如果任一引用为左值引用,则结果为左值引用;否则结果为右值引用。 有点不好理解,我换个说法,左值引用是 0 ,右值引用时 1,折叠规则就是:左值引用 and 右值引用(这里的and是算数操作符 与 ),因此:

int& && = 0 and 1 = int&
int&& & = 1 and 0 = int&
int& & = 0 and 0 = int&
int&& && = 1 and 1= int&&

这也是为什么万能引用可以既接受左值引用又可以接受右值引用的原因。但是请注意折叠引用仅适用于编译器自己推出来的类型,手动定义的类型编译器不会遵循折叠引用,因此上文的定义 (int&)&& foo; int& && bar; int&&& baz; 都会报错

既然万能引用技能接受左值引用,又能接受右值引用。那有个问题,如果我func里面要调用其它方法,将 param 当作参数传给它,那么它的类型是不是就不对了呢?

完美转发(Perfect Forwarding)

template<typename T>
void bar(T& param) 
{
    cout << "param is lvalue" << endl;
}

template<typename T>
void bar(T&& param)
{
    cout << "param is rvalue" << endl;
}

template<typename T>
void foo(T&& param)
{
   bar(param);
}

int main() 
{
    int baz = 1;
    foo(baz);
    foo(1);
    return 0;
}
//Consloe:
//param is lvalue
//param is lvalue

结果都是左值,为什么呢?因为 param 相当于一个变量,它绑定的是传进来的T&&类型的值(无论这个值折叠以后是什么类型,变量总是左值)。这显然和我们的期望相违背,那怎么办呢?再强转一次呗

template<typename T>
void foo(T&& param)
{
    bar(static_cast<T&&>(param)); //替换bar(param);
}

将 param 强转回 T&& 类型,再传给 bar 函数,编译器就能准确找到对应的函数。同样,static_cast<T&&>(),也有对应的简洁写法,std::forward<T>()

总结

C++ 11引入右值引用,主要目的是为了提高性能(弥补 拷贝构造,拷贝赋值等操作必须进行内存申请等很消耗性能的操作),最大限度的利用了临时值。而移动语义,完美转发 的本质其实就是类型转换 static_cast<T&&>() ,并不神秘