一文秒懂C++右值引用

左值和右值的概念

左值:左值一般是指一个指向特定内存的具有名称的值 (具名对象),它有一个相对稳定的内存地址,并且有一段较长的生命周期。

右值:而右值则是不指向稳定内存地址的匿名值(不具名对象),它的 生命周期很短,通常是暂时性的。

基于这一特征,我们可以用取地址符&来判断左值和右值,能取到内存地址的值为左值,否则为右值。

例如:

cpp 复制代码
int main() {
    int x = 1;
    int y = x;
    &x; // ok
    &y; // ok

    &1; // error: Cannot take the address of an rvalue of type 'int'
}

x和y都是左值,在内存中都有一个内存地址与之对应。而字面量1是一个右值,无法获取内存地址。


需要注意的是,有一些表达式会违反直觉。

例如:

cpp 复制代码
int main() {
    int x = 1;
    &x++; // error: Cannot take the address of an rvalue of type 'int'
    &++x; // ok
}

在上面的代码中,x++++x虽然都是自增操作,但是却分为不同 的左右值。其中x++是右值,因为在后置++操作中编译器首先会生成一

份x值的临时复制,然后才对x递增,最后返回临时复制内容。

++x则 不同,它是直接对x递增后马上返回其自身,所以++x是一个左值。如果 对它们实施取地址操作,就会发现++x的取地址操作可以编译成功,而 对x++取地址则会报错。但是从直觉上来说,&x++看起来更像是会编译成功的一方。


最后需要强调的是,通常字面量都是一个右值,除字符串字面量以 外:

cpp 复制代码
int main() {
    &1; // error: Cannot take the address of an rvalue of type 'int'
    &"hello world";
}

原因仔细想来也很简单, 编译器会将字符串字面量存储到程序的数据段中,程序加载的时候也会 为其开辟内存空间,所以我们可以使用取地址符&来获取字符串字面量的内存地址。

左值引用与右值引用

左值引用

左值引用是编程过程中的常用特性之一,它的出现让C++编程在一 定程度上脱离了危险的指针。当我们需要将一个对象作为参数传递给子 函数的时候,往往会使用左值引用,因为这样可以免去创建临时对象的操作。非常量左值的引用对象很单纯,它们必须是一个左值。对于这一 点,常量左值引用的特性显得更加有趣,它除了能引用左值,还能够引用右值,比如:

cpp 复制代码
int main() {
    int &x = 1; // error: Non-const lvalue reference to type 'int' cannot bind to a temporary of type 'int'
    const int &y = 2; // ok
}

在上面的代码中,第一行代码会编译报错,因为int&无法绑定一 个int类型的右值,但是第二行代码却可以编译成功。

请注意,虽然在 结果上const int &x = 11const int x = 11是一样的,但是从语 法上来说,前者是被引用了,所以语句结束后11的生命周期被延长,而 后者当语句结束后右值11应该被销毁。

虽然常量左值引用可以引用右值的这个特性在赋值表达式中看不出什么实用价值,但是在函数形参列表 中却有着巨大的作用。一个典型的例子就是复制构造函数和复制赋值运 算符函数,通常情况下我们实现的这两个函数的形参都是一个常量左值引用,例如:

cpp 复制代码
class X {
public:
    X() {}
    X(const X &) {}
    X &operator=(const X &) { return *this; }
};
X make_x() { return X(); }

int main() {
    X x1;
    X x2(x1);
    X x3(make_x());
    x3 = make_x();
}

以上代码可以通过编译,但是如果这里将类X的复制构造函数和复 制赋值函数形参类型的常量性删除,则X x3(make_x());x3 = make_x();这两句代码会编译报错,因为非常量左值引用无法绑定 到make_x()产生的右值。常量左值引用可以绑定右值是一条非常棒的 特性,但是它也存在一个很大的缺点------常量性 。一旦使用了常量左值 引用,就表示我们无法在函数内修改该对象的内容(强制类型转换除 外)。所以需要另外一个特性来帮助我们完成这项工作,它就是右值引用

右值引用

顾名思义,右值引用是一种引用右值且只能引用右值的方法。在语 法方面右值引用可以对比左值引用,在左值引用声明中,需要在类型后添加&,而右值引用则是在类型后添加&&,例如:

cpp 复制代码
int main() {
    int v = 1;
    int &x = v;  // 左值引用
    int &&y = 2; // 右值引用
}

在上面的代码中,y是一个右值引用,如果试图用y引用变量v,则会引起编译错误。右值引用的特点之一是可以延长右值的生命周期并且可以像左值引用那样引用一个右值并进行操作,这 个对于字面量2可能看不出效果,那么请看下面的例子:

cpp 复制代码
class X {
public:
    X() { std::cout << "X constructor" << std::endl; }
    X(const X &x) { std::cout << "X copy constructor" << std::endl; }
    ~X() { std::cout << "X destructor" << std::endl; }
    void show() { std::cout << "show X" << std::endl; }
};

X make_x() {
    X x1;
    return x1;
}

int main() {
    X &&x2 = make_x();
    x2.show();
}

在理解这段代码之前,让我们想一下如果将X &&x2 = make_x()

这句代码替换为X x2 = make_x()会发生几次构造。在没有进行任何 优化的情况下应该是3次构造:

  1. 首先make_x函数中x1会默认构造一次。
  2. 然后return x1会使用复制构造产生临时对象。
  3. 接着X x2 = make_x() 会使用复制构造将临时对象复制到x2,最后临时对象被销毁。

以上流程在使用了右值引用以后发生了微妙的变化,让我们编译运 行这段代码。请注意,用GCC编译以上代码需要加上命令行参数-fno- elide-constructors用于关闭函数返回值优化(RVO)。因为GCC的 RVO优化会减少复制构造函数的调用,不利于语言特性实验:

cpp 复制代码
X constructor
X copy constructor
X destructor
show X 
X destructor

从运行结果可以看出上面的代码只发生了两次构造。第一次 是make_x函数中x1的默认构造,第二次是return x1引发的复制构 造。不同的是,由于x2是一个右值引用,引用的对象是函数make_x返 回的临时对象,因此该临时对象的生命周期得到延长,所以我们可以 在X &&x2 = make_x()语句结束后继续调用show函数而不会发生任何问题。对性能敏感的读者应该注意到了,延长临时对象生命周期并不是 这里右值引用的最终目标,其真实目标应该是减少对象复制,提升程序性能

右值引用解决的问题

前面的介绍我们知道了很多情况下右值都存储在临时对象中,当右值被使用之后程序会马上销毁对象并释放内存。这个过程可能会引发一个性能问题。

我们考虑这样一个需求,拷贝内存中一个很大的缓冲区,如果采用以前的拷贝,那么开销是巨大的,例如:

cpp 复制代码
#include <cstring>
#include <iostream>

class BigMemoryPool {
public:
    static const int PoolSize = 4096;
    BigMemoryPool() : pool_(new char[PoolSize]) {}
    ~BigMemoryPool() {
        if (pool_ != nullptr) { delete[] pool_; }
    }
    BigMemoryPool(const BigMemoryPool &other) : pool_(new char[PoolSize]) {
        std::cout << "copy big memory pool." << std::endl;
        memcpy(pool_, other.pool_, PoolSize);
    }

private:
    char *pool_;
};

BigMemoryPool get_pool(const BigMemoryPool &pool) { return pool; }

BigMemoryPool make_pool() {
    BigMemoryPool pool;
    return get_pool(pool);
}

int main() { BigMemoryPool my_pool = make_pool(); }

以上代码同样需要加上编译参数-fno-elide-constructors,编 译运行程序会在屏幕上输出字符串:

cpp 复制代码
copy big memory pool.
copy big memory pool.
copy big memory pool.

可以看到BigMemoryPool my_pool = make_pool();调用了3次 复制构造函数。 1.get_pool返回的BigMemoryPool临时对象调用复制构造函数复 制了pool 对象。 2.make_pool返回的BigMemoryPool临时对象调用复制构造函数 复制了get_pool返回的临时对象。 3.main函数中my_pool调用其复制构造函数复制make_pool返回 的临时对象。 该代码从正确性上看毫无问题,但是从运行性能的角度上看却还有 巨大的优化空间。在这里每发生一次复制构造都会复制整整4KB的数 据,如果数据量更大一些,比如4MB或者400MB,那么将对程序性能造成很大影响。


显然,我们减少拷贝,我们能否用移动的思想,直接将资源移动到指定的区域中去呢?

答案是可以的,但是如果不引入右值引用而使用左值引用的情况下面临如下的问题:

  1. 左值引用无法引用右值。
cpp 复制代码
BigMemoryPool(BigMemoryPool &other) : pool_(new char[PoolSize]) {
      std::cout << "move big memory pool." << std::endl;
      memcpy(pool_, other.pool_, PoolSize);
 }

int main() {
	BigMemoryPool(new BigMemoryPool()); // 无法引用右值
	return 0;
}
  1. 可以使用常量左值引用引用右值,但由于常量的局限性(const),无法对其进行修改。
cpp 复制代码
BigMemoryPool(const BigMemoryPool &other) : pool_(new char[PoolSize]) {
      std::cout << "move big memory pool." << std::endl;
      // memcpy(pool_, other.pool_, PoolSize);
      pool_ = other.pool_;
      other.pool_ = nullptr; // 无法修改
 }

所以后来为了解决上面的问题,便引入了右值引用,完美的实现了移动语义:

仔细分析上面的代码中3次复制构造函数的调用,不难发现第二次和第三次的复制构造是影响性能的主要原因。在这个过程中都有临时对象参与进来,而临时对象本身只是做数据的复制。如果有办法能将临时对象的内存直接转移my_pool对象中,不就能消除内存复制对性能的消耗吗?好消息是在C++11标准中引入了移动语义,它可以帮助我们将临 时对象的内存移动到my_pool对象中,以避免内存数据的复制。让我们 简单修改一下BigMemoryPool类代码:

cpp 复制代码
#include <cstring>
#include <iostream>

class BigMemoryPool {
public:
    static const int PoolSize = 4096;
    BigMemoryPool() : pool_(new char[PoolSize]) {}
    ~BigMemoryPool() {
        if (pool_ != nullptr) { delete[] pool_; }
    }
    
    BigMemoryPool(BigMemoryPool &&other) : pool_(new char[PoolSize]) {
        std::cout << "move big memory pool." << std::endl;
        pool_ = other.pool_;
        other.pool_ = nullptr;
    }

    BigMemoryPool(const BigMemoryPool &other) : pool_(new char[PoolSize]) {
        std::cout << "copyp big memory pool." << std::endl;
        memcpy(pool_, other.pool_, PoolSize);
    }

private:
    char *pool_;
};

BigMemoryPool get_pool(const BigMemoryPool &pool) { return pool; }

BigMemoryPool make_pool() {
    BigMemoryPool pool;
    return get_pool(pool);
}

int main() { BigMemoryPool my_pool = make_pool(); }

在上面的代码中增加了一个类BigMemoryPool的构造函数BigMemoryPool (BigMemoryPool&& other),它的形参是一个右值引用类型,称为移动构造函数。这个名称很容易让人联想到复制构造函数,那么就让我们先了解一下它们的区别。

从构造函数的名称和它们的参数可以很明显地发现其中的区别,对于复制构造函数而言形参是一个左值引用,也就是说函数的实参必须是 一个具名的左值,在复制构造函数中往往进行的是深复制,即在不能破 坏实参对象的前提下复制目标对象。而移动构造函数恰恰相反,它接受 的是一个右值,其核心思想是通过转移实参对象的数据以达成构造目标 对象的目的,也就是说实参对象是会被修改的。

进一步来说类BigMemoryPool的移动构造函数,在函数中没有了复制构造中的内存复制,取而代之的是简单的指针替换操作。它将实参对 象的pool_赋值到当前对象,然后置空实参对象以保证实参对象析构的时候不会影响这片内存的生命周期。 编译运行这段代码,其输出结果如下:

cpp 复制代码
copy big memory pool.
move big memory pool.
move big memory pool.

可以看到后面两次的构造函数变成了移动构造函数,因为这两次操作中源对象都是右值(临时对象),对于右值编译器会优先选择使用移动构造函数去构造目标对象。当移动构造函数不存在的时候才会退而求 其次地使用复制构造函数。在移动构造函数中使用了指针转移的方式构造目标对象,所以整个程序的运行效率得到大幅提升。

值类别

表达式首先被分为了泛左值(glvalue)和右值(rvalue),其中泛左值被进一步划分为左值和将亡值,右值又被划分为将亡值和纯右值。理 解这些概念的关键在于泛左值、纯右值和将亡值。

  1. 所谓泛左值是指一个通过评估能够确定对象、位域或函数的标 识的表达式。简单来说,它确定了对象或者函数的标识(具名对象)。
  2. 而纯右值是指一个通过评估能够用于初始化对象和位域,或者 能够计算运算符操作数的值的表达式。
  3. 将亡值属于泛左值的一种,它表示资源可以被重用的对象和位 域,通常这是因为它们接近其生命周期的末尾,另外也可能是经过右值 引用的转换产生的。 剩下的两种类别就很容易理解了,其中左值是指非将亡值的泛左 值,而右值则包含了纯右值和将亡值。再次强调,值类别都是表达式的 属性,所以我们常说的左值和右值实际上指的是表达式,不过为了描述 方便我们常常会忽略它。

是不是感觉有点晕。相信我,当我第一次看到这些概念的时候也是 这个反应。不过好在我们对传统左值和右值的概念已经了然于心了,现

在只需要做道连线题就能弄清楚它们的概念。实际上,这里的左值 (lvalue)就是我们上文中描述的C++98的左值,而这里的纯右值 (prvalue)则对应上文中描述的C++98的右值。最后我们惊喜地发现, 现在只需要弄清楚将亡值(xvalue)到底是如何产生的就可以了。


从本质上说产生将亡值的途径有两种,第一种是使用类型转换将泛左值转换为该类型的右值引用。比如:

cpp 复制代码
static_cast<BigMemoryPool&&>(my_pool);

第二种在C++17标准中引入,我们称它为临时量实质化,指的是纯 右值转换到临时对象的过程。每当纯右值出现在一个需要泛左值的地方 时,临时量实质化都会发生,也就是说都会创建一个临时对象并且使用 纯右值对其进行初始化,这也符合纯右值的概念,而这里的临时对象就 是一个将亡值。

cpp 复制代码
struct S { int a; };
int main() { int b = S().a; }

在上面的代码中,S()是一个纯右值,访问其成员变量a却需要一个 泛左值,所以这里会发生一次临时量实质化,将S()转换为将亡值,最 后再访问其成员变量a。还有一点需要说明,在C++17标准之前临时变 量是纯右值,只有转换为右值引用的类型才是将亡值。

在本节之后的内容中,依然会以左值和右值这样的术语为主。但是读者应该清楚,这里的左值是C++17中的左值(lvalue),右值是C++17 中的纯右值(prvalue)和将亡值(xvalue)。对于将亡值(xvalue), 读者实际上只需要知道它是泛左值和右值交集即可,后面的内容也不会 重点强调它,所以不会影响到读者对后续内容的理解。

将左值转换为右值

右值引用只能绑定一个右值,如果尝试绑定,左值 会导致编译错误:

cpp 复制代码
int i = 0; 
int &&k = i; // 编译失败

不过,如果想完成将右值引用绑定到左值这个"壮举"还是有办法 的。在C++11标准中可以在不创建临时值的情况下显式地将左值通过 static_cast转换为将亡值,通过值类别的内容我们知道将亡值属于右值,所以可以被右值引用绑定。值得注意的是,由于转换的并不是右 值,因此它依然有着和转换之前相同的生命周期和内存地址,例如:

cpp 复制代码
int i = 0;
int &&k = static_cast<int &&>(i); // 编译成功

读者在这里应该会有疑问,既然这个转换既不改变生命周期也不改变内存地址,那它有什么存在的意义呢?实际上它的最大作用是让左值使用移动语义。

还是以BigMemoryPool为例:

cpp 复制代码
class BigMemoryPool {
public:
    static const int PoolSize = 4096;
    BigMemoryPool() : pool_(new char[PoolSize]) {}
    ~BigMemoryPool() {
        if (pool_ != nullptr) { delete[] pool_; }
    }

    BigMemoryPool(BigMemoryPool &&other) : pool_(new char[PoolSize]) {
        std::cout << "move big memory pool." << std::endl;
        pool_ = other.pool_;
        other.pool_ = nullptr;
    }

    BigMemoryPool(const BigMemoryPool &other) : pool_(new char[PoolSize]) {
        std::cout << "move big memory pool." << std::endl;
        memcpy(pool_, other.pool_, PoolSize);
    }

private:
    char *pool_;
};

BigMemoryPool get_pool(const BigMemoryPool &pool) { return pool; }

BigMemoryPool make_pool() {
    BigMemoryPool pool;
    return get_pool(pool);
}

int main()
{
	BigMemoryPool my_pool1;
	BigMemoryPool my_pool2 = my_pool1; 
	BigMemoryPool my_pool3 = static_cast<BigMemoryPool &&>(my_pool1); // 调用移动构造函数
	return 0;
}

万能引用和引用折叠

提到过常量左值引用既可以引用左值又可以引用右值,是一 个几乎万能的引用,但可惜的是由于其常量性,导致它的使用范围受到 一些限制。其实在C++11中确实存在着一个被称为"万能"的引用,它看 似是一个右值引用,但其实有着很大区别,请看下面的代码:

cpp 复制代码
void foo(int &&i) {} // i为右值引用

template<class T>
void bar(T && t) {} // t为万能引用

int get_val() { return 5; }
int &&x = get_val(); // x为右值引用 
auto &&y = get_val(); // y为万能引用

在上面的代码中,函数foo的形参i和变量x是右值引用,而函数模 板的形参t和变量y则是万能引用。我们知道右值引用只能绑定一个右 值,但是万能引用既可以绑定左值也可以绑定右值,甚至const和 volatile的值都可以绑定,例如:

cpp 复制代码
int i = 42; 
const int j = 11; 
bar(i); bar(j); 
bar(get_val());

auto &&x = i; 
auto &&y = j;
auto &&z = get_val();

看到这里读者应该已经发现了其中的奥秘。所谓的万能引用是因为 发生了类型推导,在T&&auto&&的初始化过程中都会发生类型的推 导,如果已经有一个确定的类型,比如int &&,则是右值引用。在这 个推导过程中,初始化的源对象如果是一个左值,则目标对象会推导出 左值引用;反之如果源对象是一个右值,则会推导出右值引用,不过无 论如何都会是一个引用类型。

万能引用能如此灵活地引用对象,实际上是因为在C++11中添加了 一套引用叠加推导的规则------引用折叠。在这套规则中规定了在不同的 引用类型互相作用的情况下应该如何推导出最终类型,如表所示。

类模板型 T实际类型 最终类型
T& R R&
T& R& R&
T& R&& R&
T&& R R&&
T&& R& R&
T&& R&& R&&

得一提的是,万能引用的形式必须是T&&或者auto&&,也就是说 它们必须在初始化的时候被直接推导出来,如果在推导中出现中间过 程,则不是一个万能引用,例如:

cpp 复制代码
#include <vector>
template<class T>
void foo(std::vector<T> &&t) {}
int main() {
    std::vector<int> v{1, 2, 3};
    foo(v); // 编译错误
}

在上面的代码中,foo(v)无法编译通过,因为foo的形参t并不是一个万能引用,而是一个右值引用。因为foo的形参类型 是std::vector<T>&&而不是T&&,所以编译器无法将其看作一个万能引用处理。

移动语义模板和完美转发模板

移动语义

将一个左值转换为一个右值,除了使用static_cast<> 呢,标准库提供了一个模板:std::move

std::move的函数原型定义:

cpp 复制代码
template<typename _Tp>
	constexpr typename std::remove_reference<_Tp>::type&&
  move(_Tp&& __t) noexcept
  { return static_cast<typename std::remove_reference<_Tp>::type&&>(__t); }

对于remove_reference是通过类模板的部分特例化进行实现的,其实现代码如下

cpp 复制代码
//原始的,最通用的版本
template <typename T> 
struct remove_reference{
    typedef T type;  //定义T的类型别名为type
};
 
//部分版本特例化,将用于左值引用和右值引用
template <class T> 
struct remove_reference<T&> //左值引用
{ typedef T type; }
 
template <class T> struct 
remove_reference<T&&> //右值引用
{ typedef T type; }   
  
//举例如下,下列定义的a、b、c三个变量都是int类型
int i;
remove_refrence<decltype(42)>::type a;             //使用原版本,
remove_refrence<decltype(i)>::type  b;             //左值引用特例版本
remove_refrence<decltype(std::move(i))>::type  b;  //右值引用特例版本

实现原理

通过引用折叠+类型转换实现。

  1. X& &、X&& &、X& &&都折叠成X&,用于处理左值
cpp 复制代码
string s("hello");
std::move(s) => std::move(string& &&) => 折叠后 std::move(string& )
// 此时:T的类型推导为 string&
// typename remove_reference<T>::type为 string 
// 整个std::move被实例化如下
string&& move(string& t) //t为左值,移动后不能在使用t
{
    //通过static_cast将string&强制转换为string&&
    return static_cast<string&&>(t); 
}
  1. X&& &&折叠成X&&,用于处理右值
cpp 复制代码
std::move(string("hello")) => std::move(string&&)
// 此时:T的类型为string 
// remove_reference<T>::type为 string 
// 整个std::move被实例如下
string&& move(string&& t) //t为右值
{
    return static_cast<string&&>(t);  //返回一个右值引用
}

完美转发

cpp 复制代码
// 转发左值
template<typename _Tp>
	_GLIBCXX_NODISCARD
	constexpr _Tp&&
	forward(typename std::remove_reference<_Tp>::type& __t) noexcept
	{ return static_cast<_Tp&&>(__t); }

// 转发右值
template<typename _Tp>
  _GLIBCXX_NODISCARD
  constexpr _Tp&&
  forward(typename std::remove_reference<_Tp>::type&& __t) noexcept
  {
    static_assert(!std::is_lvalue_reference<_Tp>::value,
  "std::forward must not be used to convert an rvalue to an lvalue");
    return static_cast<_Tp&&>(__t);
  }

当实参是一个左值 时,先通过获得类型type,定义_t为左值引用的左值变量,通过static_cast进行强制转换。_Tp&&会发生引用折叠,当_Tp推导为左值引用,则折叠为_Tp& &&,即_Tp&,当推导为右值引用,则为本身_Tp&&,即forward返回值与static_cast处都为_Tp&&

当实参是一个右值 时,不同于转发左值,_t为右值引用的左值变量,除此之外中间加了一个断言,表示当不是左值的时候,也就是右值,才进行static_cast转换。

参考博文

相关推荐
qq_18735263416 分钟前
马踏棋盘c++
开发语言·c++·马踏棋盘c++
qing_04060339 分钟前
C++——string的了解和使用
开发语言·c++·string
xiaobai12 31 小时前
集群聊天服务器项目【C++】(六)MySql数据库
服务器·数据库·c++
奇点 ♡1 小时前
【线程】线程的控制
linux·运维·c语言·开发语言·c++·visual studio code
一道秘制的小菜2 小时前
C++第十一节课 new和delete
开发语言·数据结构·c++·学习·算法
心怀花木2 小时前
【C++】模拟实现list
c++·stl·list
kkk_皮蛋2 小时前
力扣773:滑动谜题
c++
float_com3 小时前
【STL】 set 与 multiset:基础、操作与应用
c++·stl
临沂堇4 小时前
CCF刷题计划——训练计划(反向拓扑排序)
数据结构·c++·算法·拓扑·ccf
猿饵块4 小时前
cmake--get_filename_component
java·前端·c++