C++11的右值引用移动语义(std::move)和完美转发(std::forward)详解

Posted 彼方丶

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了C++11的右值引用移动语义(std::move)和完美转发(std::forward)详解相关的知识,希望对你有一定的参考价值。

1、源码准备

本文是基于gcc-4.9.0的源代码进行分析,右值引用相关内容是C++11才加入标准的,所以低版本的gcc源码是没有这些相关的内容的,建议选择4.9.0或更新的版本去学习,不同版本的gcc源码差异应该不小,但是原理和设计思想的一样的,下面给出源码下载地址
http://ftp.gnu.org/gnu/gcc

2、C++11右值引用概念

右值引用是C++11众多新特性中的一个,也是最重要的特性之一。很多初学者都感觉右值引用晦涩难懂,其实不然,右值引用只不过是一种新的C++语法,真正理解起来有难度的是基于右值引用延伸出的两种C++11编程技巧,即移动语义和完美转发。

2.1、左值和右值

右值引用可以从字面意思上理解,指的是以引用传递(而非值传递)的方式使用C++的右值。在C++或者C语言中,一个表达式(可以是字面量、变量、对象、函数的返回值等)根据其使用场景不同,分为左值表达式和右值表达式,确切来说C++中左值和右值的概念是从C语言继承过来的。

需要注意的是,左值的英文简写为lvalue,右值的英文简写为rvalue,很多人想当然地认为它们分别是left valueright value的缩写,其实这种说法是不正确的
lvalueloactor value的缩写,指的是存储在内存中、有明确存储地址(可寻址)的数据
rvalueread value的缩写,指的是那些可以提供数据值的数据(不一定可以寻址,例如存储于寄存器中的数据)

通常情况下,判断某个表达式是左值还是右值,常用的有以下两种方法:

  1. 可位于赋值号(=)左侧的表达式就是左值;反之,只能位于赋值号右侧的表达式就是右值。举个例子:
int i = 1;
1 = i; //错误,1不能作为左值

其中,变量i就是一个左值,而字面量1就是一个右值。值得一提的是,C++中的左值也可以当做右值使用,例如:

int j = 7; // j是一个左值
i = j;     // a、b都是左值,只不过可以将j当做右值使用
  1. 有名称的、可以获取到存储地址的表达式即为左值;反之则是右值。

以上面定义的变量ij为例,ij是变量名,且通过&i&j可以获得他们的存储地址,因此ij都是左值;反之,字面量17,它们既没有名称,也无法获取其存储地址(字面量通常存储在寄存器中,或者和代码存储在一起),因此17都是右值。

由于本文主要讲解右值引用相关知识,因此这里适可而止,不再对左值和右值作深度剖析,感兴趣的读者可自行查阅资料去了解更加具体的内容。

2.2、右值引用

其实早在C++98/03标准中就有引用了,使用"&"表示。但此种引用方式有一个缺陷,即正常情况下只能操作左值,无法对右值添加引用。举例如下:

int a = 10;
int& b = a;  //正确
int& c = 10; //错误

如上所示,编译器允许我们为a(左值)建立一个引用,但不可以为10(右值)建立引用。因此,C++98/03标准中的引用又称为左值引用。

需要注意的是,虽然C++98/03标准不支持为右值建立非常量左值引用,但允许使用常量左值引用操作右值。也就是说,常量左值引用既可以操作左值,也可以操作右值,例如:

int a = 10;
const int& b = a;  //正确
const int& c = 10; //正确

我们知道,右值往往是没有名称的,因此要使用它只能借助引用的方式。这就产生一个问题,实际开发中我们可能需要对右值进行修改(实现移动语义时就需要),显然上面这种引用的方式是行不通的。

为此,C++11标准新引入了另一种引用方式,称为右值引用,用&&表示。需要注意,和声明左值引用一样,右值引用也必须立即进行初始化操作,且只能使用右值进行初始化,比如:

int a = 10;
int&& a = num; //错误,右值引用不能初始化为左值
int&& a = 10;

和常量左值引用不同的是,右值引用还可以对右值进行修改。例如:

int&& a = 10;
a = 100;
std::cout << a << std::endl; // 输出100

另外值得一提的是,C++语法上是支持定义常量右值引用的,例如:

const int&& a = 10;//编译器不会报错

但这种定义出来的右值引用并无实际用处。一方面,右值引用主要用于移动语义和完美转发,其中前者需要有修改右值的权限;其次,常量右值引用的作用就是引用一个不可修改的右值,这项工作完全可以交给常量左值引用完成。

下面给出一张表方便大家记忆(Y 表示支持,N 表示不支持)
引用类型可以引用的值类型使用场景
非常量左值常量左值非常量右值常量右值
非常量左值引用YNNN一般场景均适用
常量左值引用YYYY常用于类中构建拷贝构造函数
非常量右值引用NNYN移动语义、完美转发
常量右值引用NNYY无实际用途

其实,C++11标准中对右值做了更细致的划分,分别称为纯右值(Pure value,简称 pvalue)和将亡值(eXpiring value,简称 xvalue )。其中纯右值就是C++98/03标准中的右值,而将亡值则指的是和右值引用相关的表达式(比如某函数返回的T&&类型的表达式)。对于纯右值和将亡值,都属于右值,读者知道即可,不必深究。

3、C++11的移动语义(std::move)和完美转发(std::forward)

3.1、移动语义(std::move)

C++11中的移动语义是通过std::move实现的,std::move并不移动任何东西,它唯一的功能就是将一个传入参数强制转化为右值引用,继而可以通过右值引用使用该值

std::move源代码如下:

template<typename _Tp>
constexpr typename std::remove_reference<_Tp>::type&&
move(_Tp&& __t) noexcept
{
    return static_cast<typename std::remove_reference<_Tp>::type&&>(__t);
}
  • 首先,函数参数T&&是一个指向模板参数类型的右值引用,通过引用折叠技术,此参数可以与任何类型的实参匹配(既可以传递左值也可以传递右值,这也是std::move主要使用的两种场景)

引用折叠技术:

形式一:T& &、T&& &、T& &&都折叠为T&,用于处理左值,例子如下:
std::string s = “haha”;
std::move(s) 展开为 std::move(std::string& &&),套用上面的规则,折叠之后为 std::move(std::string&)
此时整个std::move被实例化为如下形式:
std::string&& move(std::string& __t)
{ return static_cast<std::string&&>(__t); }

形式二:T&& &&折叠成T&&,用于处理右值,例子如下:
std::move(std::string(“hello”)) 展开为 std::move(std::string&& &&),套用上面的规则,折叠之后为 std::move(std::string&&)
此时整个std::move被实例化为如下形式:
std::string&& move(std::string&& __t)
{ return static_cast<std::string&&>(__t); }

  • 对于std::remove_reference的使用,我们看一下它的源代码(位于libstdc+±v3\\include\\std\\type_traits中),其实从std::remove_reference存在于type_traits文件这一点就可以大致推断出,std::remove_reference使用了模板元技术,模板元的主要思想为:利用模板特化机制实现编译期条件选择结构,利用递归模板实现编译期循环结构,模板元程序则由编译器在编译器解释运行,但是其也有明显的优缺点,优点是运行时速度极快,缺点是程序很难看懂,容易劝退初学者,这里不对其做深入分析,知道是这样一个东西就行,有兴趣的可以去查阅专业的C++书籍去了解其中的奥秘
    源代码如下,作用是将模板_Tp的引用属性分离(左值引用和右值引用都可以),这样的话使用::type就可以获得一个没有引用属性的类型了
template<typename _Tp>
struct remove_reference
{ typedef _Tp    type; };

template<typename _Tp>
struct remove_reference<_Tp&>
{ typedef _Tp    type; };

template<typename _Tp>
struct remove_reference<_Tp&&>
{ typedef _Tp    type; };

总结:std::move首先通过右值引用传递模板,引用折叠原理将右值经过T&&传递类型保持不变还是右值,而左值经过T&&变为普通的左值引用,以保证模板可以传递任意实参,且保持类型不变,然后通过static_cast进行强制类型转换返回T&&右值引用,而static_cast之所以能使用类型转换,是通过std::remove_refrence::type模板移除T&&T&的引用,获取具体原始类型T

3.2、完美转发(std::forward)

C++11中的完美转发是通过std::forward实现的,std::forward会将输入的参数原封不动的传递到下一个函数中,这个“原封不动”指的是,如果输入的参数是左值,那么传递给下一个函数的参数的也是左值;如果输入的参数是右值,那么传递给下一个函数的参数的也是右值。
一个经典的完美转发的场景如下:

template <class... Args>
void forward(Args&&... args)
{
    f(std::forward<Args>(args)...);
}

std::forward源代码如下:

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::forward时必须说明模板类型(std::forward无法自己推断出_TP的类型是什么)
  • 通过使用std::remove_reference去掉了_Tp的引用属性,以此来区分传入参数是左值引用还是右值引用
  • 如果std::forward接受的参数为左值的话,则_TP也是一个左值引用的类型,则调用的是第一个函数,将代码展开之后变为以下形式(假设T为随便一种类型),由前面3.1小节中提到的引用折叠技术我们可以得出,此时返回值是一个左值引用
T& && forward(typename std::remove_reference<T&>::type& __t) noexcept
{
    return static_cast<T& &&>(__t);
}
  • std::forward接受的参数为右值的话,则_TP也是一个右值引用的类型,则调用的是第而个函数,将代码展开之后变为以下形式(假设T为随便一种类型),由前面3.1小节中提到的引用折叠技术我们可以得出,此时返回值是一个右值引用
T&& forward(typename std::remove_reference<T&&>::type&& __t) noexcept
{
    return static_cast<T&& &&>(__t);
}
  • 这里需要注意一点,并不是对象被变成右值之后就不能再被使用了,右值引用只是区别于左值的另一种对对象的引用方式,只要不经过移动拷贝(赋值),都可以简单地理解为一次普通的引用,不改变原对象的生命周期,经过移动构造(拷贝)之后可以将右值对象的内容给左值,但是右值对象本身不会被析构,其原有的生命周期仍旧不会改变。

4、总结

本文通过几个简单的例子讲解了C++11右值引用的基本概念,从源代码的角度分析了基于右值引用延伸出的两种C++11编程技巧(移动语义和完美转发)

  • 移动语义(std::move)并不移动任何东西,它唯一的功能就是将一个传入参数转化为右值引用,这个转换是强制的,无条件的
  • 完美转发(std::forward)则是只有在它的参数绑定到一个右值上的时候,它才将传入参数转换为一个右值
  • 需要注意的是,在运行期间,std::movestd::forward没有做任何事情,他们没有产生需要在运行期执行的代码,一字节都没有

以上是关于C++11的右值引用移动语义(std::move)和完美转发(std::forward)详解的主要内容,如果未能解决你的问题,请参考以下文章

第15课 右值引用_std::move和移动语义

[转][c++11]我理解的右值引用移动语义和完美转发

强制转换为右值

重新理解C11的右值引用

关于右值和移动构造

静态转换为右值引用和 std::move 之间有啥区别吗