右值、右值引用、万能引用与完美转发 - C++基础

右值引用是C++11标准提出的非常重要概念,正确使用可以提升执行效率。此前已经系统看过很多遍,可惜看一遍忘一遍。此次系统学习记录下来,供日后回忆。主要参考《现代C++语言核心特性解析》1和《Effective Modern C++》2

在C语言或者早期的C++可以从字面理解,在等式左边的值为左值,等式右边的值为右值。一个变量作为左值时,表示对变量地址的引用,即可以修改变量的值;一个变量作为右值时,表示变量的内容,即可以将变量的值赋给等号左边的左值。

而在现代C++11中,对左右值进行重新定义: 左值一般是指一个指向特定内存的具有名称的值(具名对象),它有一个相对稳定的内存地址,并且有一段较长的生命周期。而右值则是不指向稳定内存地址的匿名值(不具名对象),它的生命周期很短,通常是暂时性的。可以通过是否能够用取地址符&来区分左值和右值。所以C语言标准中所讲的左右值和C++11标准中所讲的概念并不完全一样

在C++11中有一些情况下左右值的判断可能会反直觉。

  1. 另一篇文章讨论的++i和i++的左右值问题: ++i则是左值,直接对i进行递增后返回自身。如果对它们进行取地址操作,可以发现++i的取地址编译通过,而i++不可以。

    c++

    int i = 0;
    int *p = &i++; // 编译失败
    int *q = &++i; // 编译成功
    
  2. 通常字面量都是右值,但是字符字面量却是左值。编译器会将字符字面量放入程序的.rodata只读数据段中,程序加载时会放入内存空间中,并且和程序有一样的生命周期,故可以用&取地址,字符字面量也算左值。

    c++

    auto p = &"hello world";
  3. 右值引用其本身是左值。右值引用可以被取地址,进一步说右值引用绑定右值会延长右值的生命周期。

  4. 使用类型转换将左值转换为该类型的右值引用,该左值将会被转换成右值。如下的例子,std::move和static_cast(Type&&)是一样的作用。会在后边的值类别详细介绍。

    c++

    int a = 1;
    std::cout << &a << std::endl;
    std::cout << &static_cast(int&&)(a) << std::endl; // 编译错误,右值不能取地址
    std::cout << &std::move(a) << std::endl;          // 编译错误,右值不能取地址,std::move同上作用一样
    

左值右值的分类在C++17标准中进行了清晰的定义,如下图,引入了多种值类别。这些类别概念比较偏学术,我们只需有个简单认识: 左值(lvalue)即上述我们所讨论的左值;右值(rvlaue)也是上述讨论的右值,但其又分为两种类别: 1. 纯右值(prvalue)即通常意义我们理解的右值,如字面量、临时变量等。2. 将亡值(xvalue)的新概念,下面详细讲述。

值类别
值类别

将亡值是右值的一种类型,产生将亡值有如下两种。

  1. 使用类型转换将值转换成该类型的右值引用,将产生将亡值(右值)。即上述第4点使用std::move和static_cast(Type&&)可以将左值转换成将亡值即右值。

  2. 在C++17标准中引入,又称为临时量实质化,每当纯右值出现在一个需要泛左值的地方时,临时量实质化都会发生。例如,如下代码。

    c++

    struct X {
      int a;
    };
    
    int main() {
      std::cout << X().a << std::endl;
    }

    由于X()是一个纯右值,访问其成员变量a需要一个泛左值,所以这里发生临时量实质化,将X()转化成将亡值,然后再访问其成员变量a。标准定义的该方式产生的将亡值,感觉是为了标准定义的统一,对于实际开发并无影响。

引用是C++中引入的新概念,C语言中没有。不同于指针,它在初始化时必须绑定一个对象,绑定后也无法绑定其它对象。C++中的引用语义是对象的别名,而java等语言的引用的语义更像是指针。

左值引用只能引用左值,而常量的左值引用不仅可以引用左值,也能引用右值,并且延长右值的生命周期,比如:

c++

int &x = 7; // 编译失败,左值引用不能引用右值
const int &y = 11; // 编译成功,常量的左值引用可以引用右值,并且延长其生命周期

声明一个函数的入参时,拷贝开销大则应该声明为常量的左值引用,比如:

c++

void handle(const std::string &str);

std::string s{"hello"};
handle(s); // 绑定左值
handle("world"); // 绑定右值

对于拷贝开销小于实现引用的开销,声明函数应该使用值拷贝而不是引用,此时使用引用会造成性能的下降而不是提高,比如:

c++

void handle(int str);

int a = 1;
handle(a); // 复制左值
handle(2); // 复制右值

右值引用只能引用右值,并且可以延长右值的生命周期。相比于常量的左值引用没有常量的限制。

c++

int val = 1;
int &&i = 1;              // 编译成功,字面量1是右值
int &&j = val;            // 编译失败,右值引用不可引用左值
int &&k = std::move(val); // 编译成功,std::move(val)是左值
int &&l = i;              // 编译失败,i为右值引用是左值

结合上边的值类别讨论和右值引用,会带来非常反直觉的地方,当使用右值引用变量调用函数时,并不会选择入参类型为右值引用的重载函数,因为右值引用本身为左值而右值引用只能绑定右值。如下代码示例。

c++

void func(int &&x) {
  std::cout << "int &&x: " << x << std::endl;
}

void func(int &x) {
  std::cout << "int &x: " << x << std::endl;
}

int main() {
  int val = 1;
  int &left_ref = val;
  int &&right_ref = 2;
  func(left_ref);             // int &x: 1
  func(right_ref);            // int &x: 2
  func(std::move(left_ref));  // int &&x: 1
  func(std::move(right_ref)); // int &&x: 2
  func(3);                    // int &&x: 3
}

左值引用只能绑定左值,右值引用只能绑定右值,常量左值引用可以绑定左值和右值,但其常量性有一定的限制。万能引用既可以绑定左值也可以绑定右值,也可绑定含有const和volatile属性的值

万能引用只有以下两种形式:

c++

// 1. param是个万能引用
template<typename T>
void f(T&& param);

// 2. var是个万能引用
auto&& var = var1;

如下都不是万能引用:

c++

template<typename T>
void f(std::vector<T>&& param); // param不是万能引用

template<typename T>
void f(const T&& param); // param不是万能引用

template<typename T>
class X {
  void f(T&& param); // param不是万能引用, 类型推导在类实例化时确定,而不在函数调用处实例化
}

下面叙述如何万能引用如何推导达到既可以绑定左值也可以绑定右值。

首先需要了解引用折叠概念,简单来讲只要有左值引用参与,最后推导结果就是左值引用类型,否则被推导成右值引用类型。下面看下参数为万能引用的f函数,传入不同的int值类型的推导过程。

当左值传递给万能引用时,模版类型T被推导成该类型的左值引用。如下,则T被推导成int&类型,根据引用折叠规则,最终的参数param类型为int&类型。

c++

template<typename T>
void f(T&& param);

// T被推导为int&
void f(int& && param); // 根据引用折叠被最终的函数签名为void f(int& param)

同上边的反直觉地方,这里传入右值引用也会被当做左值来处理,故如下几种情况都是左值传递,推导过程如上。

c++

int val = 1;
int& left_ref = val;
int&& right_ref = 1;

f(left_ref); // 最终param的类型为左值引用
f(right_ref);// 最终param的类型为左值引用

而当右值传递给万能引用时,模版类型T被推导成该类型的非引用类型。如下,则T被推导成int类型,最终的参数param的类型为int&&类型,即右值引用类型。

c++

template<typename T>
void f(T&& param);

// T被推导成int
void f(int&& param);

std::move是无条件将传入的值转换成右值(将亡值),而std::forward则根据传入的参数类型决定是否转换成右值(将亡值),具体来讲就是右值转换成右值(将亡值),左值转换成左值。

std::forward也是一种转换,运行期不执行任何操作。如下,std::forward如同调用static_cast<T&&>转换。

c++

template<typename T>
void forward_func(T&& v) {
  func(static_cast<T&&>(v)); // 和func(std::forward<T>(v)); 效果一样
}

static_cast<T&&>当传入的参数为左值时,T被推导为左值引用,根据引用折叠最终为static_cast<T&>;而当传入参数为右值时,T被推导成非引用的T类型,最终为static_cast<T&&>