理解移动语义

试图理解令人困惑的移动语义~

❗ 前排提示:在阅读这篇文章之前,你必须理解了引用,且知道知道左值与右值。

前言

移动语义(move semantics)很早就出现了,但直到 C++11 才被正式引入。在 C++11 之前,C++ 就已经存在两种其他语义——分别是值语义(value semantics)对象语义(object semantics,也叫引用语义,reference semantics)。在正式开始理解移动语义之前,我们可以先理解另外两种。

值语义

值语义(value semantics)指的是对象的拷贝与原对象无关。C++ 的内置类型、标准库(STL)容器等等都是值语义,拷贝之后会得到一个与原对象完全无关的对象,它们都有各自的内存空间和地址,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>
#include <vector>

int main() {

int a = 10;
int b = a;
// 尽管 a 和 b 的值是相等的,但 b 是 a 的拷贝,它们具有单独的内存空间
std::cout << &a << " " << &b << std::endl;
std::cout << a << " " << b << std::endl;

// 同理,v1 和 v2 是相等(内部元素相同)的,v2 是 v1 的拷贝,它们也具有单独的内存空间
std::vector<int> v1{1};
std::vector<int> v2{v1};
std::cout << &v1 << " " << &v2 << std::endl;

return 0;
}
/*
0x9b755ff724 0x9b755ff720
10 10
0x9b755ff700 0x9b755ff6e0
*/

对象语义

对象语义(object semantics)指的是面对对象意义下的对象是禁止拷贝的,或者说一个对象被系统标准的复制方式复制后,其与被复制的对象之间依然共享底层资源,对任何一个改变都将改变另一个。比如 Java 就是典型的对象语义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import java.util.ArrayList;

public class Main {
public static void main(String[] args) {
ArrayList<String> list1 = new ArrayList<String>();
list1.add("xxx");
ArrayList<String> list2 = list1;
list2.add("sss");
for (String s : list1) {
System.out.println(s);
}
}
}
/*
xxx
sss
*/

观察上述代码的结果,我们最后遍历list1时,通过list2添加的String也被遍历出来了。所以,list1list2的底层资源是一样的。
同样的,在 C++ 中,我们也可以用指针或引用完成类似的事情。

移动语义

由来

回到移动语义(move semantics)上,之前在真正理解引用这篇 blog 中,我们已经知道了 C++11 引入右值引用的目的之一就是为了实现移动语义。
现在,我们首先要思考的第一个问题是有了上面两种语义后,为什么还要增加移动语义呢?
一句话来讲就是,避免不必要的拷贝。比如,我们需要将一个容器中的元素交给另外一个容器,这时按照值语义,会发生拷贝现象,如果是内置类型,那么消耗不会很大,但若是比较大的数据,那拷贝带来的性能开销就很大了。
所以,我们需要想一个办法直接将元素“移动”,避免不必要的拷贝,从而提升性能,这就是移动语义要解决的问题和意义所在。
那么 C++11 是如何引入移动语义的呢?答案就是我们前面提到的右值引用(当然,右值引用还有别的用途)。而要引入右值引用,就必须明确左值与右值的区别,为此 C++11 又重新定义了值类别(value category)这个概念(打了很多补丁),而这些都是笔者前面几篇 blog 中的内容,我们都已经有所了解,也足够我们来理解移动语义了。

简单使用

好了,我们现在已经理清楚移动语义的由来和意义了,如何运用呢?
好消息是 C++11 标准库(STL)都是支持移动语义的,所以当我们不想拷贝元素,而只是想将其中一个容器中元素“移动”到另一个元素的时候,我们可以这么做:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>
#include <vector>

int main() {

std::vector<int> v1{1, 2, 3};
std::vector<int> v2;
std::cout << v1.size() << " " << v2.size() << std::endl;
std::cout << &v1[0] << std::endl;
v2 = std::move(v1);
std::cout << v1.size() << " " << v2.size() << std::endl;
std::cout << &v2[0] << std::endl;
return 0;
}
/*
3 0
0x22eada66f00
0 3
0x22eada66f00
*/

在上述代码中,我们使用std::move()函数“得到”了v1的资源,并通过赋值将v1的资源所有权转移给了v2。同时,我们比较了两个容器在“移动”前后的大小和首元素的地址,发现首元素的地址是不变的,换句话说,移动语义其实压根没有真的移动元素,它所作的事情在整个过程中只是将资源的所有权进行转移而已,这也是我们前面总时用带引号的移动来描述的原因。

类中的实现

标准库支持移动语义固然很好,但 C++ 是一个面向对象的语言,我们不可避免的会设计自己的类,使用自己设计的类的对象,那又要如何支持移动语义呢?
针对这方面的需求,C++11 又引入移动构造函数移动赋值运算符来帮助我们实现这个目的,现在我们来尝试实现一个简单的带有移动构造的类。
首先,我们应该已经能很熟练写一个简单的类了,包括它的构造函数、析构函数、拷贝构造函数和拷贝赋值运算符

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class A {
public:
A() {
std::cout << "A constructor" << std::endl;
}
A(const A& rhs) {
std::cout << "A copy constructor" << std::endl;
}
A& operator=(const A& rhs) {
std::cout << "A copy assignment operator" << std::endl;
return *this;
}
~A() {
std::cout << "A destructor" << std::endl;
}
};

在 C++11 之前,我们定义一个类所需要的内容基本就是这些,有了这些,一个类就能完成基本的工作了。同时,如果我们没有自己定义,编译器也会默认帮我们生成,并且在 C++11 我们还可以偷懒,选择直接使用编译器默认生成的,只需要在函数后面加上= default即可,比如:

1
2
// 使用编译器默认生成的构造函数
A() = default;

但默认生成的可能不太够用,也容易导致问题,所以一般还是自己写。
实际上,在 C++11 中,如果我们什么也不写,只定义一个空类,编译器会帮我们生成默认的构造函数、析构函数、拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符。
可一旦我们自己定义了析构函数、拷贝构造函数或者拷贝赋值运算符(三个之中任意一个),编译器就不会自动生成移动构造函数和移动赋值运算符了。
所以,我们最好全部自己定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
#include <iostream>
#include <utility>

class A {
public:
A() {
std::cout << "A constructor" << std::endl;
}
A(const A& rhs) {
std::cout << "A copy constructor" << std::endl;
}
A& operator=(const A& rhs) {
std::cout << "A copy assignment operator" << std::endl;
return *this;
}
A(A&& rhs) noexcept {
// do some move operation
std::cout << "A move constructor" << std::endl;
}
A& operator=(A&& rhs) noexcept {
// do some move operation
std::cout << "A move assignment operator" << std::endl;
return *this;
}
~A() {
std::cout << "A destructor" << std::endl;
}
};

int main() {
A a; // 调用默认构造
A aa(a); // 调用拷贝构造
aa = a; // 调用拷贝赋值运算符
A aaa(std::move(a)); // 调用移动构造
A aaaa; // 调用默认构造
aaaa = std::move(a); //调用移动赋值运算符
return 0;
}

/*
A constructor
A copy constructor
A copy assignment operator
A move constructor
A constructor
A move assignment operator
A destructor
A destructor
A destructor
A destructor
*/

在上述代码中,我们自定义了移动构造函数和移动赋值运算符,并且做了调用测试,结果符合预期。从代码中也可以看出,自定义类的移动构造函数和移动赋值运算符的行为究竟如何,取决于类的作者如何规定。
换句话说,在移动构造函数或移动赋值运算符的实现中写拷贝操作也是合(语)法的,但显然这违背了移动语义的初衷,不能这么干。另外,我们还可以发现,不论是否执行移动构造,对象本身是独立的,其生命周期结束后也会被自动回收掉,所以最后调用了 4 次析构函数。也可以换个角度思考,我们定义的A类对象aaaaaaaaaa本身也是左值,不会被立刻析构掉。

适配标准库

同时,在我们自定义的移动构造函数和移动赋值运算符后面还有一个noexcept的关键字,这会告诉编译器和标准库容器,我自定义的类的移动构造函数和移动赋值运算符,不会抛出异常。为什么要这么设置呢?主要是两点:

  1. 移动构造函数和移动赋值运算符不涉及重新分配内存的操作,只涉及资源所有权的转移,逻辑合理的情况下一定不会出现异常情况,因为我们就是按照这样的思路设计的。
  2. 为了更好的使用标准库容器,就必须与标准库容器兼容。对标准库容器来讲,如果我不知道你的移动构造函数或赋值运算符是否会抛出异常,若在“移动”资源的过程中,突然抛出异常了,不但后续无法进行,之前的状态也无法还原了。与其如此,那不如我直接用拷贝构造函数会更安全。

上面的例子不足以说明noexcept的作用,依据vector会自动扩容的特性,我们再举一个与vector相关的简单例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
#include <iostream>
#include <utility>
#include <vector>

class A {
public:
A() {
std::cout << "A constructor" << std::endl;
}
A(const A& rhs) {
std::cout << "A copy constructor" << std::endl;
}
A& operator=(const A& rhs) {
std::cout << "A copy assignment operator" << std::endl;
return *this;
}
A(A&& rhs) {
// do some move operation
std::cout << "A move constructor" << std::endl;
}
A& operator=(A&& rhs) noexcept {
// do some move operation
std::cout << "A move assignment operator" << std::endl;
return *this;
}
~A() {
std::cout << "A destructor" << std::endl;
}
};

int main() {
std::vector<A> v;
A a;
std::cout << 1 << std::endl;
v.push_back(a);
std::cout << 2 << std::endl;
v.push_back(a);
std::cout << 3 << std::endl;
v.push_back(a);
std::cout << 4 << std::endl;
v.push_back(a);

std::cout << "------------------\n";
return 0;
}

/*
A constructor
1
A copy constructor
2
A copy constructor
A copy constructor
A destructor
3
A copy constructor
A copy constructor
A copy constructor
A destructor
A destructor
4
A copy constructor
------------------
A destructor
A destructor
A destructor
A destructor
A destructor
*/

在上述代码中,我们移除了移动构造函数中的noexcept关键字,发现调用容器的push_back函数时,会自动触发A的拷贝构造,这没什么问题,因为我们在外面定义的A类对象a的生命周期并没有结束,不能被“移动”给容器。而且,我们还发现容器自动扩容时,也会调用拷贝构造函数,在新的容器中生成新的对象,并将原容器中的对象析构掉,这也没有问题,因为前面我们已经说过,标准库在不确定用户提供的类的移动构造是否会抛出异常的情况下,会默认选择更安全的拷贝构造。
现在,我们再看看加上noexcept关键字的情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
#include <iostream>
#include <utility>
#include <vector>

class A {
public:
A() {
std::cout << "A constructor" << std::endl;
}
A(const A& rhs) {
std::cout << "A copy constructor" << std::endl;
}
A& operator=(const A& rhs) {
std::cout << "A copy assignment operator" << std::endl;
return *this;
}
A(A&& rhs) noexcept {
// do some move operation
std::cout << "A move constructor" << std::endl;
}
A& operator=(A&& rhs) noexcept {
// do some move operation
std::cout << "A move assignment operator" << std::endl;
return *this;
}
~A() {
std::cout << "A destructor" << std::endl;
}
};

int main() {
std::vector<A> v;
A a;
std::cout << 1 << std::endl;
v.push_back(a);
std::cout << 2 << std::endl;
v.push_back(a);
std::cout << 3 << std::endl;
v.push_back(a);
std::cout << 4 << std::endl;
v.push_back(a);

std::cout << "------------------\n";
return 0;
}

/*
A constructor // 构造局部对象 a
1
A copy constructor // 拷贝到容器的第一个元素
2
A copy constructor // 容器自动扩容,拷贝到新容器的第二个元素
A move constructor // “移动”旧元素到新容器,新元素拥有对资源的所有权
A destructor // 旧元素已经不再拥有资源的所有权,析构旧元素
3
A copy constructor // 新容器再次自动扩容,拷贝到新新容器的第三个元素
A move constructor // “移动”旧元素到新新容器,新元素拥有对资源的所有权
A destructor // 旧元素已经不再拥有资源的所有权,析构旧元素
A move constructor // “移动”旧元素到新新容器,新元素拥有对资源的所有权
A destructor // 旧元素已经不再拥有资源的所有权,析构旧元素
4
A copy constructor // 无需扩容,拷贝到新新容器的第四个元素
------------------ // 5 次析构
A destructor
A destructor
A destructor
A destructor
A destructor
*/

详细的结果分析已经写在代码的结果注释中,对比前一个例子,我们会发现容器扩容时使用的是移动构造函数,省去了一些不必要的拷贝操作和析构操作,避免了不必要的性能开销,这符合我们的预期,也正是引入移动语义的意义所在。
最后,还应当指出的是标准库容器的移动赋值运算符也需要使用的noexcept

注意事项

为了更规范的使用移动语义,我们还需要知道一些事项:

  1. 编译器默认生成的移动构造函数和移动赋值运算符会按照类的成员顺序生成,类似下面这种:

    1
    2
    3
    4
    5
    6
    7
    class A {
    A(A&& rhs) noexcept
    : a(std::move(rhs.a)), s(std::move(rhs.s)) { }
    private:
    int a;
    std::string s;
    };
  2. 当一个对象被移动后,该对象本身还是有效的,比如上面例子中vector容器扩容时会调用移动构造,结束后就会自动析构掉原来的对象,因为那些对象已经没有意义。

  3. 避免不必要的std::move函数调用(这个话题其实放在std::move函数下更好一点),如果一个类没有定义移动构造函数,且当某个函数返回这个类的对象时,默认开启 RVO(return value optimization)优化的编译器,会直接将这个值返回。如果加上了std::move函数,反而使得编译器调用拷贝构造后返回。总之,就是不要写出下面这样的代码:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    class A {
    public:
    ~A() { } // 自定义析构函数后,编译器不会自动生成移动构造函数和移动赋值运算符
    };

    A func() {
    A a;
    return std::move(a); // 编译器会调用拷贝构造再返回这个值
    }

总结

好了,经过长篇大论的讨论,我们终于算是讲完了移动语义,但本文中其实对移动语义的介绍并不深入,也没有具体的实践案例,只能算是勉勉强强说明了问题吧。
回顾一下我们主要做的事情:

  1. 我们知道了移动语义的由来,知道它是一种语义,是一种编程思想(或者说设计),不能只局限于某一门语言。
  2. 我们知道了 C++11 是如何引入移动语义的,知道了如何自己该如何实现移动语义,明白了移动语义其实根本没有真的移动,只是在做资源所有权的转移。但具体实现过程中,是否真的需要转移,取决于用户自己的实现。
  3. 我们知道了 C++11 标准库支持移动语义,所以它的实现极其高效,也知道了如何将自己的类与标准库一起使用。
  4. 我们知道了使用移动语义时的一些注意事项,当然,这部分还需要我们自身用更多的工程实践来补全。

最后,再给出一些参考链接,有很多人写的文章都很不错。
PS:本来写给自己看的东西,不想写这么啰嗦的,没想到还是写了这么...😂


Buy me a coffee ? :)
0%