前言
如果你还不知道C++11引入的右值、移动语义、完美转发是什么,可以阅读这篇文章;如果你已经对这些知识了如指掌,也可以看看有什么可以补充~😏
一、右值
值类别vs变量类型
在正式认识右值之前,我们要先区分值的类别和变量类型:
- 值 (value) 和 变量 (variable) 是两个独立的概念。值不一定拥有变量名(如表达式:i + j + k)。
- 值只有类别(category) 之分,而变量只有类型(type)之分。
值类别可以被划分左值和右值。
那什么是左值和右值呢?左值是能被取地址、不能被移动的值。右值是表达式中间结果/函数返回值(可能拥有变量名,也可能没有)。
有一个可以区分左值和右值的便捷方法:看能不能对表达式取地址,如果能,则为左值,否则为右值。
C++11扩展了右值的概念,将右值分为了纯右值和将亡值,但本文不作讨论。
如下的示例将帮助我们区分左值和右值:
int i = 3;
int j = i+8;
char a = getCh();
左值引用、右值引用、常引用
在以前的文章中,我们曾经讨论过左值引用和常引用的区别。在本篇文章中,我们需要进一步系统的了解它们三者之间的关系。
引用类型 可以分为两种:
- 左值引用:用
&
符号引用左值(但不能引用右值), - 右值引用:用
&&
符号引用右值(可以移动左值)。
在C++11中,因为增加了右值引用(rvalue reference)的概念,所以C++98中的引用都称为了左值引用(lvalue reference)。
使用方法如下所示:
int&& a = 3;
int b = 8;
int& bb = b;
int&& c = b + 5;
AA&& aa = getTemp();
左值引用十分常见,我们知道是给变量取个别名,但是引入右值引用的意义是什么呢?(将在下文中解答)
在上述的代码中,getTemp()的返回值本来在表达式语句结束后,其生命也就该终结了(因为是临时变量),而通过右值引用重获了新生,其生命周期将与右值引用类型变量aa的生命周期一样,只要aa还活着,该右值临时变量将会一直存活下去。
在下面的代码中将帮助我们区分左值引用和右值引用:
void func(T& a);
void func(T&& a);
T var;
T& rvar1 = var;
T& rvar1 = T{};
T&& rvar2 = T{};
T&& rvar2 = var;
T&& rvar2 = std::move(var);
func(var);
func(T{});
func(rvar1);
func(rvar2);
可以看出:
- 当左值引用变量
rvar1
在初始化时,不能绑定右值T{}
, - 当右值引用变量
rvar2
在初始化时,不能绑定左值var
,但是可以通过std::move()
将左值转为右值引用。 - 在代码的最后,右值引用变量
rvar2
作为实参传入func
中时,在作用域内是左值(已命名的右值引用是左值)。
另外,C++还支持了常引用,能够同时接受左值和右值(作为常引用)。
void func(const T& a);
常引用和右值引用 都能接受右值的绑定,有什么区别呢?
- 常引用可以像右值引用一样将右值的生命期延长,但它有一个缺点是,只能读不能改。
现在回到我们的问题:引入右值引用的意义是什么?
如果函数重载能够同时接受:右值引用/常引用参数,则编译器将优先重载:右值引用参数,即引入右值引用的主要目的是实现移动语义。
下面是不同值作为实参传入形参时,函数重载优先级(数字越小优先级越高):
实参/形参 | T& | const T& | T&& | const T&& |
---|
左值 | 1 | 2 | | |
常左值 | | 1 | | |
右值 | | 3 | 1 | 2 |
常右值 | | 2 | | 1 |
引用折叠
在正式学习移动语义(move semantic) 和完美转发std::forward() 之前,我们还要提一嘴引用折叠(reference collapsing),它是移动语义和完美转发的实现基础。
using Lref = Data&;
using Rref = Data&&;
Data data;
Lref& r1 = data;
Lref&& r2 = data;
Rref& r3 = data;
Rref&& r4 = Data{};
总之,只有右值引用折叠到右值引用上仍然是一个右值引用,而其他所有的引用类型之间的折叠都将变成左值引用。
二、移动语义
为什么需要移动语义
我们知道,如果一个对象中有堆区资源,需要编写拷贝构造函数和赋值函数,实现深拷贝。如果被拷贝的对象是临时的,拷贝完就没什么用了,这样会造成没有意义的资源申请和释放操作。如果能将对象包含的资源,直接从旧对象移动到新对象,就可以节省资源申请和释放的时间。C++11新增加的移动语义(move semantic) 就是为了做到这一点(基本类型不包含资源,其移动和拷贝相同。)。
还有另一种情况:如果资源对象本身不可拷贝(如智能指针std::unique_ptr)需要定义移动构造/移动赋值函数,其原理类似。
实现移动语义要增加两个函数:移动构造函数和移动赋值函数。我们通过实现一个简单的string类对象来说明:
class String
{
public:
String()
{
cout << "String类" << this << "的构造函数" << endl;
const char* s = "Hello C++";
int len = strlen(s);
_str = new char[len + 1];
strcpy(_str, s);
}
String(const String& another)
{
cout << "String类" << this << "的拷贝构造" << endl;
int len = strlen(another._str);
_str = new char[len + 1];
strcpy(_str, another._str);
}
String& operator=(String& another)
{
cout << "String类" << this << "的拷贝赋值" << endl;
if (this == &another)
return *this;
int len = strlen(another._str);
_str = new char[len + 1];
strcpy(_str, another._str);
return *this;
}
String(String&& another)noexcept
{
cout << "String类" << this << "的移动构造" << endl;
if(_str != nullptr)
delete[] _str;
this->_str = another._str;
another._str = nullptr;
}
String& operator=(String&& another)
{
cout << "String类" << this << "的移动赋值" << endl;
if (this == &another)
return *this;
if(_str != nullptr)
delete[] _str;
this->_str = another._str;
another._str = nullptr;
return *this;
}
friend ostream& operator<<(ostream& out, String& str)
{
out << str._str;
return out;
}
~String()
{
cout << "String类" << this << "的析构函数" << endl;
if(_str != nullptr)
{
delete[] _str;
_str = nullptr;
}
}
private:
char* _str;
};
上述代码中,编译器会根据传入的实参的优先级(详见上文表格),来决定重载的构造函数。
- 当实参是左值时,使用拷贝构造,拷贝源对象所有的元素。
- 当实参是右值时,使用移动构造,将指向源对象的内存空间的指针“移动”到新对象,并将源对象的指针置空。
- 拷贝/移动赋值函数的原理相同,在此不再过多描述。
测试用例及输出的结果:
int main()
{
{
String str1{};
cout << "str1 = " << str1 << endl;
String str2{str1};
cout << "str2 = " << str2 << endl;
auto f = [] {String aa; return aa;};
String str{};
String str3{move(str)};
cout << "str3 = " << str3 << endl;
String str4{};
str4 = f();
cout << "str4 = " << str4 << endl;
}
system("pause");
return 0;
}
尽管C++11引入了移动语义,但是仍有优化的空间——与其调用一次没有意义的移动构造函数,不如让编译器直接跳过这个过程——于是就有了拷贝省略(copy elision)。
移动语义和拷贝省略的区别:
- 移动语义是语言标准提出的概念。是通过编写遵守移动语义的移动构造函数、右值限定成员函数,在逻辑上优化对象内资源的转移流程。
- 拷贝省略是非标准(C++ 17 前)的编译器优化。跳过移动/拷贝构造函数,让编译器直接在移动后的对象内存上,构造被移动的对象。
由于拷贝省略的存在,在上述代码中,String str3 = f();
会被编译器优化,为了方便演示移动构造函数,我们使用了std::move()
的方法移动返回值,当然这会造成不必要的开销。
三、完美转发
阅读本节需要读者有一定的模板编程基础。
通用引用
C++11中引入了变长模板的概念,允许向模板参数里传入不同类型的不定长引用参数。由于每个类型可能是左值引用或右值引用,针对所有可能的左右值引用组合,特化所有模板 是不现实的。
如果没用通用引用的概念,那么对于一个变长模板函数,至少需要两个重载:
template <typename T, typename ...Args>
void func(T& arg, Args&...args)
{
func(args...);
}
template <typename T, typename ...Args>
void func(T&& arg, Args&&...args)
{
func(std::move(args...));
}
Scott Meyers(Effective Modern C++的作者)指出“有时候符号&&
并不一定代表右值引用,它也可能是左值引用。”
事实上,如果一个引用符号需要通过推导才能得出左右值的类型(如模板参数类型或者auto
),那么这个符号就可以是左值引用或右值引用——这就是通用引用 (universal reference)。
完美转发
这一点我们通过上文中的引用折叠的示例也可以得出。
基于通用引用,我们可以对上述的代码进行改进:
template <typename T, typename ...Args>
void func(T&& arg, Args&&...args)
{
func(std::forward<Args>(args)... );
}
其中std::forward
实现了针对左右值的参数,能保证被转发参数的左、右值属性不变,即完美转发(perfect forwarding)。
在C++11中,完美转发支持:
- 如果模板中(包括类模板和函数模板)函数的参数书写成为
T&& 参数名
,那么,函数既可以接受左值引用,又可以接受右值引用。 - 提供了模板函数
std::forward<T>(参数)
,用于转发参数,如果 参数是一个右值,转发之后仍是右值引用;如果参数是一个左值,转发之后仍是左值引用。
使用示例如下:
void fun1(int& i)
{
cout << "左值 = " << i << endl;
}
void fun1(int&& i)
{
cout << "右值 = " << i << endl;
}
template<class T>
void func(T&& ii)
{
fun1(std::forward<T>(ii));
}
完美转发的语法比较简单,至于其原理本文暂时不深入研究。😝
最后
本文部分参考自文章
本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)