移动语义
左值与右值
左值和右值是针对表达式而言的,左值是指表达式执行结束后依然存在的持久对象,右值是指表达式执行结束后就不再存在的临时对象。
那如何进行区分呢?
其实也简单,能对表达式取地址的,称为左值;不能取地址的,称为右值。
在实际使用过程中,字面值常量、临时对象(匿名对象)、临时变量(匿名变量),都称为右值。右值又被称为即将被销毁的对象。
- 字面值常量,也就是10, 20这样的数字,属于右值,不能取地址。
- 字符串常量,“world",是属于左值的,位于内存中的文字常量区。
关于存储区域
右值的存储位置
关于右值的存储位置,它们可以存储在内存中,也可以仅存在于寄存器中,这取决于具体的实现和上下文。编译器优化策略在很大程度上影响了这一点:
- 在内存中存储: 尽管右值通常被视为临时的,但它们可以在内存中创建并存储,尤其是当它们是较大的对象或者编译器决定这样做更高效时。例如,一个复杂的右值对象(比如一个大的临时结构体或对象)可能会在内存中分配空间,以便存储其状态。
- 仅存在于寄存器中: 对于简单的右值(如基本数据类型的算术表达式结果),编译器可能会选择将其存储在寄存器中以优化性能。寄存器的使用减少了内存访问的需要,可以加快程序的执行速度。当一个右值用于简单表达式或作为函数参数传递时,这种情况更常见。
优化和存储决策
C++标准并没有具体规定对象必须存储在内存还是寄存器中,这留给了编译器作为实现细节。现代编译器使用复杂的优化策略来决定何时在内存中分配空间以及何时使用寄存器。这些决策基于减少程序的总运行时间和内存使用,同时还要满足程序的语义要求。
因此,是否一个右值会短暂存储在内存中或只会存在寄存器中,取决于多种因素,包括但不限于右值的类型、大小、上下文以及编译器的优化策略。在实际编程中,除非在性能调优阶段需要深入了解这些细节,否则开发者通常不需要过分关注这一点。
试试看下面这些取址操作和引用绑定操作是否可行:
void test1() {
int a = 1, b = 2;
&a;
&b;
&(a + b);
&10;
&String("hello");
// 非const引用尝试绑定
int &r1 = a;
int &r2 = 1;
// const引用尝试绑定
const int &r3 = 1;
const int &r4 = a;
String s1("hello");
String s2("wangdao");
&s1;
&s2;
&(s1 + s2);
}
如上定义的 int & r1
和 const int & r3
叫作左值引用与const左值引用
非const左值引用只能绑定到左值,不能绑定到右值,也就是非const左值引用只能识别出左值。
const左值引用既可以绑定到左值,也可以绑定到右值,也就是表明const左值引用不能区分是左值还是右值。
——希望能够区分出右值,并且还要进行绑定
就是为了实现 String s3 = "hello"
的空间复用需求。
右值引用
C++11中引入了&&
代表一种新的数据类型。引入这种数据类型一方面可以提高程序的运行效率,把拷贝对象改为移动对象来提高运行效率;另一方面是来应付移动构造函数和移动运算符的。
右值引用就是引用右值,也就是说绑定到右值(必须是绑定到右值的引用)。我们可以把右值引用理解成一个对象的名字,但是要明确的是右值引用也是一种引用,其次&&
运算符希望用右值引用来绑定一些即将销毁的对象或者临时对象。
std::move函数
std::move()
是C++ 11标准库中的新函数,虽然函数名是移动的意思但是这个函数并不会做移动的操作,函数的功能只有一个就是将一个左值强制转换为右值,带来的结果就是一个右值引用可以绑定上去了。
在使用std::move函数的时间也有一些值得注意的事情,比如下面的语句:
string s1 = "Hello World!";
// string s2 = std::move(s1);
使用一个新的对象来接收move的结果,会调用string的移动构造函数,会将s1中的内容拷贝到新的对象身上并清空s1中的内容使之成为空串,但是如果使用右值引用来接收就是正常的引用的情况,如
string &&s3 = std::move(s1);
s3中的内容会和s1保持同步,修改任一变量另一个变量的内容也会随之改变。
对象移动
对象移动是C++ 11中引入的概念,简单来理解就是把临时对象中或者不再使用的对象中的有用的数据、可以再次使用的数据摘出来放到需要的对象中去,这样我们在构建新对象的时间就不用把所有的数据都重新构建了。
移动构造和移动赋值运算符
移动构造函数和移动赋值运算符也都是C++ 11中引入的新概念,它和前面的右值引用、std::move()
、对象移动等概念引入的目的一样,都是为了解决程序运行效率的问题,以提高程序运行效率为终极目标。
移动构造函数和移动赋值运算符与我们之前学的拷贝构造是比较类似的,可以对比着学习。其中有几个点需要注意:
- 如果把A移动给了B,就不能再继续使用A了(无论是部分移动还是全部移动,A都不再是完整的A了,如果再进行使用会造成程序异常);
- 移动并不是把内存中的数据从一个地址移动到另一个地址,如果只是简单的移动并不会提高程序运行的效率,这里的。
其实右值引用这种类型(或者说这种概念)被创造出来就是为了支持对象移动这种操作,所以C++ 11才创造出来两个引用运算符&&
组成的右值引用这种类型。我们把右值引用看成一种新类型就可以了,这样这部分知识就贯通起来了。
移动构造函数和移动赋值运算符应该完成的功能
- 完成相应资源的移动,斩断内存中数据和源对象的关系;
- 确保移动后源对象处于一种即便被销毁也不会造成其他额外影响的状态。
移动构造函数
首先来看一下我们之前学过的默认构造、拷贝构造、析构函数的执行
#include <iostream>
using namespace std;
class B {
public:
//默认构造函数,默认将m_b初始化为0
B():m_b(0){
cout << "B的默认构造函数执行了" << endl;
}
//拷贝构造函数,将m_b初始化为传入对象中的m_b的值
B(const B& tmpb):m_b(tmpb.m_b){
cout << "B的拷贝构造函数执行了" << endl;
}
//析构函数,因为没有在堆区开辟内存,空实现也不会有什么问题
virtual ~B(){
cout << "B的析构函数执行了" << endl;
}
int m_b;
};
int main() {
B *b1 = new B;
B *b2 = new B(*b1);
delete b1;
delete b2;
system("pause");
return 0;
}
接下来我们改造这段程序,添加一个类A,并在A中添加一个类B对象的指针,然后我们来分析一下类A的默认构造、拷贝构造、析构函数的执行
#include <iostream>
using namespace std;
class B {
public:
B():m_b(0){}
B(const B& tmpb):m_b(tmpb.m_b){}
virtual ~B(){}
int m_b;
};
class A {
public:
A():m_b(new B){
cout << "类A的默认构造函数执行了" << endl;
}
A(const A& tmpa):m_b(new B(*(tmpa.m_b))){
cout << "类A的拷贝构造函数执行了" << endl;
}
virtual ~A(){
delete m_b;
cout << "类A的析构函数执行了" << endl;
}
private:
B* m_b;
};
static A getA() {
A tmpa;
return tmpa; //临时对象,会调用拷贝构造函数
}
int main() {
A a1 = getA();
return 0;
}
接下来我们改造这段程序,添加一个类A,并在A中添加一个类B对象的指针,然后我们来分析一下类A的默认构造、拷贝构造、析构函数的执行
#include <iostream>
using namespace std;
class B {
public:
B():m_b(0){}
B(const B& tmpb):m_b(tmpb.m_b){}
virtual ~B(){}
int m_b;
};
class A {
public:
A():m_b(new B){
cout << "类A的默认构造函数执行了" << endl;
}
A(const A& tmpa):m_b(new B(*(tmpa.m_b))){
cout << "类A的拷贝构造函数执行了" << endl;
}
A(A&& tmpa) :m_b(tmpa.m_b) { //建立与传入对象中有用数据的关系
tmpa.m_b = nullptr; //截断传入的对象与相应内存地址中的关系
cout << "类A的移动构造函数执行了" << endl;
}
virtual ~A(){
delete m_b;
cout << "类A的析构函数执行了" << endl;
}
private:
B* m_b;
};
static A getA() {
A tmpa;
return tmpa; //临时对象,会调用拷贝构造函数
}
int main() {
A a1 = getA();
return 0;
}
以上就完成了移动构造函数的实现和使用,但是在定义移动构造函数的时间还有一个关键字noexpect用来告诉关键字这个函数内不抛出任何异常,来进一步优化程序运行效率,将移动构造函数改写为如下形式即可:
A(A&& tmpa) noexcept :m_b(tmpa.m_b) {
tmpa.m_b = nullptr;
cout << "类A的移动构造函数执行了" << endl;
}
一般来说只要实现移动构造函数就会使用关键字noexpect
,这是一种约定俗成的习惯、只要定义移动构造函数就要添加关键字noexpect
。
还有下面几项使用移动构造的情况需要自己明确都是什么作用
A a1 = getA();
A a2(std::move(a1)); //创建了新的对象,并且调用了移动构造函数
A&& a3(std::move(a1)); //没有创建新对象,只是把a1指向的地址添加了一个别名
A a4(getA()); //跟a1的效果一样,创建了对象,并且调用了移动构造函数
A&& a5(getA()); //返回的对象被a5接管了,等于给它起了名字
移动构造函数和拷贝构造函数的区别
两者之间形式上的差别只在函数参数上有区分,构建拷贝构造函数的时间函数参数是一个对象的引用,使用一个引用运算符&
,是左值引用;构建移动构造函数的时间函数参数也是一个对象的引用,但是使用的是两个引用运算符&&
,是右值引用。
移动赋值运算符
移动赋值运算符与拷贝赋值运算符类似,下面请看示例中的定义语法:
#include <iostream>
using namespace std;
class B
{
public:
B(): m_b(0) {}
B(const B& tmpb): m_b(tmpb.m_b) {}
virtual ~B() {}
int m_b;
};
class A
{
public:
A(): m_b(new B)
{
cout << "类A的默认构造函数执行了" << endl;
}
A(const A& tmpa): m_b(new B(*(tmpa.m_b)))
{
cout << "类A的拷贝构造函数执行了" << endl;
}
A(A&& tmpa) noexcept : m_b(tmpa.m_b)
{
tmpa.m_b = nullptr;
cout << "类A的移动构造函数执行了" << endl;
}
A& operator=(const A& tmpa)
{
if (this == &tmpa) {
return *this;
}
delete m_b; //把自己的内存先释放
m_b = new B(*(tmpa.m_b)); //然后使用传入的对象的m_b重新初始化自己的m_b
cout << "类A的拷贝运算符调用了" << endl;
return *this;
}
A& operator=(A&& tmpa) noexcept
{
if (this == &tmpa) {
return *this;
}
delete m_b; //把自己的内存先释放
m_b = tmpa.m_b; //直接使用传入对象的m_b属性初始化自己的m_b
tmpa.m_b = nullptr; //断开源对象与相应内存之间的联系
cout << "类A的移动运算符调用了" << endl;
return *this;
}
virtual ~A()
{
delete m_b;
cout << "类A的析构函数执行了" << endl;
}
private:
B* m_b;
};
static A getA()
{
A tmpa;
return tmpa; //临时对象,会调用拷贝构造函数
}
int main()
{
A a1 = getA();
A a2;
a2 = std::move(a1);
return 0;
}
移动赋值运算符在内部实现的时间一定要注意添加noexpect
关键字,
有一个值的注意的点就是
A a1 = getA();
这句代码按照我们的分析在getA()
的执行过程中会产生临时对象,产生临时对象如果没有实现移动构造函数的情况下会调用拷贝构造函数,实现了移动构造函数的情况下会调用移动构造函数,但是在实际测试运行的时间只有MSVC 2017会调用拷贝构造或者移动构造,gcc、clang以及MSVC 2022会直接优化这一步骤,不会调用拷贝构造或者移动构造函数也不会有相应的析构函数(等同于没有到函数内部进行临时对象的创建)。跟踪调试程序发现getA()
函数内部会创建相应的临时对象,但是函数调用结束临时对象就消亡了。
如果我们不进行手动实现移动构造函数和移动赋值运算符,某些条件下编译器也会自动为我们合成。具体情况如下:
- 在我们提供了拷贝构造函数或者拷贝赋值运算符或者析构函数的情况下(三种情况满足任一情况即可),编译器不会默认合成。
- 如果我们没有自己实现移动构造函数和移动赋值运算符,编译器会使用拷贝构造函数和拷贝赋值运算符来代替。
- 如果我们的类内部没有实现任何的拷贝成员(拷贝构造函数或拷贝赋值运算符),并且类中的非静态成员可以移动时,编译器会默认为我们合成移动成员。
那么什么是成员可以移动呢?
- 内置数据类型是可以移动的
- 类类型成员在类中有移动构造函数或者移动赋值运算符的情况下也是可以移动的
总结
- 尽量给类实现移动成员
- 实现移动成员的时间要添加noexpect关键字
- 该给nullptr就要给,要确保被移动的对象处于随时可以被析构的状态
- 没有实现移动,编译器会使用拷贝代替
完美转发
在 C++11 之前,泛型函数在传递参数时无法保持参数的原始类型(左值或右值),导致额外的拷贝或移动操作。完美转发(Perfect Forwarding)是一种 高效传递参数 的技术,能够 保持参数的原始特性,避免额外的性能开销。
完美转发 是指 在泛型模板函数中,以参数的原始形式(左值或右值)传递给目标函数,从而避免 不必要的拷贝或移动操作。
#include <iostream>
using namespace std;
void process(int& x) { cout << "Lvalue reference: " << x << endl; }
void process(int&& x) { cout << "Rvalue reference: " << x << endl; }
// 泛型函数,使用完美转发
template <typename T>
void forwardExample(T&& arg) {
process(std::forward<T>(arg)); // 关键:std::forward 保持原始类型
}
int main() {
int a = 10;
forwardExample(a); // 传递左值
forwardExample(20); // 传递右值
return 0;
}
输出:
Lvalue reference: 10
Rvalue reference: 20
分析:
std::forward<T>(arg)
让 arg 保持左值或右值特性,从而 正确调用process(int&)
或process(int&&)
。forwardExample(a)
传递左值,std::forward<T>(a)
仍是左值。forwardExample(20)
传递右值,std::forward<T>(20)
仍是右值。
如果去掉 std::forward 会怎样?
template <typename T>
void forwardExample(T&& arg) {
process(arg); // 没有 std::forward
}
输出:
Lvalue reference: 10
Lvalue reference: 20 // 右值变成了左值!
错误分析:
- arg 在
process(arg)
语境中变成了左值,即使forwardExample(20)
传递的是右值,arg 也会 丢失右值特性。 - 结果:
process(int&&)
无法调用,所有右值都会被当成左值,导致 额外拷贝或移动。
std::forward的工作原理
std::forward<T>(arg)
通过 引用折叠(Reference Collapsing)和 类型推导 来决定参数是否应该保留右值特性。
T 传递的类型 | T&& 推导后 | std::forward<T>(arg) 结果 |
---|---|---|
int | int&& | 右值 int&& |
int& | int& && → int& | 左值 int& |
核心规则:
T&&
绑定左值 时,T 会被推导为int&
,最终std::forward<int&>(arg)
仍是左值。T&&
绑定右值 时,T 会被推导为int
,最终std::forward<int>(arg)
仍是右值。
应用场景
传递构造函数参数
class MyClass {
public:
template <typename T>
MyClass(T&& arg) : data(std::forward<T>(arg)) {}
private:
int data;
};
std::forward<T>(arg)
确保 arg 以最佳方式传递给 data,避免不必要的拷贝。
传递函数参数
#include <utility>
void print(const std::string& s) {
std::cout << "Lvalue: " << s << std::endl;
}
void print(std::string&& s) {
std::cout << "Rvalue: " << s << std::endl;
}
// 通过完美转发调用 print
template <typename T>
void callPrint(T&& arg) {
print(std::forward<T>(arg));
}
总结
对比项 | 不使用 std::forward | 使用 std::forward |
---|---|---|
右值传递 | 变成左值,调用左值版本 | 保持右值,调用右值版本 |
左值传递 | 保持左值 | 保持左值 |
额外拷贝 | 可能会有 | 避免拷贝 |
性能 | 可能较低 | 更高效 |