5 规则(用于构造函数和析构函数)过时了吗?

Posted

技术标签:

【中文标题】5 规则(用于构造函数和析构函数)过时了吗?【英文标题】:Is the Rule of 5 (for constructors and destructors) outdated? 【发布时间】:2021-04-03 22:01:14 【问题描述】:

5 规则规定,如果一个类具有用户声明的析构函数、复制构造函数、复制赋值构造函数、移动构造函数或移动赋值构造函数,则它必须具有其他 4 个。

但今天我恍然大悟:什么时候需要用户定义的析构函数、复制构造函数、复制赋值构造函数、移动构造函数或移动赋值构造函数?

在我的理解中,隐式构造函数/析构函数对于聚合数据结构来说工作得很好。但是,管理资源的类需要用户定义的构造函数/析构函数。

但是,不是所有资源管理类都可以使用智能指针转换成聚合数据结构吗?

例子:

// 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;
;

现在示例 2 的行为与示例 1 完全相同,但所有隐式构造函数都可以工作。

当然,你不能复制ResourceManager,但如果你想要不同的行为,你可以使用不同的智能指针。

关键是,当智能指针已经拥有用户定义的构造函数时,您不需要用户定义的构造函数,因此隐式构造函数可以工作。

我认为拥有用户定义构造函数的唯一原因是:

    你不能在一些低级代码中使用智能指针(我非常怀疑这种情况永远不会发生)。

    您正在自己实现智能指针。

但是,在普通代码中,我看不出有任何理由使用用户定义的构造函数。

我错过了什么吗?

【问题讨论】:

@Peter 这就是我的观点。为什么不能总是将移动/复制委托给智能指针? 如果您正在编写自己的智能指针怎么办? 这被称为“零规则”。 任何具有异常获取/释放语义的东西。 你的例子只是有点做作来说明这一点。但这不是一个好的。假设您的构造函数在数据库中创建了一个新表,析构函数需要完成该表。您将如何使用智能指针避免这种情况? 【参考方案1】:

规则的全称是the rule of 3/5/0。

没有说“总是提供全部五个”。它说您必须要么提供三个、五个或一个都不提供。

确实,最明智的做法往往是不提供这五项中的任何一项。但是,如果您正在编写自己的容器、智能指针或围绕某些资源的 RAII 包装器,则不能这样做。

【讨论】:

事件这个版本的规则不是应该始终遵循的。也有例外。 @eerorika 很好奇,有哪些例外?我想我没见过。 假设您需要一个指向成员的指针。如果复制对象,则需要更新此指针。因此,您需要一个自定义(或已删除)的复制构造函数和赋值运算符。你不需要析构函数。 @HolyBlackCat,我有一个类,它是围绕 SQLite 数据库连接的 C++ 包装器。它有一个析构函数(因此当对象被销毁时连接关闭),但正确的功能要求连接对象是唯一的:调用复制构造函数、赋值运算符或任何其他会创建第二个对象包装的东西都是错误的相同的连接。 @Mark 这需要=deleteing 复制操作,IMO 将其视为出于规则 3 的目的提供它们。【参考方案2】:

但是,在普通代码中,我看不出有任何理由使用用户定义的构造函数。

用户提供的构造函数还允许保持一些不变量,因此与规则 5 正交。

比如一个

struct clampInt

    int min;
    int max;
    int value;
;

不保证min &lt; max。所以封装数据可能会提供这种保证。 聚合并不适合所有情况。

您何时需要用户定义的析构函数、复制构造函数、复制赋值构造函数、移动构造函数或移动赋值构造函数?

现在关于 5/3/0 的规则。

确实应该首选 0 规则。

可用的智能指针(我包括容器)用于指针、集合或Lockables。 但是资源不是必需的指针(可能句柄隐藏在int,内部隐藏的静态变量(XXX_Init()/XXX_Close())),或者可能需要更高级的处理(对于数据库,在范围结束时自动提交或在异常情况下回滚),因此您必须编写自己的 RAII 对象。

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

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

【讨论】:

感谢您澄清资源管理!= 指针。我从来没有想过您可以使用 int 进行资源管理......现在我明白为什么在这种情况下需要 RAII。【参考方案3】:

如前所述,完整的规则是 0/3/5 规则;通常实现其中的 0 个,如果你实现了,则实现其中的 3 或 5 个。

在少数情况下,您必须实现复制/移动和销毁操作。

    自我参考。有时,对象的某些部分引用对象的其他部分。当您复制它们时,它们会天真地引用您复制的 other 对象。

    智能指针。实现更多智能指针是有原因的。

    比智能指针更普遍的是资源拥有类型,如vectors 或optionalvariants。所有这些都是让用户不关心它们的词汇类型。

    比 1 更笼统,对象的身份很重要。例如,具有外部注册的对象必须在注册存储中重新注册新副本,并且在销毁时必须自行注销。

    由于并发而必须小心或花哨的情况。例如,如果您有一个mutex_guarded&lt;T&gt; 模板并且希望它们是可复制的,则默认副本不起作用,因为包装器具有互斥锁,并且互斥锁无法复制。在其他情况下,您可能需要保证某些操作的顺序,进行比较和设置,甚至跟踪或记录对象的“本机线程”以检测它何时跨越线程边界。

【讨论】:

【参考方案4】:

拥有已经遵循五法则的良好封装概念确实可以确保您不必担心它。也就是说,如果您发现自己处于必须编写一些自定义逻辑的情况,它仍然成立。想到的一些事情:

您自己的智能指针类型 必须注销的观察者 C 库的包装器

除此之外,我发现一旦你有足够的组合,就不再清楚类的行为会是什么。赋值运算符可用吗?我们可以复制构造类吗?因此强制执行五规则,即使其中包含= default,结合-Wdefaulted-function-deleted as error 也有助于理解代码。

仔细查看您的示例:

// 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) : poolpool, resourcepool.createResource() 
    ~ResourceManager()  pool.destroyResource(resource);
;

同样,如果你给它一个自定义析构函数,这可以用unique_ptr 来完成。 不过,如果你的类现在存储了大量资源,你愿意为内存支付额外的成本吗?

如果您首先需要锁定才能将资源返回到池中以进行回收,该怎么办?您会只使用此锁一次并返回所有资源还是 1000 次一一归还它们?

我认为您的推理是正确的,拥有良好的智能指针类型会使 5 规则的相关性降低。但是,正如这个答案中所指出的,总有一些案例需要你去发现。所以说它过时可能有点过时,这有点像知道如何使用for (auto it = v.begin(); it != v.end(); ++it) 而不是for (auto e : v) 进行迭代。您不再使用第一个变体,到目前为止,您需要在突然再次变得相关的地方调用“擦除”。

【讨论】:

【参考方案5】:

该规则经常被误解,因为它经常被过度简化。

简化版是这样的:如果您需要编写至少一个(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

【讨论】:

以上是关于5 规则(用于构造函数和析构函数)过时了吗?的主要内容,如果未能解决你的问题,请参考以下文章

构造函数和析构函数

简述构造函数和析构函数的作用

构造函数和析构函数的链表问题

C语言里面构造函数和析构函数的运用办法

9. 构造函数和析构函数

构造函数和析构函数能不能被继承