右值引用与移动语义
右值引用的概念
C++中所有的表达式和值,要么是左值,要么是右值。
通俗的来说,左值指可以使用&取得其地址的“非临时对象”,而右值则是指不可用&取得其地址的“临时对象”。
类比于左值引用,右值引用也仅可以使用右值赋值。我们用T &&来表示T类型的右值引用
int &&rval1 = 1; // 正确,1是右值,可以赋值给右值引用
int a = 1;
int &&rval2 = a; // 错误,a是左值,不可以赋值给右值引用
void f(int &a) {
std::cout << "Left Version" << std::endl;
}
void f(int &&a) {
f(a); // 这里a已经成为一个int &&类型的左值,使用左值版本
std::cout << "Right Version" << std::endl;
}
int main() {
int a = 0;
f(a); // 左值版本
f(a + 1); // 注意:表达式(临时对象)也是右值,所以这里是右值版本
}
编译器返回值优化技术,简称RVO,功能是消除为保存函数返回值而创建的临时对象,使用-fno-elide-constructors关闭此优化,然后执行下面的用例观察其行为。
string foo(void) {
return "hello";
}
void bar(string & s) {
std::cout << s << std::endl;
}
void sayhi(string && s) {
std::cout << s << std::endl;
}
sayHi(std::string("hello")); //右值引用
bar(foo()); //编译报错
c++标准规定,编译器自行构造的临时对象一定是const的。foo()返回一个临时对象,而bar需要一个左值引用,其试图将一个 const 类型的对象转换为非 const 类型,这是非法的。const &是可以重新构造临时对象,非const &不可以,所以解决方法是修改bar参数为(const string & s)或(string && s)
结论
引用型参数应该在能被定义为 const 的情况下,尽量定义为const的。
发生拷贝构造的三个时机以及为什么拷贝构造是常引用传参?
- 用类的一个对象去初始化另一个对象时
- 当函数的形参是类对象值传递,如果是引用传递则不会调用
- 当函数的返回值是类对象(非引用)时
示例CopyCtr
//Test为类名
Test& show(Test t) {
return t;
}
Test t3(show(Test()));
/* Test() 默认构造
* show() 传参时调用拷贝构造(情况2)
* show() 返回时调用拷贝构造(情况3)
* t3() 接收show()返回值调用拷贝构造(情况1)
*/
- 如果拷贝构造参数是值传递,那么在建立这个参数的时候也需要调用拷贝构造,会陷入无穷递归。
- 如果拷贝构造参数是non-const引用传递,对于上述情况3,c++标准规定函数返回的,由编译器自行构造的临时对象一定是const,无法作为拷贝构造(情况1)的参数。
为什么只有赋值/移动赋值需要自身赋值判断,而构造/拷贝构造/移动构造都不需要?
因为只有当对象不存在时才会调用构造,否则只会调用赋值
示例Move
class Test {
public:
Test(int a = 0) {//普通构造函数
d = new int(a);
cout << "构造函数" << endl;
}
Test(const Test &rhs) {//拷贝构造函数
d = new int;
*d = *(rhs.d);
cout << "拷贝构造函数" << endl;
}
Test &operator=(Test &rhs) noexcept {//拷贝赋值函数
if (this == &rhs) return *this;
d = new int(*(rhs.d));
cout << "拷贝赋值函数" << endl;
return *this;
}
#if 0
Test(Test &&tmp) noexcept {//移动构造函数
d = tmp.d;
tmp.d = nullptr;
cout << "移动构造函数" << endl;
}
Test &operator=(Test &&rhs) noexcept {//移动赋值函数
if (this == &rhs) return *this;
d = rhs.d;
rhs.d = nullptr;
cout << "移动赋值函数" << endl;
return *this;
}
#endif
~Test() {//析构函数
if (d != nullptr) {
delete d;
cout << "delete d" << endl;
}
cout << "析构函数" << endl;
}
int *d;
};
Test GetTmp() {
Test t;
return t;
}
在未定义移动语义的情况下
执行Test obj = GetTmp() 输出如下:
构造函数
拷贝构造函数
delete d
析构函数
拷贝构造函数
delete d
析构函数
delete d
析构函数
分析:
GetTmp函数里先构造t,在返回时以t为参数调用拷贝构造来构造临时对象返回值(假设为tmp),之后t销毁,然后在以tmp为参数调用拷贝构造来生成obj,然后tmp销毁,程序结束后obj销毁。
在未定义移动语义的情况下
执行Test&& obj = GetTmp() 输出如下:
构造函数
拷贝构造函数
delete d
析构函数
delete d
析构函数
分析:
GetTmp函数里先构造t,在返回时以t为参数调用拷贝构造来构造临时对象返回值(假设为tmp),之后t销毁,然后定义tmp的右值引用obj(obj本身是个左值,这一步没有调用任何构造),tmp的生命周期被延长,程序结束后obj(即tmp)销毁。
在定义移动语义的情况下
执行Test&& obj = GetTmp() 输出如下:
构造函数
移动构造函数
析构函数
delete d
析构函数
分析:
GetTmp函数里先构造t,在返回时以t为参数调用移动构造函数来构造临时对象返回值(假设为tmp),之后t销毁,然后定义tmp的右值引用obj(obj本身是个左值,这一步没有调用任何构造),tmp的生命周期被延长,程序结束后obj(即tmp)销毁。对比上一种情况,这里析构时只释放了一次资源(d, 说明也只申请了一次资源)。
为什么需要move语义
示例Move显示拷贝构造和赋值操作生成的临时对象,其创建创建、拷贝以及销毁对性能有严重影响。
move语义(通过右值引用支持)可以将资源(堆,系统对象等)从一个对象转移到另一个对象,这样能够减少不必要的临时对象的创建、拷贝以及销毁,能够大幅度提高 C++ 应用程序的性能。
move语义的定义约束
- 参数(右值)的符号必须是右值引用符号,即“&&”。
- 参数(右值)不可以是常量,因为我们需要修改右值。
- 参数(右值)的资源链接和标记必须修改,否则,右值的析构函数就会释放资源,转移到新对象的资源也就无效了。
std::move
std::move是一个模板函数,作用不是真的“移动”对象,是将参数强制转换为右值。这样配合移动构造函数和移动赋值运算符,我们就可以实现移动语义。
真正的“移动”是通过移动赋值运算符和移动构造函数进行的,与std::move并没有关系,他只是在“显式地声明放弃控制权”。
std::string a { "hello, world" };
std::string b = std::move(a);
//just like this
std::string b = static_cast<std::string &&>(a);
注意
- 若不手动地声明移动构造函数和移动赋值操作符,那么C++将会使用拷贝语义的版本。
- POD对象不适合使用移动。因为无论如何,POD对象中的成员仍需要被复制。
- 资源管理对象应当配置移动语义。一般而言这些类都会被声明为“不可复制”的,配置移动语义可以使得操作这个类更加方便。
- 需要大量的额外资源的对象可以配置移动语义,这样将提供减少不必要赋值的机会。
当且仅当“移动”比“拷贝”更迅速时才应当使用,若两者时间相差不大甚至“拷贝”更迅速时,采用“拷贝”是更好的选择。
参考文章
https://blog.csdn.net/hepangda/article/details/88562995
https://tennysonsky.blog.csdn.net/article/details/77159204