手动创建将亡值

我们继续前面的函数传参的例子,但是我们做一些修改:调用方提供的不再是一个临时对象,而是一个普通的局部对象

1
2
3
4
5
6
7
8
9
10
11
12
13
void 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
5
call 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
13
void 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
16
void 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
4
call 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
4
template <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
16
void 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
10
void 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
5
Buffer(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
20
Buffer::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
16
Buffer::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类似的资源管理类,最好自行提供所有的特殊方法:构造+析构,拷贝构造+赋值,移动构造+移动赋值。