文章首发

【重学C++】04 | 说透C++右值援用(上)

引言

大家好,我是只讲技术干货的会玩code,明天是【重学C++】的第四讲,在后面《03 | 手撸C++智能指针实战教程》中,咱们或多或少接触了右值援用和挪动的一些用法。

右值援用是 C++11 规范中一个很重要的个性。第一次接触时,可能会很乱,不分明它们的目标是什么或者它们解决了什么问题。接下来两节课,咱们具体讲讲右值援用及其相干利用。内容很干,留神珍藏!

左值 vs 右值

简略来说,左值是指能够应用&符号获取到内存地址的表达式,个别呈现在赋值语句的右边,比方变量、数组元素和指针等。

int i = 42;i = 43; // ok, i是一个左值int* p = &i; // ok, i是一个左值,能够通过&符号获取内存地址int& lfoo() { // 返回了一个援用,所以lfoo()返回值是一个左值    int a = 1;    return a; };lfoo() = 42; // ok, lfoo() 是一个左值int* p1 = &lfoo(); // ok, lfoo()是一个左值

相同,右值是指无奈获取到内存地址的表白是,个别呈现在赋值语句的左边。常见的有字面值常量、表达式后果、长期对象等。

int rfoo() { // 返回了一个int类型的长期对象,所以rfoo()返回值是一个右值    return 5;};int j = 0;j = 42; // ok, 42是一个右值j = rfoo(); // ok, rfoo()是右值int* p2 = &rfoo(); // error, rfoo()是右值,无奈获取内存地址

左值援用 vs 右值援用

C++中的援用是一种别名,能够通过一个变量名拜访另一个变量的值。

上图中,变量a和变量b指向同一块内存地址,也能够说变量a是变量b的别名。

在C++中,援用分为左值援用和右值援用两种类型。左值援用是指对左值进行援用的援用类型,通常应用&符号定义;右值援用是指对右值进行援用的援用类型,通常应用&&符号定义。

class X {...};// 接管一个左值援用void foo(X& x);// 接管一个右值援用void foo(X&& x);X x;foo(x); // 传入参数为左值,调用foo(X&);X bar();foo(bar()); // 传入参数为右值,调用foo(X&&);

所以,通过重载左值援用和右值援用两种函数版本,满足在传入左值和右值时触发不同的函数分支。

值得注意的是,void foo(const X& x);同时承受左值和右值传参。

void foo(const X& x);X x;foo(x); // ok, foo(const X& x)可能接管左值传参X bar();foo(bar()); // ok, foo(const X& x)可能接管右值传参// 新增右值援用版本void foo(X&& x);foo(bar()); // ok, 精准匹配调用foo(X&& x)

到此,咱们先简略对右值和右值援用做个小结:

  1. 像字面值常量、表达式后果、长期对象等这类无奈通过&符号获取变量内存地址的,称为右值。
  2. 右值援用是一种援用类型,示意对右值进行援用,通常应用&&符号定义。

右值援用次要解决一下两个问题:

  1. 实现挪动语义
  2. 实现完满转发

这一节咱们先具体讲讲右值是如何实现挪动成果的,以及相干的注意事项。完满转发篇幅有点多,咱们留到下节讲。

复制 vs 挪动

假如有一个自定义类X,该类蕴含一个指针成员变量,该指针指向另一个自定义类对象。假如O占用了很大内存,创立/复制O对象须要较大老本。

class O {public:    O() {        std::cout << "call o constructor" << std::endl;    };    O(const O& rhs) {        std::cout << "call o copy constructor." << std::endl;    }};class X {public:    O* o_p;    X() {        o_p = new O();    }    ~X() {        delete o_p;    }};

X 对应的拷贝赋值函数如下:

X& X::operator=(X const & rhs) {    // 依据rhs.o_p生成的一个新的O对象资源    O* tmp_p = new O(*rhs.o_p);    // 回收x以后的o_p;    delete this->o_p;    // 将tmp_p 赋值给 this.o_p;    this->o_p = tmp_p;    return *this;}

假如对X有以下应用场景:

X x1;X x2;x1 = x2;

上述代码输入:

call o constructorcall o constructorcall o copy constructor

x1x2初始化时,都会执行new O(), 所以会调用两次O的构造函数;执行x1=x2时,会调用一次O的拷贝构造函数,依据x2.o_p复制一个新的O对象。

因为x2在后续代码中可能还会被应用,所以为了防止影响x2,在赋值时调用O的拷贝构造函数复制一个新的O对象给x1在这种场景下是没问题的。

但在某些场景下,这种拷贝显得比拟多余:

X foo() {    return X();};X x1;x1 = foo();

代码输入与之前一样:

call o constructorcall o constructorcall o copy constructor

在这个场景下,foo()创立的那个长期X对象在后续代码是不会被用到的。所以咱们不须要放心赋值函数中会不会影响到那个长期X对象,没必要去复制一个新的O对象给x1

更高效的做法,是间接应用swap替换长期X对象的o_px1.o_p。这样做有两个益处:1. 不必调用耗时的O拷贝构造函数,提高效率;2. 替换后,长期X对象领有之前x1.o_p指向的资源,在析构时能主动回收,防止内存透露。

这种防止昂扬的复制老本,而间接将资源从一个对象"挪动"到另外一个对象的行为,就是C++的挪动语义。

哪些场景实用挪动操作呢?无奈获取内存地址的右值就很适合,咱们不须要放心后续的代码会用到该右值。

最初,咱们看下挪动版本的赋值函数

X& operator=(X&& rhs) noexcept {    std::swap(this->o_p, rhs.o_p);    return *this;};

看下应用成果:

X x1;x1 = foo();

输入后果:

call o constructorcall o constructor

右值援用肯定是右值吗?

假如咱们有以下代码:

class X {public:    // 复制版本的赋值函数    X& operator=(const X& rhs);    // 挪动版本的赋值函数    X& operator=(X&& rhs) noexcept;};void foo(X&& x) {    X x1;    x1 = x;}

X重载了复制版本和挪动版本的赋值函数。当初问题是:x1=x这个赋值操作调用的是X& operator=(const X& rhs)还是 X& operator=(X&& rhs)
针对这种状况,C++给出了相干的规范:

Things that are declared as rvalue reference can be lvalues or rvalues. The distinguishing criterion is: _if it has a name_, then it is an lvalue. Otherwise, it is an rvalue.

也就是说,只有一个右值援用有名称,那对应的变量就是一个左值,否则,就是右值。

回到下面的例子,函数foo的入参尽管是右值援用,但有变量名x,所以x是一个左值,所以operator=(const X& rhs)最终会被调用。

再给一个没有名字的右值援用的例子

X bar();// 调用X& operator=(X&& rhs),因为bar()返回的X对象没有关联到一个变量名上X x = bar();

这么设计的起因也挺好了解。再改下foo函数的逻辑:

void foo(X&& x) {    X x1;    x1 = x;    ...    std::cout << *(x.inner_ptr) << std::endl;}

咱们并不能保障在foo函数的后续逻辑中不会拜访到x的资源。所以这种状况下如果调用的是挪动版本的赋值函数,x的外部资源在实现赋值后就乱了,无奈保障后续的失常拜访。

std::move

反过来想,如果咱们明确晓得在x1=x后,不会再拜访到x,那有没有方法强制走挪动赋值函数呢?

C++提供了std::move函数,这个函数做的工作很简略: 通过暗藏掉入参的名字,返回对应的右值。

X bar();X x1// ok. std::move(x1)返回右值,调用挪动赋值函数X x2 = std::move(x1);// ok. std::move(bar())与 bar()成果雷同,返回右值,调用挪动赋值函数X x3 = std::move(bar());

最初,用一个容易犯错的例子完结这一环节

class Base {public:    // 拷贝构造函数    Base(const Base& rhs);    // 挪动构造函数    Base(Base&& rhs) noexcept;};class Derived : Base {public:    Derived(Derived&& rhs)    // wrong. rhs是左值,会调用到 Base(const Base& rhs).    // 须要批改为Base(std::move(rhs))    : Base(rhs) noexcept {        ...    }}

返回值优化

按照常规,还是先给出类X的定义

class X {public:    // 构造函数    X() {        std::cout << "call x constructor" <<std::endl;    };    // 拷贝构造函数    X(const X& rhs) {        std::cout << "call x copy constructor" << std::endl;    };    // 挪动构造函数    X(X&& rhs) noexcept {        std::cout << "call x move constructor" << std::endl    };}

大家先思考下以下两个函数哪个性能比拟高?

X foo() {  X x;  return x;};X bar() {  X x;  return std::move(x);}

很多读者可能会感觉foo须要一次复制行为:从x复制到返回值;bar因为应用了std::move,满足挪动条件,所以触发的是挪动构造函数:从x挪动到返回值。复制老本 > 挪动老本,所以bar性能更好。

实际效果与下面的推论相同,bar中应用std::move反倒多余了。古代C++编译器会有返回值优化。换句话说,编译器将间接在foo返回值的地位结构x对象,而不是在本地结构x而后将其复制进来。很显著,这比在本地结构后挪动效率更快。

以下是foobar的输入:

// foocall x constructor// barcall x constructorcall x move constructor

挪动须要保障异样平安

仔细的读者可能曾经发现了,在后面的几个大节中,挪动结构/赋值函数我都在函数签名中加了关键字noexcept,这是向调用者表明,咱们的挪动函数不会抛出异样。

这点对于挪动函数很重要,因为挪动操作会对右值造成毁坏。如果挪动函数中产生了异样,可能会对程序造成不可逆的谬误。以上面为例

class X {public:    int* int_p;    O* o_p;    X(X&& rhs) {        std::swap(int_p, rhs.int_p);        ...        其余业务操作        ...        std::swap(o_p, rhs.o_p);    }}

如果在「其余业务操作」中产生了异样,不仅会影响到本次结构,rhs外部也曾经被毁坏了,后续无奈重试结构。所以,除非明确标识noexcept,C++在很多场景下会慎用挪动结构。

比拟经典的场景是std::vector 扩缩容。当vector因为push_backinsertreserveresize 等函数导致内存重调配时,如果元素提供了一个noexcept的挪动构造函数,vector会调用该挪动构造函数将元素挪动到新的内存区域;否则,则会调用拷贝构造函数,将元素复制过来。

总结

明天咱们次要学了C++中右值援用的相干概念和利用场景,并花了很大篇幅解说挪动语义及其相干实现。

右值援用次要解决实现挪动语义和完满转发的问题。咱们下节接着解说右值是如何实现完满转发。欢送关注,及时收到推送~

<center>- END -</center>
【往期举荐】

01 C++如何进行内存资源管理

02 脱离指针陷阱:深入浅出 C++ 智能指针

03 手撸C++智能指针实战教程