三五零法则就是三法则(The Rule of Three)、五法则(The Rule of Five)、零法则(The Rule of Zero)。
三五零法则是和C++的特殊成员函数有关,特别是那些涉及对象如何被创建、复制、移动和销毁的函数。这些法则提供了指导原则,帮助开发者设计和实现那些管理资源(如动态内存、文件句柄等)的类。
特殊成员函数
析构函数
~X()
- 调用每个类成员和基类的析构函数
- 负责在对象生命周期结束时是否其占用的资源
拷贝构造函数
X(X const& other)
- 调用每个类成员和基类的拷贝构造函数
- 通过另一个同类型的现有对象来初始化新对象
- 自定义对象如何被拷贝,是深拷贝还是浅拷贝
拷贝赋值运算符
X& operator=(X const& other)
- 调用每个类成员和基类的拷贝赋值运算符
- 通过另一个同类型的现有对象赋值给自己
移动构造函数
X(X&& other)
- 调用每个类成员和基类的移动构造函数
- 通过移动而非拷贝来初始化一个对象,通常涉及资源的转移,使得原对象变为无效状态
移动赋值运算符
X& operator=(X&& other)
- 调用每个类成员和基类的移动赋值运算符
- 以赋值的形式将对象资源转移
编译器和特殊成员函数
class X {
public:
int a = 1;
};
int main()
{
X a, b;
a.a = 2;
b.a = 3;
a = b;
std::cout << "a.a = " << a.a << std::endl;
return 0;
}
上面代码的输出结果是:a.a = 3
我们思考一下,为什么X
类中没有显示定义拷贝赋值运算符,但a = b;
能够有效地把b
对象的数据赋值给a
对象。
其实,在很多时候,自定义类的特殊成员函数的实现几乎是一样的,为了提升语言的易用性,编译器会在满足某些条件下的时候提供默认行为。
下图展示的是编译器隐式定义特殊成员函数的条件:
三五零法则
三法则
法则内容
三法则规定,如果一个类需要显式定义以下其中一项时,那么它必须显示定义这全部的三项:
- 拷贝构造函数
- 拷贝赋值运算符
- 析构函数
案例说明
根据RAII原则,当类手动管理至少一个动态分配的资源时,通常需要实现上述函数。
class Student {
public:
Student(char* name, int id) {
this->id = id;
this->name = new char[strlen(name) + 1];
strcpy(this->name, name);
}
~Student() {
delete[] this->name;
}
private:
int id;
char* name;
};
在这个示例中,我们有一个Student
类手动管理了动态分配的资源(即name
),构造函数为name
分配内存,析构函数释放分配的内存。
但是当Student
的对象被复制时会发生什么?
Student s1("Tom", 12);
Student s2 = s1;
当构造s2
时,将执行Student
的默认拷贝构造函数(因为用户没有显式定义拷贝构造函数)。默认的拷贝构造函数将每个成员进行浅拷贝,这意味着s1.name
和s2.name
都指向同一块内存。
当main()
函数结束时会发生什么?s2
的析构函数将被调用,这将释放name
所指向的内存,然后s1
的析构函数被调用,它将再次尝试释放name
指向的内存,但是这块内存已经被释放了!这就导致重复释放内存。
为了避免这种情况,需要提供适当的复制操作:
// 拷贝构造函数
Student(const Student& other) {
this->id = other.id;
this->name = new char[strlen(other.name) + 1];
strcpy(this->name, other.name);
}
// 拷贝赋值运算符
Student& operator=(const Student& rhs) {
// 防止自拷贝
if (this != &rhs) {
this->id = rhs.id;
// delete old data
if (this->name) {
delete[] this->name;
}
this->name = new char[strlen(rhs.name) + 1];
strcpy(this->name, rhs.name);
}
return *this;
}
拷贝构造函数和拷贝赋值运算符都执行动态分配资源的深拷贝。
五法则
法则内容
五法则是三法则的扩展。五法则规定,如果一个类需要显式定义以下其中一项时,建议显式定义全部的五项:
- 拷贝构造函数
- 拷贝赋值运算符
- 析构函数
- 移动构造函数
- 移动赋值运算符
除了三法则中的三项外,我们还建议实现移动语义。与拷贝操作相比,移动操作更加高效,因为它们利用已分配的内存并避免不必要的拷贝操作。
不实现移动语义通常不被视为错误。如果缺少移动语义,编译器通常会尽可能使用效率较低的复制操作。如果一个类不需要移动操作,我们可以轻松跳过这些操作。但是,使用它们会提高效率。
案例说明
我们还是在三法则的案例基础上添加移动语言:
// 移动构造函数
Student(Student&& other) {
this->id = other.id;
this->name = other.name;
other.name = nullptr;
}
// 移动赋值运算符
Student& operator=(Student&& rhs) {
// 防止自移动
if (this != &rhs) {
this->id = rhs.id;
// 删除原数据(防止内存泄漏)
if (this->name) {
delete[] this->name;
}
this->name = rhs.name;
rhs.name = nullptr;
}
return *this;
}
调用代码:
Student s1("John", 10);
Student s2 = s1; // 调用拷贝构造函数
Student s3;
s3 = s1; // 调用拷贝赋值运算符
Student s4("Jane", 12);
Student s5 = std::move(s4); // 调用移动构造函数
Student s6;
s6 = std::move(s5); // 调用移动赋值运算符
使用std::move()
可以强制调用移动语义。
零法则
法则内容
如果没有显式定义任何特殊成员函数,则编译器会隐式定义所有特殊成员函数(成员变量也会影响隐式定义)。
零法则就是建议优先选择不需要显式定义特殊成员函数的情况。