文章首发
【重学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)
到此,咱们先简略对右值和右值援用做个小结:
- 像字面值常量、表达式后果、长期对象等这类无奈通过
&
符号获取变量内存地址的,称为右值。 - 右值援用是一种援用类型,示意对右值进行援用,通常应用
&&
符号定义。
右值援用次要解决一下两个问题:
- 实现挪动语义
- 实现完满转发
这一节咱们先具体讲讲右值是如何实现挪动成果的,以及相干的注意事项。完满转发篇幅有点多,咱们留到下节讲。
复制 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
x1
和x2
初始化时,都会执行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_p
和x1.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
而后将其复制进来。很显著,这比在本地结构后挪动效率更快。
以下是foo
和bar
的输入:
// 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_back
、insert
、reserve
、resize
等函数导致内存重调配时,如果元素提供了一个noexcept
的挪动构造函数,vector
会调用该挪动构造函数将元素挪动
到新的内存区域;否则,则会调用拷贝构造函数,将元素复制过来。
总结
明天咱们次要学了C++中右值援用的相干概念和利用场景,并花了很大篇幅解说挪动语义及其相干实现。
右值援用次要解决实现挪动语义和完满转发的问题。咱们下节接着解说右值是如何实现完满转发。欢送关注,及时收到推送~
<center>- END -</center>
【往期举荐】
01 C++如何进行内存资源管理
02 脱离指针陷阱:深入浅出 C++ 智能指针
03 手撸C++智能指针实战教程