Cpp 进阶笔记——2.移动语义
手动创建将亡值
我们继续前面的函数传参的例子,但是我们做一些修改:调用方提供的不再是一个临时对象,而是一个普通的局部对象
1
2
3
4
5
6
7
8
9
10
11
12
13void ProcessBuf(Buffer buf) {
for (int i = 0; i < buf.size(); i++) { buf.at(i) = 2 * i; }
for (int i = 0; i < buf.size(); i++) { std::cout << buf.at(i) << " "; }
std::cout << "\n";
}
void test2() {
Buffer a{5};
a.at(0) = 100;
ProcessBuf(a);
}
程序运行结果如下(这里无所谓是否关闭优化,因为编译器并不敢进行优化)
1
2
3
4
5call constructor // 在test2函数中,构造局部变量a
call copy constructor // 给ProcessBuf函数传参的过程,基于a拷贝构造参数对象buf
0 2 4 6 8
call destructor // ProcessBuf函数调用结束,参数对象buf自动析构
call destructor // test2函数调用结束,局部变量a自动析构
如果我们将ProcessBuf
函数的参数类型改为右值引用,程序会直接编译报错
1
2
3
4
5
6
7
8
9
10
11
12
13void ProcessBuf(Buffer &&buf) {
for (int i = 0; i < buf.size(); i++) { buf.at(i) = 2 * i; }
for (int i = 0; i < buf.size(); i++) { std::cout << buf.at(i) << " "; }
std::cout << "\n";
}
void test2() {
Buffer a{5};
a.at(0) = 100;
ProcessBuf(a); // compile error
}
原因很简单,我们在函数传参中相对于执行了如下语句 1
Buffer &&buf = a;
但是正如前文所说,右值引用不能绑定普通变量,只能绑定将亡值。
这在语法上其实是非常合理的:我们不能让右值引用buf
“夺舍”一个普通的变量,万一这个变量在函数调用之后还会使用呢?
但是如果我确实有这样的特殊需求:
- 明确保证普通变量在调用方这一侧已经完全不打算用了,主动将其视作将亡值;
- 我希望把
a
的内存控制权直接移交给ProcessBuf
函数中的buf
参数,从而节约传参过程的成本。
简而言之,我希望手动让一个变量强行变为将亡值,在语法上可以做到吗?答案是既可以也不可以。
我们先说可以的部分,直接将变量强制类型转换为右值引用就可以让编译通过了,而且不会发生额外的拷贝构造
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16void ProcessBuf(Buffer &&buf) {
for (int i = 0; i < buf.size(); i++) { buf.at(i) = 2 * i; }
for (int i = 0; i < buf.size(); i++) { std::cout << buf.at(i) << " "; }
std::cout << "\n";
}
void test2() {
Buffer a{5};
a.at(0) = 100;
ProcessBuf(static_cast<Buffer &&>(a));
// a已经被我们用右值引用“夺舍”了,我们不应该再次使用它,否则是未定义行为
std::cout << "a should not be used again\n";
}
此时我们的程序可以正常编译执行,运行结果如下 1
2
3
4call constructor // 在test2函数中,构造局部变量a
0 2 4 6 8
a should not be used again
call destructor // test2函数调用结束,局部变量a自动析构
我们再说不可以的部分,上述操作只是欺骗编译器的一种文字游戏而已:
- 局部变量
a
看起来被右值引用“夺舍”了,但是我们实际上仍然可以使用它,只是不应该使用它,否则行为未定义; - 局部变量
a
的析构时机没有被影响,仍然是在test2
函数调用后才被自动析构,右值引用buf
的生命周期与其并不一致。
std::move
上面使用强制类型转换的语法不太优雅,C++直接提供了std::move
函数来简化我们的使用,下面是MSVC的源码
1
2
3
4template <class T>
constexpr remove_reference_t<T>&& move(T&& Arg) noexcept {
return static_cast<remove_reference_t<T>&&>(Arg);
}
std::move
就是帮我们将普通类型强制类型转换为右值引用类型,仅此而已,当然了为了实现这个功能,代码中还用到了一些类型转换工具和万能引用,对于万能引用的讨论见后面的笔记。
在前面的例子中,使用std::move
是完全一样的效果
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16void ProcessBuf(Buffer &&buf) {
for (int i = 0; i < buf.size(); i++) { buf.at(i) = 2 * i; }
for (int i = 0; i < buf.size(); i++) { std::cout << buf.at(i) << " "; }
std::cout << "\n";
}
void test2() {
Buffer a{5};
a.at(0) = 100;
ProcessBuf(std::move(a));
// a已经被我们用右值引用“夺舍”了,我们不应该再次使用它,否则是未定义行为
std::cout << "a should not be used again\n";
}
std::move
所体现的就是移动语义:
- 将一个对象手动变成将亡对象,主动交出控制权,使得右值引用可以绑定并接管它;(实际上并没有影响它的生命周期)
- 被移动的对象不应该被再次使用,除非对变量重新赋值,否则行为未定义。
被移动之后的对象处于一个非常特殊的状态,此时只有对它赋予新值和析构的操作是合法的,其它操作全都是未定义行为!在赋值之后又会恢复到正常状态。
std::move
只是编译期的处理,不会在运行期引入任何额外成本。
移动构造和移动赋值
有了右值引用和移动语义之后,我们还必须明确对自定义类型的下面两个行为的语义:
- 通过自定义类型的右值引用(它接管着某个将亡值对象的“遗体”)如何构造正常的对象,
- 通过自定义类型的右值引用,如何赋值给正常对象。
为了实现这两个功能,我们需要为自定义类型提供移动构造函数/移动赋值运算,它们和拷贝构造函数/赋值运算具有对应关系:
- 前者负责支持“移动”语义,后者负责支持“拷贝”语义;
- 对于管理内存资源的自定义类型,前者的行为通常是“浅复制”,后者的行为通常是“深复制”,这代表前者通常不会遇到内存分配失败等意外情况;
- 赋值和移动赋值都需求考虑自己给自己赋值的特殊情况。
延续上一篇的例子,下面是移动构造和移动赋值的使用示例
1
2
3
4
5
6
7
8
9
10void Demo() {
Buffer buf1{16};
// 移动构造
Buffer buf2(std::move(buf1)); // 把buf1强制“将亡”,但用它的“遗体”构造新的buf2
Buffer buf3{8};
// 移动赋值,因为已经构造过了
buf3 = std::move(buf2); // 把buf2强制“将亡”,把“遗体”转交个buf3,buf3原本的东西被丢弃了
}
自定义类型的这四个函数通常具有如下形式 1
2
3
4
5Buffer(const Buffer &ob); // 拷贝构造
Buffer &operator=(const Buffer &ob); // 赋值
Buffer(Buffer &&ob); // 移动构造
Buffer &operator=(Buffer &&ob); // 移动赋值
为了支持移动语义,我们提供的这两个函数需要满足如下要求:
- 移动会让当前对象接管所有的资源,并且保证被移动对象的析构操作是合法但无害的:
- 合法是指不会产生任何错误或异常,通常需要将管理资源的指针置空
- 无害是指不会对当前对象产生任何影响
- 虽然语法上允许移动操作抛异常,但是考虑到性能因素,建议不要在其中抛出任何异常,并且明确标记
noexcept
Buffer
类型的移动构造和移动赋值的具体实现如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20Buffer::Buffer(Buffer &&ob) noexcept : m_buf_size(ob.m_buf_size), m_buf(ob.m_buf) {
PRINT_INFO("call move constructor");
ob.m_buf_size = 0;
ob.m_buf = nullptr;
}
Buffer::Buffer &operator=(Buffer &&ob) noexcept {
PRINT_INFO("call move assignment operator");
if (this == &ob) { return *this; }
deallocate();
m_buf_size = ob.m_buf_size;
m_buf = ob.m_buf;
ob.m_buf_size = 0;
ob.m_buf = nullptr;
return *this;
}
作为对比,下面是拷贝构造和赋值的具体实现 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16Buffer::Buffer(const Buffer &ob) {
PRINT_INFO("call copy constructor\n");
allocate(ob.m_buf_size);
memcpy(m_buf, ob.m_buf, sizeof(int) * ob.m_buf_size);
}
Buffer::Buffer &operator=(const Buffer &ob) {
PRINT_INFO("call copy operator\n");
if (this == &ob) { return *this; }
deallocate();
allocate(ob.m_buf_size);
memcpy(m_buf, ob.m_buf, sizeof(int) * ob.m_buf_size);
return *this;
}
对于自定义类型,编译器可能会为我们自动生成相应的移动构造和移动赋值运算,具体细节这里不作讨论。 为了安全考虑,对于和
Buffer
类似的资源管理类,最好自行提供所有的特殊方法:构造+析构,拷贝构造+赋值,移动构造+移动赋值。