右值引用

1.左值、右值的纯右值、将亡值、右值

左值 (lvalue, left value),顾名思义就是赋值符号左边的值。准确来说,左值是表达式(不一定是赋值表达式)后依然存在的持久对象。

右值 (rvalue, right value),右边的值,是指表达式结束后就不再存在的临时对象。
而 C++11 中为了引入强大的右值引用,将右值的概念进行了进一步的划分,分为:纯右值、将亡值。

纯右值 (prvalue, pure rvalue),纯粹的右值,要么是纯粹的字面量,例如 10, true;要么是求值结果相当于字面量或匿名临时对象,例如 1+2。非引用返回的临时变量、运算表达式产生的临时变量、原始字面量、 Lambda 表达式都属于纯右值。

​ 需要注意的是,字符串字面量只有在类中才是右值,当其位于普通函数中是左值。例如:

class Foo {
    const char *&&right = "this is a rvalue"; // 此处字符串字面量为右值
public:
    void bar() {
        right = "still rvalue"; // 此处字符串字面量为右值
    }
};

int main() {
    const char *const &left = "this is an lvalue"; // 此处字符串字面量为左值
}

将亡值 (xvalue, expiring value),是 C++11 为了引入右值引用而提出的概念(因此在传统 C++中,纯右值和右值是同一个概念),也就是即将被销毁、却能够被移动的值。
​ 将亡值可能稍有些难以理解,我们来看这样的代码:

std::vector<int> foo() {
    std::vector<int> temp = {1, 2, 3, 4};
    return temp;
}
    
std::vector<int> v = foo();

在这样的代码中,就传统的理解而言,函数 foo 的返回值 temp 在内部创建然后被赋值给 v,然而 v获得这个对象时,会将整个 temp 拷贝一份,然后把 temp 销毁,如果这个 temp 非常大,这将造成大量额外的开销(这也就是传统 C++ 一直被诟病的问题)。在最后一行中, v 是左值、 foo() 返回的值就是右值(也是纯右值)。但是, v 可以被别的变量捕获到,而 foo() 产生的那个返回值作为一个临时值,一旦被 v 复制后,将立即被销毁,无法获取、也不能修改。而将亡值就定义了这样一种行为:临时的值能
够被识别、同时又能够被移动。
在 C++11 之后,编译器为我们做了一些工作,此处的左值 temp 会被进行此隐式右值转换,等价于static_cast<std::vector<int> &&>(temp),进而此处的 v 会将 foo 局部返回的值进行移动。也就是后面我们将会提到的移动语义。

简单点理解:

int b = 1;
int c = 2;
int a = a + b;

C++中有一个被广泛认同的说法,那就是可以取地址的、有名字的就是左值,反之,不能取地址的、没有名字的就是右值。那么这个加法赋值表达式中,&a是允许的操作,但&(b + c)这样的操作则不会通过编译。因此a是一个左值,(b + c)是一个右值。

相对于左值,右值表示字面常量、表达式、函数的非引用返回值等。

2.右值引用和左值引用

​ 左值引用是对一个左值进行引用的类型,右值引用则是对一个右值进行引用的类型。

​ 左值引用和右值引用都是属于引用类型。无论是声明一个左值引用还是右值引用,都必须立即进行初始化。而其原因可以理解为是引用类型本身自己并不拥有所绑定对象的内存,只是该对象的一个别名。

左值引用是具名变量值的别名,而右值引用则是不具名(匿名)变量的别名。

​ 左值引用:

int &a = 2;       // 左值引用绑定到右值,编译失败, err
int b = 2;        // 非常量左值
const int &c = b; // 常量左值引用绑定到非常量左值,编译通过, ok
const int d = 2;  // 常量左值
const int &e = c; // 常量左值引用绑定到常量左值,编译通过, ok
const int &b = 2; // 常量左值引用绑定到右值,编程通过, ok

​ “const 类型 &”为 “万能”的引用类型,它可以接受非常量左值、常量左值、右值对其进行初始化;

​ 右值引用,使用&&表示:

int && r1 = 22;
int x = 5;
int y = 8;
int && r2 = x + y;
T && a = ReturnRvalue();

通常情况下,右值引用是不能够绑定到任何的左值的。

int c;
int && d = c; //err

​ 要拿到一个将亡值,就需要用到右值引用: T &&,其中 T 是类型。右值引用的声明让这个临时值的生命周期得以延长、只要变量还活着,那么将亡值将继续存活。

#include <bits/stdc++.h>

void reference(std::string &str) {
    std::cout << " left value" << std::endl;
}

void reference(std::string &&str) {
    std::cout << " right value" << std::endl;
}

int main() {
    std::string lv1 = "string,"; // lv1 是一个左值
    // std::string&& r1 = lv1; // 非法, 右值引用不能引用左值
    
    const std::string &lv2 = lv1 + lv1; // 合法, 常量左值引用能够延长临时变量的生命周期
    // lv2 += "Test"; // 非法, 常量引用无法被修改
    std::cout << lv2 << std::endl; // string,string,

    std::string &&rv2 = lv1 + lv2; // 合法, 右值引用延长临时对象生命周期
    rv2 += "Test"; // 合法, 非常量引用能够修改临时变量
    std::cout << rv2 << std::endl; // string,string,string,Test

    reference(rv2); // 输出左值

    return 0;
}

rv2 虽然引用了一个右值,但由于它是一个引用,所以 rv2 依然是一个左值。

3.移动语义

3.1 为什么需要移动语义

​ 传统 C++ 通过拷贝构造函数和赋值操作符为类对象设计了拷贝/复制的概念,但为了实现对资源的移动操作,调用者必须使用先复制、再析构的方式,否则就需要自己实现移动对象的接口。试想,搬家的时候是把家里的东西直接搬到新家去,而不是将所有东西复制一份(重买)再放到新家、再把原来的东西全部扔掉(销毁),这是非常反人类的一件事情。
​ 传统的 C++ 没有区分『移动』和『拷贝』的概念,造成了大量的数据拷贝,浪费时间和空间。右值引用的出现恰好就解决了这两个概念的混淆问题 。

​ 右值引用是用来支持转移语义的。转移语义可以将资源 ( 堆,系统对象等 ) 从一个对象转移到另一个对象,这样能够减少不必要的临时对象的创建、拷贝以及销毁,能够大幅度提高

​ C++ 应用程序的性能。临时对象的维护 ( 创建和销毁 ) 对性能有严重影响。

转移语义是和拷贝语义相对的,可以类比文件的剪切与拷贝,当我们将文件从一个目录拷贝到另一个目录时,速度比剪切慢很多。

通过转移语义,临时对象中的资源能够转移其它的对象里。

3.2 移动语义定义

​ 在现有的 C++ 机制中,我们可以定义拷贝构造函数和赋值函数。要实现转移语义,需要定义转移构造函数,还可以定义转移赋值操作符。对于右值的拷贝和赋值会调用转移构造函数和转移赋值操作符。

​ 如果转移构造函数和转移拷贝操作符没有定义,那么就遵循现有的机制,拷贝构造函数和赋值操作符会被调用。

​ 普通的函数和操作符也可以利用右值引用操作符实现转移语义。

3.3 移动构造函数

#include <bits/stdc++.h>

using namespace std;

class MyString {
public:
    MyString(const char *tmp = "abc") {//普通构造函数
        len = strlen(tmp);  //长度
        str = new char[len + 1]; //堆区申请空间
        strcpy(str, tmp); //拷贝内容

        cout << "普通构造函数 str = " << str << endl;
    }

    MyString(const MyString &tmp) {//拷贝构造函数
        len = tmp.len;
        str = new char[len + 1];
        strcpy(str, tmp.str);

        cout << "拷贝构造函数 tmp.str = " << tmp.str << endl;
    }

    //移动构造函数
    //参数是非const的右值引用
    MyString(MyString &&t) {
        str = t.str; //拷贝地址,没有重新申请内存
        len = t.len;

        //原来指针置空
        t.str = nullptr;
        cout << "移动构造函数" << endl;
    }

    MyString &operator=(const MyString &tmp) {//赋值运算符重载函数
        if (&tmp == this) {
            return *this;
        }

        //先释放原来的内存
        len = 0;
        delete[]str;

        //重新申请内容
        len = tmp.len;
        str = new char[len + 1];
        strcpy(str, tmp.str);

        cout << "赋值运算符重载函数 tmp.str = " << tmp.str << endl;

        return *this;

    }

    ~MyString() {//析构函数
        cout << "析构函数: ";
        if (str != nullptr) {
            cout << "已操作delete, str =  " << str;
            delete[]str;
            str = nullptr;
            len = 0;

        }
        cout << endl;
    }

private:
    char *str = nullptr;
    int len = 0;
};

MyString func() //返回普通对象,不是引用
{
    MyString obj("mike");

    return obj;
}

int main() {
    MyString &&tmp = func(); //右值引用接收

    return 0;
}

和拷贝构造函数类似,有几点需要注意:

  • 参数(右值)的符号必须是右值引用符号,即“&&”。
  • 参数(右值)不可以是常量,因为我们需要修改右值。
  • 参数(右值)的资源链接和标记必须修改,否则,右值的析构函数就会释放资源,转移到新对象的资源也就无效了。

​ 有了右值引用和转移语义,我们在设计和实现类时,对于需要动态申请大量资源的类,应该设计转移构造函数和转移赋值函数,以提高应用程序的效率。

3.4 转移赋值函数

#include <bits/stdc++.h>

using namespace std;

class MyString {
public:
    MyString(const char *tmp = "abc") {//普通构造函数
        len = strlen(tmp);  //长度
        str = new char[len + 1]; //堆区申请空间
        strcpy(str, tmp); //拷贝内容

        cout << "普通构造函数 str = " << str << endl;
    }

    MyString(const MyString &tmp) {//拷贝构造函数
        len = tmp.len;
        str = new char[len + 1];
        strcpy(str, tmp.str);

        cout << "拷贝构造函数 tmp.str = " << tmp.str << endl;
    }

    //移动构造函数
    //参数是非const的右值引用
    MyString(MyString &&t) {
        str = t.str; //拷贝地址,没有重新申请内存
        len = t.len;

        //原来指针置空
        t.str = NULL;
        cout << "移动构造函数" << endl;
    }

    MyString &operator=(const MyString &tmp) {//赋值运算符重载函数
        if (&tmp == this) {
            return *this;
        }

        //先释放原来的内存
        len = 0;
        delete[]str;

        //重新申请内容
        len = tmp.len;
        str = new char[len + 1];
        strcpy(str, tmp.str);

        cout << "赋值运算符重载函数 tmp.str = " << tmp.str << endl;

        return *this;

    }

    //移动赋值函数
    //参数为非const的右值引用
    MyString &operator=(MyString &&tmp) {
        if (&tmp == this) {
            return *this;
        }

        //先释放原来的内存
        len = 0;
        delete[]str;

        //无需重新申请堆区空间
        len = tmp.len;
        str = tmp.str; //地址赋值
        tmp.str = NULL;

        cout << "移动赋值函数\n";

        return *this;
    }

    ~MyString() {//析构函数
        cout << "析构函数: ";
        if (str != NULL) {
            cout << "已操作delete, str =  " << str;
            delete[]str;
            str = NULL;
            len = 0;

        }
        cout << endl;
    }

private:
    char *str = NULL;
    int len = 0;
};

MyString func() //返回普通对象,不是引用
{
    MyString obj("mike");

    return obj;
}

int main() {
    MyString tmp("abc"); //实例化一个对象
    tmp = func();

    return 0;
}

4 .标准库函数 std::move

​ 既然编译器只对右值引用才能调用转移构造函数和转移赋值函数,而所有命名对象都只能是左值引用,如果已知一个命名对象不再被使用而想对它调用转移构造函数和转移赋值函数,也就是把一个左值引用当做右值引用来使用,怎么做呢?标准库提供了函数 std::move,这个函数以非常简单的方式将左值引用转换为右值引用。

int a;
int &&r1 = a;                     // 编译失败
int &&r2 = std::move(a);          // 编译通过
#include <bits/stdc++.h>

using namespace std;

int main() {
   std::string lv1 = "string,"; // lv1 是一个左值
   // std::string&& r1 = lv1; // 非法, 右值引用不能引用左值
   std::string&& rv1 = std::move(lv1); // 合法, std::move 可以将左值转移为右值
   std::cout << rv1 << std::endl; // string,
   return 0;
}
#include <bits/stdc++.h>

using namespace std;

int main() {
    std::string str = "Hello world.";
    std::vector<std::string> v;

    // 将使用 push_back(const T&), 即产生拷贝行为
    v.push_back(str);
    // 将输出 "str: Hello world."
    std::cout << "str: " << str << std::endl;
    
    // 将使用 push_back(const T&&), 不会出现拷贝行为
    // 而整个字符串会被移动到 vector 中,所以有时候 std::move 会用来减少拷贝出现的开销
    // 这步操作后, str 中的值会变为空
    v.push_back(std::move(str));
    // 将输出 "str: "
    std::cout << "str: " << str << std::endl;
    return 0;
}

5. 完美转发 std::forward

​ 完美转发适用于这样的场景:需要将一组参数原封不动的传递给另一个函数。

“原封不动”不仅仅是参数的值不变,在 C++ 中,除了参数值之外,还有一下两组属性:左值/右值和 const/non-const。完美转发就是在参数传递过程中,所有这些属性和参数值都不能改变,同时,而不产生额外的开销,就好像转发者不存在一样。在泛型函数中,这样的需求非常普遍。

​ 前面我们提到了,一个声明的右值引用其实是一个左值。这就为我们进行参数转发(传递)造成了问题:

#include <bits/stdc++.h>

void reference(int &v) {
    std::cout << " 左值" << std::endl;
}

void reference(int &&v) {
    std::cout << " 右值" << std::endl;
}

template<typename T>
void pass(T &&v) {
    std::cout << " 普通传参:";
    reference(v); // 始终调用 reference(int&)
}

int main() {
    std::cout << " 传递右值:" << std::endl;
    pass(1); // 1 是右值, 但输出是左值
    std::cout << " 传递左值:" << std::endl;
    int l = 1;
    pass(l); // l 是左值, 输出左值
    return 0;
}

​ 对于 pass(1) 来说,虽然传递的是右值,但由于 v 是一个引用,所以同时也是左值。因此reference(v) 会调用 reference(int&),输出『左值』。而对于 pass(l) 而言, l 是一个左值,为什么会成功传递给 pass(T&&) 呢?
​ 这是基于引用坍缩规则的:在传统 C++ 中,我们不能够对一个引用类型继续进行引用,但 C++ 由于右值引用的出现而放宽了这一做法,从而产生了引用坍缩规则,允许我们对引用进行引用,既能左引用,又能右引用。但是却遵循如下规则:

函数形参类型 实参参数类型 推导后函数形参类型
T& 左引用 T&
T& 右引用 T&
T&& 左引用 T&
T&& 右引用 T&&

一旦定义中出现了左值引用,引用折叠总是优先将其折叠为左值引用

typedef const int T;
typedef T & TR;
TR &v = 1; //在C++11中,一旦出现了这样的表达式,就会发生引用折叠,即将复杂的未知表达式折叠为已知的简单表达式

​ 因此,模板函数中使用 T&& 不一定能进行右值引用,当传入左值时,此函数的引用将被推导为左值。更准确的讲, 无论模板参数是什么类型的引用,当且仅当实参类型为右引用时,模板参数才能被推导为右引用类型。这才使得 v 作为左值的成功传递。
​ 完美转发就是基于上述规律产生的。所谓完美转发,就是为了让我们在传递参数的时候,保持原来的参数类型(左引用保持左引用,右引用保持右引用)。为了解决这个问题,我们应该使用 std::forward来进行参数的转发(传递):

#include <bits/stdc++.h>

void reference(int &v) {
    std::cout << "左值引用" << std::endl;
}

void reference(int &&v) {
    std::cout << "右值引用" << std::endl;
}

template<typename T>
void pass(T &&v) {
    std::cout << "普通传参: ";
    reference(v);
    std::cout << "std::move 传参: ";
    reference(std::move(v));
    std::cout << "std::forward 传参: ";
    reference(std::forward<T>(v));
    std::cout << "static_cast<T&&> 传参: ";
    reference(static_cast<T &&>(v));
}

int main() {
    std::cout << "传递右值:" << std::endl;
    pass(1);
    std::cout << "传递左值:" << std::endl;
    int v = 1;
    pass(v);
    return 0;
}

输出结果:

传递右值:
普通传参: 左值引用
std::move 传参: 右值引用
std::forward 传参: 右值引用
static_cast<T&&> 传参: 右值引用
传递左值:
普通传参: 左值引用
std::move 传参: 右值引用
std::forward 传参: 左值引用
static_cast<T&&> 传参: 左值引用

​ 无论传递参数为左值还是右值,普通传参都会将参数作为左值进行转发,所以 std::move 总会接受到一个左值,从而转发调用了 reference(int&&) 输出右值引用。
​ 唯独 std::forward 即没有造成任何多余的拷贝,同时完美转发 (传递) 了函数的实参给了内部调用的其他函数。
​ std::forward 和 std::move 一样,没有做任何事情, std::move 单纯的将左值转化为右值,std::forward 也只是单纯的将参数做了一个类型的转换,从现象上来看, std::forward(v) 和static_cast<T&&>(v) 是完全一样的。

上一篇:Go基础语法(一)


下一篇:C++中的友元关系