“Rule of 5”(对于构造函数和析构函数)已经过时了吗?

44
“5规则”指的是,如果一个类有用户声明的析构函数、拷贝构造函数、拷贝赋值构造函数、移动构造函数或移动赋值构造函数,则必须具备其他四个函数。
但是今天我突然想到:你何时需要自定义析构函数、拷贝构造函数、拷贝赋值构造函数、移动构造函数或移动赋值构造函数呢?
在我看来,隐式构造函数/析构函数对于聚合数据结构来说已经足够了。然而,那些管理资源的类需要用户定义的构造函数/析构函数。
不过,所有管理资源的类都可以使用智能指针转换为聚合数据结构,这是不是可以呢?
例如:
// RAII Class which allocates memory on the heap.
class ResourceManager {
    Resource* resource;
    ResourceManager() {resource = new Resource;}
    // In this class you need all the destructors/ copy ctor/ move ctor etc...
    // I haven't written them as they are trivial to implement
};

vs

class ResourceManager {
    std::unique_ptr<Resource> resource;
};

现在示例2的行为与示例1完全相同,但是所有隐式构造函数都起作用。
当然,你不能复制ResourceManager,但如果你想要不同的行为,可以使用不同的智能指针。
关键是当智能指针已经有了用户定义的构造函数时,就不需要再定义隐式构造函数了。
我唯一能想到需要使用用户定义构造函数的原因是:
1.在某些低级代码中无法使用智能指针(我非常怀疑这种情况会发生);
2.正在实现智能指针本身。
然而,在正常的代码中,我看不出使用用户定义构造函数的任何理由。
我错过了什么吗?

3
@Peter,这就是我的观点。为什么不能始终将移动/复制委托给智能指针? - SomeProgrammer
3
如果你正在编写自己的智能指针,该怎么办? - HolyBlackCat
11
这被称为“零规则”。 - Galik
4
任何具有不寻常获取/释放语义的东西。 - Galik
4
你的例子只是为了说明一点而人为地制造出来的,但并不好。比如说你的构造函数创建了一个新的数据库表格,而析构函数需要对其进行最终化处理。你该如何使用智能指针来避免这种情况? - Tasos Papastylianou
显示剩余14条评论
5个回答

74

这条规则的全名是三五零法则

并不是说“总是提供所有五个”。它说你必须提供其中的三个、五个或者都不提供

事实上,更多时候最明智的做法是不提供这五个中的任何一个。但如果你正在编写自己的容器、智能指针或者 RAII 封装某些资源,则无法这样做。


2
即使这个规则的版本并不是应该总是遵循的,但也有例外情况。 - eerorika
@eerorika 好奇,有哪些异常?我不认为我见过任何异常。 - HolyBlackCat
12
假设您需要拥有一个指向成员的指针。如果复制该对象,则需要更新此指针。因此,您需要自定义(或删除)复制构造函数和赋值运算符。不需要析构函数。 - eerorika
@HolyBlackCat,我有一个类,它是一个SQLite数据库连接的C++包装器。它有一个析构函数(因此当对象被销毁时,连接会关闭),但正确的功能要求连接对象是唯一的:调用复制构造函数、赋值运算符或任何其他创建第二个封装相同连接的对象的操作都是错误的。 - Mark
6
这句话的意思是:这需要使用=delete来删除复制操作,我认为这算是提供了复制操作,因为它符合三大法则之一。 - HolyBlackCat

19
然而,在正常的代码中,我看不出使用用户定义的构造函数的任何理由。
用户提供的构造函数还可以维护一些不变量,因此与五法则相互独立。
例如,一个{{a}}。
struct clampInt
{
    int min;
    int max;
    int value;
};

不能确保min < max,因此封装数据可以提供此保证。

你什么时候需要用户定义析构函数、复制构造函数、复制赋值构造函数、移动构造函数或移动赋值构造函数呢?

现在来谈谈5/3/0规则。

确实应该优先考虑0规则。

可用的智能指针(包括容器)适用于指针、集合或Lockables。但是资源不一定是指针(可能是隐藏在int中的handle,内部隐藏的静态变量(XXX_Init()/XXX_Close())),或者可能需要更高级的处理(例如对于数据库,在作用域结束时自动提交或在出现异常时回滚),因此您必须编写自己的RAII对象。

您还可能希望编写不真正拥有资源的RAII对象,例如TimerLogger(记录“范围”使用的经过时间)。

另一个通常需要编写析构函数的时刻是抽象类,因为您需要虚析构函数(并且可能通过虚clone进行多态复制)。


1
谢谢您澄清了资源管理与指针的区别。我从未想到过您可以使用 int 进行资源管理... 现在我明白了为什么需要 RAII。 - SomeProgrammer

12
完整的规则是,如上所述,0/3/5原则;通常实现0个,在实现任何一个时,实现3或5个。
在一些情况下,您需要实现复制/移动和销毁操作。
1. 自引用。有时候对象的某些部分会引用到对象的其他部分。当你复制它们时,它们会天真地引用从中复制的其他对象。
2. 智能指针。有理由实现更多智能指针。
3. 更普遍的是,资源拥有类型,比如像vectoroptionalvariant这样的词汇类型。所有这些类型都让用户不必关心它们。
4. 比1更普遍的是,身份很重要的对象。那些具有外部注册的对象需要重新注册新副本,并在销毁时取消注册自己。
5. 在并发情况下需要小心或花哨的情况。例如,如果您有一个mutex_guarded<T>模板,并且您希望它们是可复制的,默认的复制不起作用,因为包装器有一个互斥锁,而互斥锁不能被复制。在其他情况下,您可能需要保证一些操作的顺序,执行比较和设置,甚至跟踪或记录对象的“本地线程”,以检测它何时跨越了线程边界。

8
这个规则经常被误解,因为它经常被过度简化。 简化版本是这样的:如果您需要编写至少一个(3/5)特殊方法,则需要编写所有(3/5)个。 实际上,有用的规则是:负责手动拥有资源的类应该专门处理管理资源的所有权/生命周期;为了正确执行此操作,必须实现所有3/5个特殊成员。否则(如果您的类没有手动拥有资源),您必须将所有特殊成员隐式或默认设置为零规则。 简化版本使用这种修辞方式:如果您发现自己需要编写其中之一(3/5),则很可能您的类手动管理资源的所有权,因此您需要实现所有(3/5)。 例如1:如果您的类管理系统资源的获取/释放,则必须实现所有3/5。 例如2:如果您的类管理内存区域的生命周期,则必须实现所有3/5。 例子3: 在析构函数中,您进行一些日志记录。编写析构函数的原因不是为了管理您拥有的资源,因此您不需要编写其他特殊成员。 总之: 在用户代码中,应遵循零规则:不要手动管理资源。使用已经为您实现了这一点的RAII包装器(如智能指针、标准容器、std::string等)。
然而,如果您发现自己需要手动管理资源,则编写一个专门负责资源生命周期管理的RAII类。该类应实现所有(3/5)个特殊成员。
关于这方面的好文章: https://en.cppreference.com/w/cpp/language/rule_of_three

7
拥有良好的封装概念,并已遵循五个规则,确实可以使您少担心一些。 话虽如此,如果您发现自己需要编写一些自定义逻辑的情况,则仍然适用。 一些想到的事情包括:
- 您自己的智能指针类型 - 必须注销的观察者 - C库的包装器
此外,我发现一旦您足够了解组合,就不再清楚类的行为方式。 是否可以使用赋值运算符? 我们能够复制构造类吗? 因此,在强制执行五个规则的同时,即使在其中加入“=默认” ,结合-Wdefaulted-function-deleted作为错误,有助于理解代码。
接下来,让我们更近距离地看一下您的示例:
// RAII Class which allocates memory on the heap.
class ResourceManager {
    Resource* resource;
    ResourceManager() {resource = new Resource;}
    // In this class you need all the destructors/ copy ctor/ move ctor etc...
    // I haven't written them as they are trivial to implement
};

这段代码确实可以很好地转换为:

class ResourceManager {
    std::unique_ptr<Resource> resource;
};

然而,现在想象一下:

class ResourceManager {
    ResourcePool &pool;
    Resource *resource;

    ResourceManager(ResourcePool &pool) : pool{pool}, resource{pool.createResource()} {}
    ~ResourceManager() { pool.destroyResource(resource);
};

如果您提供自定义析构函数,使用unique_ptr也可以完成此操作。但是,如果您的类现在存储了大量资源,您愿意承担额外的内存成本吗?

如果您需要先锁定才能将资源返回到池中进行回收怎么办?您只会锁一次并返回所有资源,还是每次返回一个资源就锁1000次?

我认为您的推理是正确的,拥有良好的智能指针类型使得规则5不再那么重要。然而,正如这个答案所示,总会发现需要它的情况。因此,将其称为过时可能有点过分,就像知道如何使用for (auto it = v.begin(); it != v.end(); ++it)而不是for (auto e : v)。你不再使用第一个变体,直到你需要调用'erase',这时它突然又变得相关了。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接