第十章 特殊函数及数量规则
# 第十章 特殊函数及数量规则
# 问题69:解释三法则(the rule of three)
如果一个类需要自定义析构函数、自定义复制构造函数或自定义复制赋值运算符,那么它几乎肯定需要同时定义这三者。
当你按值返回或传递对象、操作容器等时,这些成员函数就会被调用。如果它们没有被自定义,编译器会生成它们(自C++98起)。
自C++98起,编译器会尝试生成:
- 默认构造函数(
T()
),它会调用每个类成员和基类的默认构造函数。 - 复制构造函数(
T(const T& other)
),它会调用每个成员和基类的复制构造函数。 - 复制赋值运算符(
T& operator=(const T& other)
),它会调用每个类成员和基类的复制赋值运算符。 - 析构函数(
~T()
),它会调用每个类成员和基类的析构函数。需要注意的是,这个默认生成的析构函数永远不是虚函数(除非它所在的类继承自一个有虚析构函数的类) 。
如果你有一个类持有原始指针、智能指针、文件描述符、数据库连接或其他管理资源的类型,那么编译器生成的函数很可能是不正确的,你应该手动实现它们。
这就引出了三法则:如果你手动实现了复制构造函数、复制赋值运算符或析构函数中的任何一个,编译器就不会生成你未编写的其他函数。所以,如果你需要编写这三个函数中的任何一个,那么可以认为你也需要编写其余的函数。
这就是C++98引入的三法则,明天我们将介绍五法则。
# 问题70:解释五法则(the rule of five)
前面我们讨论了三法则。你还记得它的内容吗?
让我们回顾一下。
如果你手动实现了复制构造函数、复制赋值运算符或析构函数中的任何一个,编译器就不会生成你未编写的其他函数。这是因为编译器认为,如果你必须实现其中一个,那么几乎可以肯定你也需要其他的函数。
三法则是由C++98引入的,而五法则是由C++11引入的。
多出来的两个是什么呢?
这与C++11引入的移动语义(move semantics)有关。所以,如果你手动实现了以下任何一个特殊函数,那么其他特殊函数都不会被生成,你必须自己实现所有这些函数:
- 复制构造函数:
T(const T&)
- 复制赋值运算符:
operator=(const T&)
- 移动构造函数:
T(X&&)
- 移动赋值运算符:
operator=(T&&)
- 析构函数:
~T()
接下来我们将介绍零法则。
# 问题71:解释零法则(the rule of zero)
在过去的两天里,我们讨论了C++98引入的三法则,以及C++11引入的移动语义所补充的五法则。
让我们回顾一下五法则:
如果你手动实现了以下任何一个特殊函数,那么其他特殊函数都不会被生成,你必须自己实现所有这些函数:
- 复制构造函数:
T(const T&)
- 复制赋值运算符:
operator=(const T&)
- 移动构造函数:
T(X&&)
- 移动赋值运算符:
operator=(T&&)
- 析构函数:
~T()
今天,我们用零法则来结束这一系列规则的小专题。
它是C++核心准则中定义的一条规则的昵称:
C.20:如果你可以避免定义默认操作,那就不要定义。
如果所有成员都有各自的特殊函数,那就无需再定义,一个都不用。
class MyClass {
public:
// . . . 未声明默认操作
private:
std::string name;
std::map<int , int> rep;
};
MyClass mc;
MyClass mc2 {nm};
2
3
4
5
6
7
8
9
10
由于std::map
和std::string
都有所有的特殊函数,所以MyClass
中无需定义任何特殊函数。
其理念是,如果一个类需要声明任何特殊函数,那么它应该专门处理所有权问题,而其他类则不应声明这些特殊函数。
所以请记住,如果你需要任何特殊函数,就要实现所有特殊函数,但首先应尽量避免需要它们。
# 问题72:std::move移动了什么?
std::move
什么都没移动。在运行时,它根本不会执行任何操作,甚至不会生成一个字节的可执行代码。
实际上,std::move
只是一个工具,用于将其输入的任何内容转换为右值引用(rvalue reference)。
因此,std::move
这个名字不太恰当,也许叫rvalue_cast
会更好,但它就叫这个名字,我们只要记住它并不会移动任何东西就行。它返回一个右值引用,而右值引用是移动操作的候选对象。对一个对象使用std::move
,是在告诉编译器这个对象可以被移动。这就是std::move
名字的由来:便于指定哪些对象可以被移动。
值得注意的是,从常量变量进行移动是不可能的,因为移动构造函数(move constructor)和移动赋值(move assignment)会改变执行移动操作的对象。然而,如果你尝试从常量对象进行移动,编译器不会报错,甚至不会给出警告。对常量对象的移动请求会被悄悄地转换为复制操作。
# 问题73:什么是析构函数?如何对它进行重载?
析构函数是类的一个特殊成员函数。它与类名相同,并且前面有一个波浪号(~
)前缀(如~MyClass
)。只要对象超出作用域,如果析构函数存在,它就会自动执行。
析构函数没有参数,不能是const
、volatile
或static
的,并且和构造函数一样,它没有返回类型。
默认情况下,析构函数由编译器生成,但你需要注意五法则的应用。如果手动实现了其他4个特殊函数中的任何一个,析构函数就不会被生成。快速回顾一下,除析构函数外的特殊函数还有:
- 复制构造函数
- 赋值运算符
- 移动构造函数
- 移动赋值运算符
如果类获取了必须释放的资源,就需要析构函数。记住,你应该编写遵循资源获取即初始化(RAII,Resource Acquisition Is Initialization)原则的类,这意味着在构造时获取资源,在析构时释放资源。例如释放连接、关闭文件句柄、保存事务等操作。
如前所述,析构函数没有参数,不能是const
、volatile
或static
的,并且一个类只能有一个析构函数,因此它不能被重载。
另一方面,析构函数可以是虚函数,不过这是明天要讨论的话题。
# 问题74:对于未使用/不支持的特殊函数,是应该显式删除,还是声明为私有?
首先要回答的问题是,为什么要选择这些做法呢?
你可能不希望一个类被复制或移动,所以想要让调用者无法访问相关的特殊函数。一种选择是将它们声明为私有(private)或受保护(protected),另一种选择是显式删除它们。
class NonCopyable {
public:
NonCopyable() {/* . . .*/}
// . . .
private:
NonCopyable(const NonCopyable&);
NonCopyable& operator=(const NonCopyable&);
};
2
3
4
5
6
7
8
在C++11之前,除了将不需要的特殊函数声明为私有且不实现它们之外,没有其他选择。通过这种方式,可以禁止复制对象(当时还没有移动的概念)。不实现这些函数有助于防止在成员函数、友元函数中意外使用,或者在忽略访问说明符时的误用。不过,这样在链接时会出现问题。
从C++11开始,你可以通过声明它们为= delete
来简单地标记为删除:
class NonCopyable {
public:
NonCopyable() {/* . . .*/}
NonCopyable(const NonCopyable&) = delete ;
NonCopyable& operator=(const NonCopyable&) = delete ;
// . . .
private:
// . . .
};
2
3
4
5
6
7
8
9
C++11的方式更好,原因如下:
- 它比将函数放在私有部分更明确,将函数放在私有部分可能只是一种错误处理方式;
- 如果你试图进行复制,在编译时就会报错。
被删除的函数应该声明为公共(public),而不是私有。这不是编译器的强制要求,但有些编译器可能只会抱怨你调用了私有函数,而不会提示该函数已被删除。
# 问题75:C++中的平凡类(trivial class)是什么?
在C++中,如果一个类或结构体(struct)具有编译器提供的或显式默认的特殊成员函数,那么它就是一个平凡类型。它占据连续的内存区域,可以有不同访问说明符的成员。在这种情况下,C++编译器可以自由选择成员的排列顺序。因此,你可以使用memcpy
函数处理这样的对象,但不能在C程序中可靠地使用它们。一个平凡类型T
可以被复制到char
或unsigned char
数组中,然后再安全地复制回T
类型的变量。请注意,由于对齐要求,类型成员之间可能存在填充字节。
平凡类型有平凡的默认构造函数、平凡的拷贝和移动构造函数、平凡的拷贝和移动赋值运算符,以及平凡的析构函数。在每种情况下,“平凡”意味着构造函数/运算符/析构函数不是用户提供的,并且属于一个满足以下条件的类:
- 没有虚函数(virtual functions)或虚基类(virtual base classes);
- 没有具有相应非平凡构造函数/运算符/析构函数的基类;
- 没有具有相应非平凡构造函数/运算符/析构函数的类类型数据成员。
一个类是否为平凡类,可以通过std::is_trivial
特性类(trait class)来验证。它会检查该类是否是平凡可拷贝的(std::is_trivially_copyable
)以及是否是平凡可默认构造的(std::is_trivially_default_constructible
)。
一些示例:
#include <iostream>
#include <type_traits>
class A {
public:
int m;
};
class B {
public:
B() {}
};
class C {
public:
C() = default ;
};
class D : C {};
class E {
virtual void foo() {}
};
int main() {
std::cout << std::boolalpha;
std::cout << std::is_trivial<A>::value << '\n';
std::cout << std::is_trivial<B>::value << '\n';
std::cout << std::is_trivial<C>::value << '\n';
std::cout << std::is_trivial<D>::value << '\n';
std::cout << std::is_trivial<E>::value << '\n';
}
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
程序输出结果如下:
true
false
true
true
false
2
3
4
5
# 问题76:拥有默认构造函数(default constructor)有什么好处?
我们可能会说,默认构造函数让我们能够简单地创建对象,但实际并非完全如此。
确实,不传递任何参数就能创建对象很简单,但只有当创建的对象可以完全使用时,这种简单创建才有意义。如果创建的对象仍需初始化,那么这种简单创建就毫无价值,实际上甚至会产生误导和危害。
另一方面,标准库的许多特性都要求有默认构造函数。
以std::vector
为例,当你创建一个包含10个元素的向量(std::vector<T> ts(10);
)时,10个默认构造的T
对象会被添加到新向量中。
拥有默认构造函数还有助于定义一个刚被移动走的对象的状态。
值得注意的是,有默认构造函数并不意味着你必须定义它。只要有可能,就让编译器生成它。例如,如果一个默认构造函数只是对数据成员进行默认初始化,那么你最好使用类内成员初始化器,让编译器生成默认构造函数。
所以,只要有可能,你就应该有一个默认构造函数,因为它能让你使用更多的语言和标准库特性,但同时也要确保默认构造函数创建的对象是完全可用的。
接下来,我们将探讨面向对象设计的一些内容、继承、C++如何处理多态、奇特递归模板模式(Curiously Recurring Template Pattern)等等。