C++11新特性 右值引用详解

C++引入右值引用以及与之相关的移动语义和完美转发,C++的语义变得更加丰富和合理,与此同时它的性能也有了更大的优化空间。对于这些优化空间,C++委员会已经对标准库进行了优化,比如常用的容器均已支持移动构造函数和移动赋值运算符函数。

一、左值与右值

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

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

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

例子1

int a = 1;
cout << &a << endl; // 左值
cout << &1 << endl; // 右值,报错:error: lvalue required as unary '&' operand

这个很明显了,通常字面量都是一个右值,除字符串字面量以外。

auto p = &"hello world"; // 编译成功,字符串字面量保存在data区,可以寻址

例子2

int *p = &x++;  // 编译失败,x++返回右值
int *q = &++x;  // 编译成功,++x返回左值

为什么 x++ 编译失败,++x编译成功呢?

后置++操作中编译器首先会生成一份x值的临时复制,然后才对x递增,最后返回临时复制内容。
前置++操作会将x自增后直接返回自身。

所以原因在于一个返回的是临时内容,是右值,一个返回的是变量,是左值

例子3

int x = 1;

int get_val() {
	return x;
}

int y = get_val();   // 编译成功
int *p = &get_val(); // 编译失败

get_val 返回的是一个右值,返回的并不是x变量本身,而是x的临时复制。

二、左值引用

左值引用是编程过程中的常用特性之一,也是我们最常见的引用形式,当我们需要将一个对象作为参数传递给子函数的时候,往往会使用左值引用,因为这样可以免去创建临时对象的操作。非常量左值的引用对象很单纯,它们必须是一个左值。

int a = 10;               
int &x1 = a;           // 编译成功,左值引用指向左值
int &x2 = 10;          // 编译失败,左值引用指向右值
const int &x3 = 10;    // 编译成功,常量左值引用可以指向右值

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

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();
}

如果我们去掉拷贝构造函数中的 const,那么 X x3(make_x()); 将报错,因为 make_x() 函数返回的是右值,而非常量左值引用无法绑定到make_x()产生的右值上,导致传参失败。

为什么非常量左值引用无法绑定到右值上:右值通常是临时的,可能会在语句结束后被销毁,而引用则可能会在稍后的代码中被使用。如果允许非常量左值引用绑定到右值上,那么在引用被使用时,可能会访问已经被销毁的对象,导致未定义行为。

常量左值引用可以绑定右值是一条非常棒的特性,但是他有很大的限制,他是常量,传入后我们无法修改该对象的内容。为此需要另一个特性,右值引用。

三、右值引用

右值引用顾名思义就是右值的引用,语法如下

int i = 0;
int &j = i;    // 左值引用
int &&k = 11;  // 右值引用

对于字面量使用右值引用并没有什么具体的意义,我们看一个比较有意义的代码

class X {
public:
    X() { std::cout << "X ctor" << std::endl; }
    X(const X&x) { std::cout << "X copy ctor" << std::endl; }
    ~X() { std::cout << "X dtor" << 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();
}

编译这段代码需要加上参数 -fno-elide-constructors,才能看出效果,否则编译器直接给我们优化了。看下图的结果:

image-20240508221018594

首先是在make_x()栈中生成了一个x对象,调用了一次构造函数,为了返回这个对象的临时副本使用了拷贝构造函数,销毁在make_x()栈中的对象使用析构函数,返回的右值(临时副本)给了右值引用,延长了这个右值的生命周期,最后销毁这个临时副本。

如果不使用右值引用,使用 X x2 = make_x() 则是几次构造?应该是三次,x1会默认构造一次,然后return x1会使用复制构造产生临时对象,接着X x2 = make_x()会使用复制构造将临时对象复制到x2,最后临时对象被销毁。

main() 函数中并不需要手动释放 x2make_x() 返回的临时对象,因为它们的生命周期由 C++ 的对象生命周期管理机制来管理。

延长临时对象生命周期并不是这里右值引用的最终目标,其真实目标应该是减少对象复制,提升程序性能。

四、使用右值优化性能

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

#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();
}

如果在不做优化的情况下,会调用三次拷贝构造函数,当 get_pool 返回 pool 时,会调用一次拷贝构造函数,当 make_pool返回 pool时,会调用一次拷贝构造函数,当pool 对象被赋值给 my_pool 时,会调用一次拷贝构造函数。

从代码角度看没问题,但是性能上,每次都会复制4KB数据,这个开销还是很大的。

实际上,编译器会启动传参优化,只会复制一次。

五、移动语义

不难发现,上述代码中,第二次和第三次的复制是影响效率的主要因素,而这两次都是先复制到临时对象,如果有办法直接将临时对象的内存转移到 my_pool 中,不就可以消除内存复制带来的系统开销了嘛。这就是移动语义。

5.1、移动构造函数

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

  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的移动构造函数,在函数中复制构造中的内存复制,取而代之的是简单的指针替换操作。然后置空实参对象以保证实参对象析构的时候不会影响这片内存的生命周期。

5.2、移动复制运算符

移动赋值运算符函数也能完成移动操作,继续以BigMemoryPool为例,在这个类中添加移动赋值运算符函数:

class BigMemoryPool {
public:
    BigMemoryPool& operator=(BigMemoryPool&& other)
    {
        std::cout << "move(operator=) big memory pool." << std::endl;
        if (pool_ != nullptr) {
            delete[] pool_;
        }
        pool_ = other.pool_;
        other.pool_ = nullptr;
        return *this;
    }

private:

  char *pool_;
};

5.3、一点说明

编译器在一些条件下会生成一份移动构造函数,这些条件包括:没有任何的复制函数,包括复制构造函数和复制赋值函数;没有任何的移动函数,包括移动构造函数和移动赋值函数;也没有析构函数。实际上编译器自己生成的移动构造函数和复制构造函数也没有什么区别,不应该对此抱有很大的期待。

虽然使用移动语义在性能上有很大收益,但是也有一些风险。试想一下,在一个移动构造函数中,如果当一个对象的资源移动到另一个对象时发生了异常,也就是说对象的一部分发生了转移而另一部分没有,这就会造成源对象和目标对象都不完整的情况发生,这种情况的后果是无法预测的。所以在编写移动语义的函数时建议确保函数不会抛出异常,与此同时,如果无法保证移动构造函数不会抛出异常,可以使用noexcept说明符限制该函数。这样当函数抛出异常的时候,程序不会再继续执行而是调用std::terminate中止执行以免造成其他不良影响。

六、值类别

值类别是C++11信引入的概念,它将表达式分为三种类别,左值(lvalue)、纯右值(prvalue)和将亡值(xvalue)。

                   expression(表达式)
                    /            \
              glvalue(泛左值) rvalue(右值)     
                 /      \      /      \
         lvalue(左值)  xvalue(将亡值)  prvalue(纯右值)   

1、所谓泛左值是指一个通过评估能够确定对象、位域或函数的标识的表达式。简单来说,它确定了对象或者函数的标识(具名对象)。

2、而纯右值是指一个通过评估能够用于初始化对象和位域,或者能够计算运算符操作数的值的表达式。

3、将亡值属于泛左值的一种,它表示资源可以被重用的对象和位域,通常这是因为它们接近其生命周期的末尾,另外也可能是经过右值引用的转换产生的。

左值就是上面中描述的在C++98中的左值,纯右值也就是上面描述的C++98中的右值,那么将亡值其实也就呼之欲出了,本来即将死亡的右值,被我们手动延长了生命周期的值。

将亡值的途径有两种:

第一种是使用类型转换将泛左值转换为该类型的右值引用。比如:

static_cast<BigMemoryPool&&>(my_pool)

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

struct X {
   int a;
};

int main() {
   int b = X().a;
}

X()是一个纯右值,这一点毫无疑问,但是访问其成员变量却需要一个泛左值,所以这里会发生一次临时量实质化,将 X() 变为一个将亡值,最后在访问其成员变量。在C++17标准之前临时变量是纯右值,只有转换为右值引用的类型才是将亡值。

七、将左值转化为右值

右值引用只能绑定一个右值,如果尝试绑定一个左值会导致编译错误,不过想要这么做的话,还是有办法的。在C++11标准中可以在不创建临时值的情况下显式地将左值通过static_cast转换为将亡值:

int i = 0;
int &&k = static_cast<int&&>(i);

这个转变看似好像没什么意义,我们看一个有意义的

BigMemoryPool my_pool1;
BigMemoryPool my_pool2 = my_pool1;
BigMemoryPool my_pool3 = static_cast<BigMemoryPool &&>(my_pool1);

使用移动构造函数和移动赋值运算符将my_pool1转化为一个右值,由于调用了移动构造函数,my_pool1失去了自己的内存数据,后面的代码也不能对my_pool1进行操作。

这样单纯的将左值转化为右值也没什么意义啊,而且再次使用my_pool1还会引发未定义的行为,正确的使用场景是在一个右值被转换为左值后需要再次转换为右值,最典型的例子是一个右值作为实参传递到函数中。

void move_pool(BigMemoryPool &&pool) {
    std::cout << "call move_pool" << std::endl;
    BigMemoryPool my_pool(pool);
}

int main() {
  move_pool(make_pool());
}

上述代码在构造my_pool的时候还是会调用复制构造函数而非移动构造函数。为了让my_pool调用移动构造函数进行构造,需要将形参pool强制转换为右值:

void move_pool(BigMemoryPool &&pool) {
    std::cout << "call move_pool" << std::endl;
    BigMemoryPool my_pool(static_cast<BigMemoryPool&&>(pool));
}

在C++11的标准库中还提供了一个函数模板std::move帮助我们将左值转换为右值,这个函数内部也是用static_cast做类型转换。

void move_pool(BigMemoryPool &&pool) {
    std::cout << "call move_pool" << std::endl;
    BigMemoryPool my_pool(std::move(pool));
}

八、万能引用

常量左值引用既可以引用左值又可以引用右值,是一个几乎万能的引用,但可惜的是由于其常量性,导致它的使用范围受到一些限制。所以C++11添加了万能引用

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则是万能引用。我们知道右值引用只能绑定一个右值,但是万能引用既可以绑定左值也可以绑定右值,甚至constvolatile的值都可以绑定:

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 &&,则是右值引用。在这个推导过程中,初始化的源对象如果是一个左值,则目标对象会推导出左值引用;反之如果源对象是一个右值,则会推导出右值引用。

引用的推导规则如下

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

可以看到,只要有左值引用参与就是左值引用,只有实际类型是一个非引用类型或者右值引用,最后推导出来的才是一个右值引用。

九、完美转发

先看一个普通的转发函数模板

template<class T>
void show_type(T t) {
    std::cout << typeid(t).name() << std::endl;
}

template<class T>
void normal_forwarding(T t) {
    show_type(t);
}

int main() {
    std::string s = "hello world";
    normal_forwarding(s);
}

上述的转发函数normal_forwarding按值转发,也就是说,std::string在转发过程中会额外发生一次临时对象的复制,其中一个解决办法是将void normal_forwarding(T t)替换为void normal_forwarding(T &t),这样就能避免临时对象的复制。不过这样会带来另外一个问题,如果传递过来的是一个右值,则该代码无法通过编译。

当然,我们还可以将void normal_forwarding(T &t)替换为void normal_forwarding (const T &t)来解决这个问题,因为常量左值引用是可以引用右值的。但是我们也知道,这样我们就无法修改字符串中的内容了。

万能引用的出现改变了这个尴尬的局面。上文提到过,对于万能引用的形参来说,如果实参是给左值,则形参被推导为左值引用;反之如果实参是一个右值,则形参被推导为右值引用,所以下面的代码无论传递的是左值还是右值都可以被转发,而且不会发生多余的临时复制:

template<class T>
void show_type(T t) {
    std::cout << typeid(t).name() << std::endl;
}

template<class T>
void perfect_forwarding(T &&t) {
    show_type(static_cast<T&&>(t));
}

std::string get_string() {
    return "hi world";
}

int main() {
    std::string s = "hello world";
    perfect_forwarding(s);
    perfect_forwarding(get_string());
}
  • 当实参是一个左值时,T被推导为std::string&,于是static_cast<T&&>被推导为static_cast<std:: string&>,传递到show_type函数时继续保持着左值引用的属性;

  • 当实参是一个右值时,T被推导为std::string,于是static_cast <T&&>被推导为static_cast<std::string&&>,所以传递到show_type函数时保持了右值引用的属性。

和移动语义的情况一样,显式使用static_cast类型转换进行转发不是一个便捷的方法。在C++11的标准库中提供了一个std::forward函数模板,在函数内部也是使用static_cast进行类型转换:

template<class T>
void perfect_forwarding(T &&t) {
    show_type(std::forward<T>(t));
}
  • 20
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

LyaJpunov

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值