共计 4967 个字符,预计需要花费 13 分钟才能阅读完成。
文章首发
【重学 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 constructor
call o constructor
call 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 constructor
call o constructor
call 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 constructor
call 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
的输入:
// foo
call x constructor
// bar
call x constructor
call 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 ++ 智能指针实战教程