查看原文
其他

C++之右值引用

思想觉悟 思想觉悟 2022-10-09

有了左值引用为什么还需要右值引用?

在平时编码过程为了减少数据的拷贝,提高性能,我们一般通过引用的方式来传递参数,例如:

void func(const int &a){

}

int main() {
    func(10); // 可以
    int a = 20;
    func(a); // 可以
    return 0;
}

如果在上面的程序中我们将函数func中的const修饰去掉之后呢?我们发现调用func(10);居然无法通过了,这是为什么呢?在C++中带const修饰的引用成为常量左值引用,常量左值引用是可以绑定右值的,如果去掉了const修饰的话,就不是常量左值引用了,就不能绑定右值了,func(10);也就编译是失败了,因为在不带const修饰的函数func中表明引用a是可以修改的,但是func(10);传递进去的10确实不可修改的,这就产生了矛盾,也就无法通过编译了。

面对这种情况,右值引用的作用就发挥出来了。右值引用是 C++11 引入的与 Lambda 表达式齐名的重要特性之一。它的引入解决了 C++ 中大量的历史遗留问题,消除了诸如 std::vector、std::string 之类的额外开销。

什么是右值

那么什么是右值呢?按照我们以往的编程经验,一般认为位于等号左边的值就是左值,位于等号右边的值就是右值,然而真相并不是这样的。

int a = 10;
int b = a;

例如在异常的代码中如果认为位于表达式左边的值就是左值,位于表达式右边的值就是右值的话,那么在第一行代码中变量a是左值,在第二行代码中变量a却变成了右值, 显然这是矛盾的。那么到底该如何区分左值和右值呢?在C++ Primer中对左值和右值的归纳为:

当一个对象被用作右值的时候,用的是对象的值(内容);当对象被用作左值的时候,用的是对象的身份(在内存中的位置)。

大概的意思就是说左值就有内存地址的,存活的生命周期较长的,而右值一般是无法获取到内存地址的,生命周期是短暂的。还是以以上的代码为例子, 变量a和变量b都是可以通过取地址符号&获取到具体的内存地址的,所以变量a和变量b都是左值,而10,是一个普通的字面量,是不可以通过取地址符号&获取到具体的内存地址的,所以10就是右值。但是普通的字面量是右值,这有一个例外就是字符串字面量不是一个右值,比如"hello world",这个字符串,我们是可以通过取地址符号&获取到地址的。

右值怎么用

在语法上右值就是在左值的基础上增加了一个符号&,也就是使用&&表示右值引用:

void func(int &a){
    // 左值引用
}

void fuc(int &&a){
    // 右值引用
}

int main() {
    int && a = 10; // 右值引用
    return 0;
}

右值引用的特点之一是可以延长右值的生命周期,比如以下程序:

int getX(){
    return 10;
}

int main() {
    int &a = getX();// 错误
    const int &aa = getX();// 正确,常量左值引用
    int && b = getX(); // 右值引用
    return 0;
}

在上面的程序中我们通过右值引用b延长了函数getX返回值的生命周期。延长临时对象生命周期并不是这里右值引用的最终目标,其真实目标应该是减少对象复制,提升程序性能。

main.cpp

main.cpp

using namespace std;
class A {
public:
    A() {
        cout << "构造函数" << endl;
    }

    A(const A &a) {
        cout << "拷贝构造函数" << endl;
    }

    ~A() {
        cout << "析构函数" << endl;
    }
};

getA() {
    return A();
}

int main() {
    A a1 = getA();
    cout << "-----------------------------------" << endl;
    A &&a2 = getA(); // 右值引用,在关闭RVO情况下减少了一次拷贝
    return 0;
}

以上程序在C++11基础上关闭RVO优化,使用命令g++ main.cpp -std=c++11 -o main -fno-elide-constructors进行编译后,执行main可执行文件发现如下输出:

构造函数
拷贝构造函数
析构函数
拷贝构造函数
析构函数
-----------------------------------
构造函数
拷贝构造函数
析构函数
析构函数
析构函数

通过对比发现确实使用右值引用比普通的引用减少了一次拷贝。到这里可能会有人说有了RVO优化就行了,还需要右值引用干嘛?其实这仅仅是举例说明右值引用的一个小小的场景而已,其用处远不止于此。

移动构造

对于拷贝构造函数而言形参是一个左值引用,而不能是某些函数返回的临时对象,而且在拷贝构造函数中往往进行的是深复制,即一般不会破坏实参对象。而移动构造函数恰恰相反,它接受的是一个右值,其核心思想是通过转移实参对象的数据以达成构造目标对象的目的, 也就是移动构造函数会修改实参对象,一般来说调用了移动构造函数之后,实参对象的相关变量资源就会被转移,原本实参的变量就会被置空,也就是实参就不能再使用了, 因此与其叫做移动构造函数不如叫做窃取构造函数更加的贴切。

那么在什么情况会发生移动构造的调用呢?比如在C++11的STL容器中,会根据具体情况自动调用移动构造函数,比如以下例子:

#include <iostream>
#include <vector>
using namespace std;
class A {
public:
    A(string s):name(new string(s)) {
        cout << "构造函数" << endl;
    }

    A(const A &a):name(new string(*a.name)) {
        cout << "拷贝构造函数" << endl;
    }

    A(A &&a){
        name = a.name;
        a.name = nullptr; // 置空
        cout << "移动构造函数" << endl;
    }

    ~A() {
        cout << "析构函数" << endl;
        delete name;
        name = nullptr;
    }

public:
    string *name;
};

int main() {
    vector<A> vector;
    vector.push_back(A("world")); // 移动构造函数,如果有的话,如果没有则调用的是拷贝构造函数
    return 0;
}

处理在STL容器中可能会自动调用移动构造函数外,我们也可以手动通过std::move或者类型转换static_cast手动地调用移动构造函数,例如针对以上的类A:

int main() {
    A a("hello");
    A b = std::move(a); // 如果没有移动构造函数则会自动调用拷贝构造函数
    A c = static_cast<A&&>(b); // 如果没有移动构造函数则会自动调用拷贝构造函数
    return 0;
}

注意:和拷贝构造函数对于拷贝赋值运算符一样,移动构造函数也对于这一个移动赋值运算符,因为在移动语义中一般会置空实参的相关变量,所以需要注意在移动赋值运算符避免自己赋值给自己的情况

左值、右值、将亡值

在C++11中因为右值引用的出现,对值的类型进行了更具体的划分,在上图中,左值和纯右值我们比较好理解,左值正如上面所说的那样,一般有内存地址的,存活的生命周期较长的,就是左值,而像整型1,浮点型1.1这种的无法通过取地址符号获取到 具体的内存地址的一般就是右值。令人眼花的是这个将亡值,它既可以代表一个左值,又可以代表一个右值,这是怎么的一回事呢?

万能引用和折叠引用

所谓的万能引用就是既可以引用左值,也可以引用右值的引用。例如:

// 右值引用,有明确的类型
}

void test(int &){
// 左值引用
}

template<typename T>
void test(T &&){
// 万能引用,因为模板需要类型推导
}

int getNum(){
return 20;
}

int main() {
int &&num1 = getNum(); // 右值引用
auto &&num2 = getNum(); // 万能引用,类型推导
return 0;
}

在上面的注释中我们发现只要发生了类型推导就会是万能引用,在T&&和auto&&的初始化过程中都会发生类型的推导所以它们是万能引用。在这个推导过程中,初始化的源对象如果是一个左值,则目标对象会推导出左值引用;反之如果源对象是一个右值,则会推导出右值引用。

在C++11中有一套引用叠加推导的规则叫做引用折叠,通过这套规则,我们可以推导出万能引用的最终类型是什么,如下图:

从图中可以看出在推导过程中,只有实际类型是一个非引用类型或者右值引用类型时,最后推导出来的才是一个右值引用,其余的推导结果都是左值引用。

完美转发

上面介绍了万能引用,它的一个重要用途就是进行完美转发,所谓完美转发指的是函数模板可以将自己的参数“完美”地转发给内部调用的其它函数,不仅能准确地转发参数的值,还能保证被转发参数的左、右值属性不变。在C++11使用标准库中的std::forward函数就可以试下完美转发:

void test(int &t){
    // 左值引用
    cout << "左值" << endl;
}

void test(int &&t){
    // 右引用
    cout << "右值" << endl;
}

template<typename T>
void funcForward(T &&t){
    // 进行了转发,根据传递进来的值类型而调用不同test
    test(std::forward<T>(t));
}

template<typename T>
void funcNormal(T &&t){
    // 没有进行转发,始终调用的都是左值的test
    test(t);
}

int main() {
    int a = 20;
    funcNormal(1); // 右值,但是调用的是左值的test
    funcNormal(a); // 左值
    cout << "----------------------" << endl;
    funcForward(1); // 右值
    funcForward(a); // 左值
    return 0;
}

输出:

左值
左值
----------------------
右值
左值

在上面的例子中我们发现,函数funcNormal无论传递进去的左值还是右值最终调用的都是左值的test函数,而函数funcForward会根据传递的的实参类型是左值还是右值调用不同的test函数, 这就是完美转发的威力所在。

推荐阅读

《C++之指针扫盲》
《C++之智能指针》
《C++之指针与引用》

关注我,一起进步,人生不止coding!!!


您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存