第三章 语言运行期的强化——右值引用

3.3 右值引用

我感觉,右值引用绝对是现代 C++ 中最重要的特性。解决了很多历史遗留问题。

右值引用消除了诸如 std::vector、std::string 等容器额外的开销,也使得函数对象容器 std::function 成为了可能。

左值、右值、纯右值、将亡值

我们先详细区分一些现代 C++ 中的各种值

左值

英文是 lvalue,表示的是存储在内存中、有明确存储地址(可寻址)的数据。

右值

英文是 rvalue,表示的是那些可以提供数据值的数据(不一定可以寻址,例如存储于寄存器中的数据)。

C++11 中为了引入强大的右值引用,将右值的概念进行了进一步的划分,分为纯右值、将亡值。

纯右值

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

将亡值

英文是 xvalue,C++11 引入右值引用而提出的概念。表示即将被销毁、却能够被移动的值。

C++之父对于值的理解

B神原话:

For all the values, there were only two independent properties:

“has identity” – i.e. an address, a pointer, the user can determine whether two copies are identical, etc.

“can be moved from” – i.e. we are allowed to leave to source of a “copy” in some indeterminate, but valid state.
There are four possible composition:

iM: has identity and cannot be moved from (defined as lvalue)
im: has identity and can be moved from (defined as xvalue)
Im: does not have identity and can be moved from (defined as prvalue)
IM: doesn’t have identity and cannot be moved (他认为这种情况在 C++ 中是没有用的)

大概意思就是,C++ 中的值有两个属性,一个是身份,一个是可移动。

身份:能够确定某个表达式是否和另一个表达式指涉同一个实体,比如指针,名称之类的东西。

可移动:能够被移动构造函数、移动赋值操作符或者其它实现移动语义的重载函数绑定。

lvalue:所谓的左值,有身份,不能被移动

xvalue:所谓的将亡值,有身份,可移动

pvalue:所谓的纯右值,没有身份,可移动

另一种情况大神认为在 C++ 中没有意义。

如果想要学习更多的关于 C++ 值的知识,可以参考网站:cppreference.com

右值引用和左值引用

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

C++11 提供了 std::move 这个方法讲左值参数无条件的转换为右值。如下代码:

#include <iostream>
#include <functional>
#include <string>

using namespace std;

void reference(string &str)
{
    cout << "lvalue" << endl;
}

void reference(string &&str)
{
    cout << "rvalue" << endl;
}

int main()
{
    string lv1 = "string,";  //lv1 是一个 lvalue
    //string&& r1 = lv1;  //非法,rvalue 引用不能引用 lvalue
    string &&rv1 = move(lv1);  //合法,std::move 可以将 lvalue 转移为 rvalue
    cout << rv1 << endl;  //string

    const string &lv2 = lv1 + lv1;  //合法,常量 lvalue 引用能够延长临时变量的生命周期
    //lv2 += "Test";  //非法,常量引用无法被修改
    cout << lv2 << endl;  //string,string

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

    reference(rv2);  //输出 lvalue
    return 0;
}

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

这里有一个比较有意思的事,先看下面代码:

#include <iostream>

using namespace std;

int main()
{
    //int &a = move(1);  //不合法,非常量 lvalue 引用只能绑定到 lvalue
    //这里说一下,move(1) 和 1 是一样的,就是说 move() 对 rvalue 操作没有效果
    const int &b = 1;  //合法,常量 lvalue 引用允许绑定 rvalue
    cout << b << endl;

    return 0;
}

书上写的没理解上去,下面说一下个人看法。

我的理解,引用是一段内存地址的别名。

第一个问题,非常量 lvalue 引用不能绑定 rvalue。我的看法是,从内存的角度,1 和 move(1) 都是在常量区的(这里关于 C++ 的分区一直各有各的说法),总之就是一段不可能更改的内存。对这段内存的所有更改操作都应该不合法。

按照我的理解,第二个问题:常量 lvalue 引用允许绑定 rvalue 就好解释了。常量引用本身就不可以修改,绑定到一个 rvalue 没有问题。

还有,书上的这段代码是跑不过的,代码如下:

void increase(int &v)
{
    v++;
}

void foo()
{
    double s = 1;
    int t = 1;
    increase(s);  //不合法

}

按照我的理解,不是像书上说的那样会诞生一个所谓的临时值保存 s 的值,而是直接报错。原因就是 int 的内存规则和 double 的内存规则不一样。没有办法用 int 内存的别名去操作 double 的内存。

关于这块的东西,完全是个人理解,和书上的角度完全不一样。如果有人有更好的理解,欢迎评论区里讨论。我也能白嫖一下。

移动语义

传统 C++ 中,为了实现资源的移动操作,必须先复制、再析构,否则就要自己实现移动的接口。因为这一点,C++ 被诟病了许多年。

传统 C++ 没有区分“移动”和“拷贝”的概念,造成了大量的数据拷贝,浪费时间和空间。右值引用完美的解决了这个问题。如下代码:

#include <iostream>

using namespace std;

class A
{
public:
    A() : pointer(new int(1))
    {
        cout << "构造" << pointer << endl;
    }

    A(A &a) : pointer(new int(*a.pointer))
    {
        cout << "拷贝" << pointer << endl;
    }  //无意义的对象拷贝

    A(A &&a) : pointer(a.pointer)
    {
        a.pointer = nullptr;
        cout << "移动" << pointer << endl;
    }

    ~A()
    {
        cout << "析构" << pointer << endl;
        delete pointer;
    }

    int *pointer;
};

//防止编译器优化,保证一定是一个 xvalue
A return_rvalue(bool test)
{
    A a, b;
    if (test)
        return a;
    else
        return b;
}

int main()
{
    A obj = return_rvalue(false);  //这里 obj 通过移动构造而来,不需要拷贝一份数据
    cout << "obj:" << endl;
    cout << obj.pointer << endl;
    cout << *obj.pointer << endl;  //因为是移动而来,所有数据的地址和原来是一样的
}
构造006CD240  //a 的构造
构造006C6E30  //b 的构造
移动006C6E30  //通过移动构造生成 obj
析构00000000  //b 被析构,注意,这时 b 的数据已经被移动到 obj
析构006CD240  //a 被析构
obj:
006C6E30  //obj 的数据,从 b 移动而来,所以和原来一样
1
析构006C6E30  //obj 的析构

说一下上面代码的思路:

1.首先会在 return_rvalue 内部构造两个 A 对象,于是获得两个构造函数的输出。return_rvalue 函数保证了一定会得到一个 xvalue。传入的 bool 没有实际意义,只是为了展示代码。

2.函数返回后,产生一个 xvalue,被 A 的移动构造引用,从而延长了生命周期,并将这个 xvalue 中的指针拿到,保存到了 obj 中,而 xvalue 的指针被设置为 nullptr,防止这块内存被销毁。

上面代码,相比于传统 C++,避免了毫无意义的拷贝构造,提升了性能。再来看看涉及标准库的例子,代码如下:

#include <iostream>
#include <utility>
#include <vector>
#include <string>

using namespace std;

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

    v.push_back(str);  //会产生拷贝行为
    cout << "str:" << str << endl;  //输出 str:Hello world.

    v.push_back(move(str));  //会产生移动行为,str 的值会变空
    cout << "str:" << str << endl;  //输出 str:,因为

    return 0;
}

完美转发

前面我们说了,一个声明的右值引用,实际上还是一个左值。这就为我们进行参数传递造成了问题。如下代码:

#include <iostream>
#include <utility>

using namespace std;

void reference(int &v)
{
    cout << "lvalue" << endl;
}

void reference(int &&v)
{
    cout << "rvalue" << endl;
}

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

int main()
{
    cout << "传递 rvalue" << endl;
    pass(1);  //1 是右值,但是输出是左值

    cout << "传递 lvalue" << endl;
    int s = 1;
    pass(s);  //l 是左值,输出是右值

    return 0;
}

对于 pass(1) 来说,虽然传递是一个 rvalue,但是由于 v 是一个引用,所以同时也是左值。因此 reference(v) 会调用 reference(int &)。而对于 pass(s) 而言,s 是一个左值,为什么也会成功传给 pass(T &&) 呢?

这是因为”引用坍缩规则“(书上这么叫的,但是也有的地方叫做引用折叠),反正不管他名字叫什么东西了(国人很愿意高一些奇奇怪怪的名称),规则是一样的。

传统 C++ 中,不能对一个引用继续引用。但是,rvalue 引用的出现,让我们可以进行一种所谓的”引用的引用“。但是需要遵循以下规则:

函数形参类型实参参数类型推到后函数形参类型
T&lvalue 引用T&
T&rvalue 引用T&
T&&lvalue 引用T&
T&&rvalue 引用T&&

也就是说,只有当形参和实参都是 rvalue 引用时,才会推导为 rvalue 引用类型。

由于 rvalue 引用的定义实际上也是一个 lvalue,我们不能直接传递。所以出现了完美转发。

完美转发,就是让我们在传递参数的时候,保持原来的参数类型(lvalue 引用保持 lvalue 引用,rvalue 引用保持 rvalue 引用)。我们可以使用 std::forward 来进行参数的传递。代码如下:

#include <iostream>
#include <utility>

using namespace std;

void reference(int &v)
{
    cout << "lvalue 引用" << endl;
}

void reference(int &&v)
{
    cout << "rvalue 引用" << endl;
}

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

int main()
{
    cout << "传递 rvalue:" << endl;
    pass(1);

    cout << "********************" << endl;

    cout << "传递 lvalue:" << endl;
    int v = 1;
    pass(v);

    return 0;
}

无论传递参数是 lvalue 还是 rvalue,普通传参都会都会将参数作为 lvalue
转发。就是说,pass 函数体中所有的 v 变量全是 lvalue。所以 std::move 一定是接受了一个 lvalue,一定是调用了 reference(int &&) 输出 rvalue 引用。

唯独 std::forward 既没有多余的拷贝,同时完美转发了函数的实参。也就是说 lvalue 还是 lvalue,rvalue 还是 rvalue。

从结果上来看,std::forward<T>(v) 和 static_cast<T&&>(v) 是完全一样的。

书上还给我们展示了一波源码,代码如下:

template <typename _Tp>
constexpr _Tp&& forward(typename std::remove_reference<_Tp>::type &__t) noexcept
{
    return static_cast<_Tp &&>(__t);
}

template <typename _Tp>
constexpr _Tp&& forward(typename std::remove_reference<_Tp>::type &__t) noexcept
{
    static_assert(!std::is_lvalue_reference<_Tp>::value, "template argument" "substituting _Tp is an lvalue reference type");
    return static_cast<_Tp &&>(__t);
} 

以下内容为书中原话:

在这份实现中,std::remove_reference 的功能是消除类型中的引用,而 std::is_lvalue_reference 用于检查类型推导是否正确,在 std::forward 的第二个实现中检查了接收到的值确实是一个 lvalue,进而体现了坍缩规则。

当 std::forward 接受 lvalue 时,_Tp 被推导为左值,所以返回值为 lvalue;而当其接受 rvalue 为右值时,_Tp 被推导为 rvalue 引用,则基于坍缩规则,返回值便成为了 && + && 的 rvalue。可见 std::forward 的原理在于巧妙的利用了模板类型推导过程中产生的差异。

这时我们能回答这样的一个问题:为什么在使用循环语句的过程中,auto&& 时最安全的方式?因为当 auto 被推导为不同的 lvalue 和 rvalue 时,与 && 的坍缩组合是完美转发。

总结

这块的东西写的比较多,也比较深。因为这里涉及到性能上的提升,个人感觉只要是在 C++ 中涉及到性能上的提升,就应该深入一点。

作为一门系统及编程语言,性能应该是首选。这也是 C++ 比其他的编程语言的强大之处。但是,这也为初学者产生了更多的学习代价,所以说 C++ 是一门可以学习一生的语言。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值