三五法则

今天谈一点轻松点的内容~

整理完移动语义相关的内容后,尽管对如何定义一个类的移动构造函数有所了解,但如何确定一个类需要定义移动构造也是个问题。现在,让我们来考虑这个问题。

零法则

在前面的讨论中,我们已经知道了,如果我们只给类定义构造函数,那么编译器会自动生成析构函数、拷贝构造、拷贝赋值运算符、移动构造、移动赋值运算符,这五个函数。此时,我们需要明确一下我们所设计的类的功能,也就是这个类是否需要管理资源,再直白点就是这个类的成员是否有指针、文件描述符这类东西。
比如,当我们的类中存在指针的时候,我们一般会要拿它来申请堆内存并使用,这时我们可能构造函数中申请,然后在析构函数中释放。看起来很完美,对吧,也确实如此。
此时,我们会发现,若我们的类中不存在管理资源的成员(或者类本身不管理资源),那么我们就可以完全不定义析构函数,只定义自己需要的构造函数即可
这就是“零”法则:如果我们不需要定义析构函数,那么我们也不需要定义拷贝构造、拷贝赋值运算符、移动构造、移动赋值运算符。

三法则

现在再回到当我们的类需要管理资源的情况。
在通常的设计中,如果我们的类用指针来申请内存使用,那我们大概率会使用析构函数来释放资源。此时,由于我们知道了编译器自动生成的拷贝构造函数和拷贝赋值运算符所执行的拷贝操作是浅拷贝,所以我们会自己定义对应函数的深拷贝版本,比如:

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
class myString{
public:
explicit myString(const char* s = ""): m_str(nullptr), len(0) {
if(s) {
len = strlen(s);
m_str = new char[len + 1];
strcpy(m_str, s);
}
}
~myString() {
delete[] m_str;
}
// copy constructor and do deep copy
myString(const myString& rhs): myString(rhs.m_str) { }
// copy assignment operator and do deep copy
myString& operator=(const myString& rhs) {
if(this != &this) {
len = rhs.len;
m_str = new char[len + 1];
strcpy(m_str, rhs.m_str);
}
return *this;
}
const char* cstring() {
return m_str;
}
private:
char* m_str;
int len;
};

在上述代码中,我们定义了一个叫做myString的类,并把这个类的默认构造函数定义为explicit,目的是为了防止隐式转换。这个类通过一个char*指针获取内存空间来存放字符串,所以它需要有一一个析构函数来释放申请的资源。同时,它还需要一个拷贝构造函数和一个拷贝赋值运算符来完善它的拷贝操作,并且这两个函数的拷贝行为都必须要是深拷贝。
此时,我们可以发现这就是三法则:如果一个类定义了析构函数,那么几乎可以肯定也需要定义拷贝构造函数和拷贝赋值运算符。

五法则

再回到 C++11 中,我们已经知道在 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
class myString{
public:
explicit myString(const char* s = ""): m_str(nullptr), len(0) {
if(s) {
len = strlen(s);
m_str = new char[len + 1];
strcpy(m_str, s);
}
}
~myString() {
delete[] m_str;
}
// copy constructor and do deep copy
myString(const myString& rhs): myString(rhs.m_str) { }
// copy assignment operator and do deep copy
myString& operator=(const myString& rhs) {
if(this != &this) {
len = rhs.len;
m_str = new char[len + 1];
strcpy(m_str, rhs.m_str);
}
return *this;
}
// move constructor
myString(myString&& rhs) noexcept{
m_str = rhs.m_str;
len = rhs.len;
rhs.m_str = nullptr;
}
// move assignment operator
myString& operator=(myString&& rhs) {
if(this != &rhs) {
delete[] m_str;
m_str = rhs.m_str;
len = rhs.len;
}
rhs.m_str = nullptr;
return *this;
}
const char* cstring() {
return m_str;
}
private:
char* m_str;
int len;
};

在上述代码中,我们补全了移动构造函数和移动赋值运算符,在这两个函数中,我们都做了资源所有权的转移(交换指针)。但移动赋值运算符明显比移动构造要复杂,这是因为它需要执行两件事情:

  1. 移动赋值运算符与移动构造函数一样,也需要做资源所有权的转移(交换指针)。同时我们也考虑到了自己赋值自己的情况,增加了逻辑判断,以免有多余操作。
  2. 移动赋值运算符与析构函数一样,也需要做资源释放的操作,但它释放的是旧资源。

另外,移动构造函数与拷贝构造函数一样,也可以使用初始化列表,于是我们可以进一步简化:

1
2
3
4
5
// move constructor
myString(myString&& rhs) noexcept
: m_str(rhs.m_str), len(rhs.len) {
rhs.m_str = nullptr;
}

现在我们的myString类就按照三五法则设计好了。
老实说,相比其他语言而言,需要考虑的问题多了不是一点半点。但相比理解那些枯燥的语义而言,设计一个具体的类反而有点快乐。😂

总结

最后我们可以总结下所有内容:

  1. 零法则:一个类只需要构造函数。
  2. 三法则:一个类需要构造函数的同时,也需要析构函数、拷贝构造和拷贝赋值运算符这三个。
  3. 五法则:在三法则的基础上,再加上移动构造和移动赋值运算符这两个。

另外,还有一点想说的内容:对 C++ 而言,编译器会偷偷地帮我们做了很多我们不知道的事情,但这些细节又无法完全对使用 C++ 语言的人隐藏起来。结果,反而衍生出很多莫名其妙的问题,本来倡导的“用户不需要为不知道的特性而复出学习成本”,结果现在成了,为了避免产生未知问题或看懂别人写的代码,即便是自己不用的特性、内容,也必须要学习。🌚

Actually, I think it’s not a good design. 😑


Buy me a coffee ? :)
0%