5 法则(针对构造函数和析构函数)是否过时了?
Is the Rule of 5 (for constructors and destructors) outdated?
5 的规则指出,如果 class 具有用户声明的析构函数、复制构造函数、复制赋值构造函数、移动构造函数或移动赋值构造函数,则它必须具有其他 4 个。
但今天我恍然大悟:什么时候需要用户定义的析构函数、复制构造函数、复制赋值构造函数、移动构造函数或移动赋值构造函数?
据我所知,隐式构造函数/析构函数对于聚合数据结构来说工作得很好。但是,管理资源的 classes 需要用户定义的构造函数/析构函数。
但是,不是所有的资源管理class都可以使用智能指针转换成聚合数据结构吗?
示例:
// 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
,但如果你想要不同的行为,你可以使用不同的智能指针。
重点是当智能指针已经有了用户定义的构造函数时,您不需要用户定义的构造函数,因此隐式构造函数可以工作。
我认为拥有用户定义的构造函数的唯一原因是:
你不能在一些低级代码中使用智能指针(我非常怀疑这种情况)。
您正在自己实现智能指针。
但是,在普通代码中,我看不出有任何理由使用用户定义的构造函数。
我是不是漏掉了什么?
规则的全称是the rule of 3/5/0。
它不会说“总是提供所有五个”。它说您必须 提供其中的三个、五个或 none。
事实上,最明智的做法往往是不提供这五项中的任何一项。但是,如果您要编写自己的容器、智能指针或围绕某些资源的 RAII 包装器,则不能这样做。
However, in normal code I don't see any reason to use user-defined constructors.
用户提供的构造函数还允许保持一些不变性,因此与规则 5 正交。
例如a
struct clampInt
{
int min;
int max;
int value;
};
不能确保 min < max
。所以封装数据可能会提供这种保证。
聚合并不适合所有情况。
when do you ever need a user-defined destructor, copy constructor, copy assignment constructor, move constructor, or move assignment constructor?
现在关于 5/3/0 的规则。
确实应该首选 0 规则。
可用的智能指针(我包括容器)用于指针、集合或 Lockables。
但是资源不是必要的指针(可能是 handle 隐藏在 int
,内部隐藏静态变量(XXX_Init()
/XXX_Close()
)),或者可能需要更高级的处理(对于数据库,在范围结束时自动提交或在出现异常时回滚)因此您必须编写自己的 RAII 对象。
您可能还想编写不真正拥有资源的 RAII 对象,例如 TimerLogger
(写入“范围”使用的经过时间)。
另一个通常需要编写析构函数的时刻是抽象 class,因为您需要虚拟析构函数(并且可能的多态复制由虚拟 clone
完成)。
拥有已经遵循五法则的良好封装概念确实可以确保您不必为此担心。也就是说,如果您发现自己处于必须编写一些自定义逻辑的情况,它仍然成立。想到的一些事情:
- 你自己的智能指针类型
- 必须注销的观察者
- C 库的包装器
除此之外,我发现一旦你有足够的组合,就不再清楚 class 的行为是什么。赋值运算符可用吗?我们可以复制构造 class 吗?因此,执行五规则,即使其中包含 = default
,结合 -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
来完成。
不过,如果你的class现在存储了很多资源,你愿意为内存付出额外的代价吗?
如果您需要先获取锁,然后才能return资源到池中被回收怎么办?你会只拿这个锁一次和 return 所有资源,还是当你 return 他们 1 对 1 时拿 1000 次?
我认为您的推理是正确的,拥有良好的智能指针类型可以降低 5 规则的相关性。但是,正如这个答案中所指出的,总有一些案例可以在您需要的地方被发现。所以说它过时可能有点过分,这有点像知道如何使用 for (auto it = v.begin(); it != v.end(); ++it)
而不是 for (auto e : v)
进行迭代。您不再使用第一个变体,到目前为止,您需要调用 'erase' 这突然又变得相关了。
如前所述,完整规则是 0/3/5 规则;通常执行其中的 0 个,如果你执行任何一个,则执行其中的 3 个或 5 个。
在少数情况下,您必须执行 copy/move 和销毁操作。
自参考。有时一个对象的部分引用对象的其他部分。当您复制它们时,它们会天真地引用您从中复制的 other 对象。
智能指针。有理由实施更多的智能指针。
比智能指针、资源拥有类型更普遍,如 vector
s 或 optional
或 variant
s。所有这些都是让他们的用户不关心他们的词汇类型。
比 1 更通用,对象的身份很重要。例如,具有外部注册的对象必须向注册存储重新注册新副本,并且在销毁时必须自行注销。
由于并发而不得不小心或花哨的情况。例如,如果您有一个 mutex_guarded<T>
模板并且您希望它们是可复制的,则默认复制不起作用,因为包装器具有互斥锁,并且无法复制互斥锁。在其他情况下,您可能需要保证某些操作的顺序,进行比较和设置,甚至跟踪或记录对象的“本机线程”以检测它何时跨越线程边界。
该规则经常被误解,因为它经常被发现过于简单化。
简化的版本是这样的:如果您需要编写至少一个 (3/5) 特殊方法,那么您需要编写所有 (3/5) ).
实际的、有用的规则:负责手动拥有资源的class应该:专门处理管理资源的ownership/lifetime资源;为了正确地做到这一点,它必须实现所有 3/5 特殊成员。否则(如果您的 class 没有资源的手动所有权)您必须让所有特殊成员隐式或默认(零规则)。
简化版本使用这种修辞:如果您发现自己需要编写 (3/5) 之一,那么很可能您的 class 手动管理资源的所有权,因此您需要实现所有(3/5).
示例 1: 如果您的 class 管理系统资源的 acquisition/release,那么它必须实现全部 3/5。
示例 2: 如果您的 class 管理内存区域的生命周期,那么它必须实现所有 3/5。
示例 3: 在您的析构函数中执行一些日志记录。你写析构函数的原因不是为了管理你拥有的资源,所以你不需要写其他特殊成员。
结论:在用户代码中你应该遵循零规则:不要手动管理资源。使用已经为您实现此功能的 RAII 包装器(如智能指针、标准容器、std::string
等)
但是,如果您发现自己需要手动管理资源,请编写一个专门负责资源生命周期管理的 RAII class。 class 应实现所有 (3/5) 个特殊成员。
很好的读物:https://en.cppreference.com/w/cpp/language/rule_of_three
5 的规则指出,如果 class 具有用户声明的析构函数、复制构造函数、复制赋值构造函数、移动构造函数或移动赋值构造函数,则它必须具有其他 4 个。
但今天我恍然大悟:什么时候需要用户定义的析构函数、复制构造函数、复制赋值构造函数、移动构造函数或移动赋值构造函数?
据我所知,隐式构造函数/析构函数对于聚合数据结构来说工作得很好。但是,管理资源的 classes 需要用户定义的构造函数/析构函数。
但是,不是所有的资源管理class都可以使用智能指针转换成聚合数据结构吗?
示例:
// 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
,但如果你想要不同的行为,你可以使用不同的智能指针。
重点是当智能指针已经有了用户定义的构造函数时,您不需要用户定义的构造函数,因此隐式构造函数可以工作。
我认为拥有用户定义的构造函数的唯一原因是:
你不能在一些低级代码中使用智能指针(我非常怀疑这种情况)。
您正在自己实现智能指针。
但是,在普通代码中,我看不出有任何理由使用用户定义的构造函数。
我是不是漏掉了什么?
规则的全称是the rule of 3/5/0。
它不会说“总是提供所有五个”。它说您必须 提供其中的三个、五个或 none。
事实上,最明智的做法往往是不提供这五项中的任何一项。但是,如果您要编写自己的容器、智能指针或围绕某些资源的 RAII 包装器,则不能这样做。
However, in normal code I don't see any reason to use user-defined constructors.
用户提供的构造函数还允许保持一些不变性,因此与规则 5 正交。
例如a
struct clampInt
{
int min;
int max;
int value;
};
不能确保 min < max
。所以封装数据可能会提供这种保证。
聚合并不适合所有情况。
when do you ever need a user-defined destructor, copy constructor, copy assignment constructor, move constructor, or move assignment constructor?
现在关于 5/3/0 的规则。
确实应该首选 0 规则。
可用的智能指针(我包括容器)用于指针、集合或 Lockables。
但是资源不是必要的指针(可能是 handle 隐藏在 int
,内部隐藏静态变量(XXX_Init()
/XXX_Close()
)),或者可能需要更高级的处理(对于数据库,在范围结束时自动提交或在出现异常时回滚)因此您必须编写自己的 RAII 对象。
您可能还想编写不真正拥有资源的 RAII 对象,例如 TimerLogger
(写入“范围”使用的经过时间)。
另一个通常需要编写析构函数的时刻是抽象 class,因为您需要虚拟析构函数(并且可能的多态复制由虚拟 clone
完成)。
拥有已经遵循五法则的良好封装概念确实可以确保您不必为此担心。也就是说,如果您发现自己处于必须编写一些自定义逻辑的情况,它仍然成立。想到的一些事情:
- 你自己的智能指针类型
- 必须注销的观察者
- C 库的包装器
除此之外,我发现一旦你有足够的组合,就不再清楚 class 的行为是什么。赋值运算符可用吗?我们可以复制构造 class 吗?因此,执行五规则,即使其中包含 = default
,结合 -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
来完成。
不过,如果你的class现在存储了很多资源,你愿意为内存付出额外的代价吗?
如果您需要先获取锁,然后才能return资源到池中被回收怎么办?你会只拿这个锁一次和 return 所有资源,还是当你 return 他们 1 对 1 时拿 1000 次?
我认为您的推理是正确的,拥有良好的智能指针类型可以降低 5 规则的相关性。但是,正如这个答案中所指出的,总有一些案例可以在您需要的地方被发现。所以说它过时可能有点过分,这有点像知道如何使用 for (auto it = v.begin(); it != v.end(); ++it)
而不是 for (auto e : v)
进行迭代。您不再使用第一个变体,到目前为止,您需要调用 'erase' 这突然又变得相关了。
如前所述,完整规则是 0/3/5 规则;通常执行其中的 0 个,如果你执行任何一个,则执行其中的 3 个或 5 个。
在少数情况下,您必须执行 copy/move 和销毁操作。
自参考。有时一个对象的部分引用对象的其他部分。当您复制它们时,它们会天真地引用您从中复制的 other 对象。
智能指针。有理由实施更多的智能指针。
比智能指针、资源拥有类型更普遍,如
vector
s 或optional
或variant
s。所有这些都是让他们的用户不关心他们的词汇类型。比 1 更通用,对象的身份很重要。例如,具有外部注册的对象必须向注册存储重新注册新副本,并且在销毁时必须自行注销。
由于并发而不得不小心或花哨的情况。例如,如果您有一个
mutex_guarded<T>
模板并且您希望它们是可复制的,则默认复制不起作用,因为包装器具有互斥锁,并且无法复制互斥锁。在其他情况下,您可能需要保证某些操作的顺序,进行比较和设置,甚至跟踪或记录对象的“本机线程”以检测它何时跨越线程边界。
该规则经常被误解,因为它经常被发现过于简单化。
简化的版本是这样的:如果您需要编写至少一个 (3/5) 特殊方法,那么您需要编写所有 (3/5) ).
实际的、有用的规则:负责手动拥有资源的class应该:专门处理管理资源的ownership/lifetime资源;为了正确地做到这一点,它必须实现所有 3/5 特殊成员。否则(如果您的 class 没有资源的手动所有权)您必须让所有特殊成员隐式或默认(零规则)。
简化版本使用这种修辞:如果您发现自己需要编写 (3/5) 之一,那么很可能您的 class 手动管理资源的所有权,因此您需要实现所有(3/5).
示例 1: 如果您的 class 管理系统资源的 acquisition/release,那么它必须实现全部 3/5。
示例 2: 如果您的 class 管理内存区域的生命周期,那么它必须实现所有 3/5。
示例 3: 在您的析构函数中执行一些日志记录。你写析构函数的原因不是为了管理你拥有的资源,所以你不需要写其他特殊成员。
结论:在用户代码中你应该遵循零规则:不要手动管理资源。使用已经为您实现此功能的 RAII 包装器(如智能指针、标准容器、std::string
等)
但是,如果您发现自己需要手动管理资源,请编写一个专门负责资源生命周期管理的 RAII class。 class 应实现所有 (3/5) 个特殊成员。
很好的读物:https://en.cppreference.com/w/cpp/language/rule_of_three