大家好,我是 同学小张,持续学习C++进阶知识和AI大模型应用实战案例,持续分享,欢迎大家点赞+关注,共同学习和进步。
重学C++系列文章,在会用的基础上深入探讨底层原理和实现,适合有一定C++基础,想在C++方向上持续学习和进阶的同学。争取让你每天用5-10分钟,了解一些以前没有注意到的细节。
上次我们了解了右值、右值引用存在的意义:减少不必要的拷贝,提高程序性能。
对于一般的变量,编译器会自动识别是左值还是右值,从而自动选择使用拷贝还是移动操作,减少拷贝次数,提高程序性能。
而对于很明确的左值,有时候我们知道这个左值的生命周期,知道什么时候应该消亡,所以我们知道它在某些场合下的赋值操作是可以不用深拷贝,而直接使用swap操作的。对于这样的左值,编译器就无法识别成右值从而减少拷贝次数了。所以,C++11提供了移动语义,来将左值转换成右值,告诉编译器,对于该变量可以使用移动操作,而非拷贝操作。
本文就来看一看C++11提供的移动语义的原理与使用细节。
文章目录
0. 右值引用回顾
0.1 值的分类
上次(【重学C++】【引用】一文看懂引用的本质与右值引用存在的意义)我们介绍了右值引用,本文,我们再将C++中值的类型细分一下。如下图:
在C++中,我们可以将值分为lvalue
(左值)、prvalue
(纯右值)和 xvalue
(有人称为将亡值)。
左值和右值我们上次已经详细介绍过了。下面解释下将亡值:将亡值从字面上理解,是生命周期将要结束的值。下面是一个可以视作将亡值的例子:
#include <iostream>
#include <utility> // 包含 std::move
class MyObject {
public:
MyObject() {
std::cout << "Constructor called" << std::endl;
}
// 移动构造函数
MyObject(MyObject&& other) noexcept {
std::cout << "Move constructor called" << std::endl;
}
// 复制构造函数
MyObject(const MyObject& other) {
std::cout << "Copy constructor called" << std::endl;
}
// 赋值构造函数
MyObject& operator=(const MyObject& other) {
std::cout << "Copy assignment operator called" << std::endl;
if (this != &other) {
// 这里进行复制操作,具体根据类的成员变量来实现
}
return *this;
}
// 移动赋值构造函数
MyObject& operator=(MyObject&& other) noexcept {
std::cout << "Move assignment operator called" << std::endl;
if (this != &other) {
// 这里进行移动操作,具体根据类的成员变量来实现
}
return *this;
}
~MyObject() {
std::cout << "Destructor called" << std::endl;
}
};
MyObject createObject() {
// 返回一个临时的 MyObject 对象,这是一个将亡值
return MyObject();
}
int main() {
// 调用 createObject() 返回一个将亡值,并将其移动到一个新的 MyObject 对象中
MyObject obj = createObject();
// 调用复制构造函数
MyObject copyObj = obj;
return 0;
}
主要看 main 函数,我们createObject创建了一个 obj 实例,然后将 obj 实例复制给了 copyObj。假设下文直到函数结束,obj实例已经不再使用了,那我们可以认为obj是个将亡值。
正如本文开头说的,有时候我们知道这个左值的生命周期,知道什么时候应该消亡,所以我们知道它在某些场合下的赋值操作是可以不用深拷贝,而直接使用swap操作的。对于这样的将亡值,我们需要手动告诉编译器,它可以作为右值使用。手动告诉编译器的方法,是使用 std::move 函数,将左值转化为右值。
0.2 右值引用一定是右值吗?
例子参考:https://mp.weixin.qq.com/s/35Jbt-vroWhxTk0SSyhgSQ
对于以下代码,foo函数中x1 = x
调用的是复制还是移动版本的函数?
class X {
public:
// 复制版本的赋值函数
X& operator=(const X& rhs);
// 移动版本的赋值函数
X& operator=(X&& rhs) noexcept;
};
void foo(X&& x) {
X x1;
x1 = x;
}
答案是调用的 复制版本的函数。
如果将 foo 函数变成下面这样,就变成了调用 移动版本的函数。
X bar();
// 调用X& operator=(X&& rhs),因为bar()返回的X对象没有关联到一个变量名上
X x = bar();
- 下面解释原因:
只要一个右值引用有名称,那对应的变量就是一个左值,否则,就是右值。
带着这句话,看第一个实现:函数foo
的入参X&& x
虽然是右值引用,但有变量名x
,所以x
是一个左值,所以operator=(const X& rhs)
最终会被调用。而第二个实现,bar()
的返回值是没有名称的,所以是一个右值。
- C++这样设计的原因:以上面foo函数为例,如最后一行,右值引用有名称,就能被程序员截获,程序员就能任意使用,所以前面为了保险起见就不能被移动。
void foo(X&& x) {
X x1;
x1 = x;
....
x = xxx // 右值引用有名称,就能被程序员截获,程序员就能任意使用,所以前面为了保险起见就不能被移动。
}
1. 移动语义 - std::move
1.1 基本使用和意义
还是以上面的代码为例,我们来看下使用 std::move 与不使用 std::move 的区别:
int main() {
// 调用 createObject() 返回一个将亡值,并将其移动到一个新的 MyObject 对象中
MyObject obj = createObject();
// 调用赋值构造函数
MyObject assignedObj;
assignedObj = obj;
// 调用移动赋值构造函数
MyObject movedObj;
movedObj = std::move(obj);
return 0;
}
assignedObj
没有使用std::move
函数,它将调用的是 赋值构造函数,会进行深拷贝操作,拷贝之后内存中存储了两份相同的数据。
movedObj
使用了 std::move
函数,它将调用的是 移动赋值构造函数,不会进行深拷贝操作,而是进行移动(swap
)操作,只是将两者的内存空间进行交换,内存中还是只有一份数据。
如果 MyObject 是一个非常大的类,拷贝一次会非常耗费性能,那使用 std::move 就可以节省很多的拷贝性能消耗。
当然,使用std::move的前提是,你必须非常确定的知道movedObj = std::move(obj);
之后,obj不会再使用。移动之后obj就为空了,再使用会出大问题。
1.2 配套的移动语义函数
在使用std::move时,还要注意的是,你所move的对象支不支持移动语义。对于C++内置的标准类型或STL中的标准容器,肯定是都是支持的,所以对于这些对象来说,直接std::move即可。
而对于自定义的类型来说,需要手动实现相应的移动语义函数,这样才能实现真正的移动操作。
还是以上的示例代码,MyObject类中,必须实现如下类似的移动构造函数,这样构造函数才能接收右值,从而调用到此函数中实现移动操作而非复制操作:
// 移动构造函数
MyObject(MyObject&& other) noexcept {
std::cout << "Move constructor called" << std::endl;
}
// 移动赋值构造函数
MyObject& operator=(MyObject&& other) noexcept {
std::cout << "Move assignment operator called" << std::endl;
if (this != &other) {
// 这里进行移动操作,具体根据类的成员变量来实现
}
return *this;
}
1.3 移动需要保证异常安全
移动构造/赋值函数都要在函数声明处加关键字 noexcept
,这是向调用者表明,我们的移动函数不会抛出异常。只有这样,调用方才会放心地使用我们定义的移动构造/赋值函数。
比较经典的场景是std::vector 扩缩容。当vector由于push_back、insert、reserve、resize 等函数导致内存重分配时,如果我们自定义的元素提供了一个
noexcept
的移动构造函数,vector会调用该移动构造函数将元素移动到新的内存区域;否则,则会调用拷贝构造函数,将元素复制过去。
1.4 不需要std::move的场景 - 返回值优化
对于以下代码,你觉得哪个效率高?
MyObject createObject1() {
return std::move(MyObject());
}
MyObject createObject() {
return MyObject();
}
int main() {
// 调用 createObject() 返回一个将亡值,并将其移动到一个新的 MyObject 对象中
MyObject obj = createObject();
}
答案可能与你想的不一样。createObject
的效率更高,而加了 std::move
的createObject1
反而效率低一点。运行结果如下:
这个原因是:现代C++编译器会有返回值优化。编译器将直接在
createObject
返回值的位置构造对象,而不是在本地构造然后将其复制出去。很明显,这比在本地构造后移动效率更快。
2. 总结
2.1 移动语义的概念
看了这么多,应该或多或少对移动语义有点感性的认识了,下面总结一下移动语义是什么:
C++中的移动语义是一种优化技术,它允许将资源(如内存、文件句柄等)从一个对象转移到另一个对象,而不是复制整个资源。通过移动语义,可以避免不必要的资源拷贝,提高程序的性能和效率。
移动语义的关键在于右值引用和移动构造函数、移动赋值运算符。
移动构造函数和移动赋值运算符允许将一个对象的资源“窃取”或“移动”到另一个对象,而不是进行深层次的资源复制。这是通过使用 std::move() 来将右值引用传递给目标对象实现的。
2.2 std::move的使用注意事项
(1)std::move自定义类型时,自定义类型必须要有移动构造函数、移动赋值构造函数等用来支持移动语义的相应函数实现。
(2)std::move使用时需要确保move的值后面不会再用
(3)移动构造函数、移动赋值构造函数等实现时必须加 noexcept
关键字
(4)注意编译器的返回值优化的场景,这种场景下不需要移动语义
如果觉得本文对你有帮助,麻烦点个赞和关注呗 ~~~
- 大家好,我是 同学小张,持续学习C++进阶知识和AI大模型应用实战案例
- 欢迎 点赞 + 关注 👏,持续学习,持续干货输出。
- +v: jasper_8017 一起交流💬,一起进步💪。
- 微信公众号也可搜【同学小张】 🙏
本站文章一览: