C++11中的Rule-of-Three变成了Rule-of-Five吗?

347

所以,在观看这个精彩的演讲关于右值引用之后,我认为如果每个类都拥有一个"移动构造函数"template<class T> MyClass(T&& other)(编辑)和一个"移动赋值运算符"template<class T> MyClass& operator=(T&& other)(正如Philipp在他的回答中指出的),如果它具有动态分配的成员或者通常存储指针,那么它将受益匪浅。就像你应该有一个复制构造函数、赋值运算符和析构函数一样,如果应用到之前提到的点。


谢谢您的建议!Stephan T. Lavavej的教程非常有帮助! - cpprust
9个回答

336

我认为三五法则现在应该改成三四五法则:

每个类都应该显式地定义以下特殊成员函数集中的一个:

  • 没有
  • 析构函数,拷贝构造函数,拷贝赋值运算符

此外,每个显式定义了析构函数的类都可以显式定义移动构造函数和/或移动赋值运算符。

通常情况下,以下几组特殊成员函数是合理的:

  • 没有(对于许多简单的类,在隐式生成的特殊成员函数正确且快速时)
  • 析构函数,拷贝构造函数,拷贝赋值运算符(在这种情况下,该类将不可移动)
  • 析构函数,移动构造函数,移动赋值运算符(在这种情况下,该类将不可复制,适用于底层资源不可复制的资源管理类)
  • 析构函数,拷贝构造函数,拷贝赋值运算符,移动构造函数(由于拷贝省略,如果拷贝赋值运算符以值传递其参数,则没有开销)
  • 析构函数,拷贝构造函数,拷贝赋值运算符,移动构造函数,移动赋值运算符

注意:

  • 如果类显式声明了其他特殊成员函数(如析构函数、拷贝构造函数或移动赋值运算符),则不会为该类生成移动构造函数和移动赋值运算符。
  • 如果类显式声明了移动构造函数或移动赋值运算符,则不会为该类生成拷贝构造函数和拷贝赋值运算符。
  • 有一个明确声明析构函数和隐式定义拷贝构造函数或隐式定义拷贝赋值运算符的类被认为已过时。

特别地,以下是完全有效的 C++03 多态基类:

class C {
  virtual ~C() { }   // allow subtype polymorphism
};

应该重写如下:

class C {
  C(const C&) = default;               // Copy constructor
  C(C&&) = default;                    // Move constructor
  C& operator=(const C&) = default;  // Copy assignment operator
  C& operator=(C&&) = default;       // Move assignment operator
  virtual ~C() { }                     // Destructor
};

有点烦人,但很可能比另一种选择更好(在这种情况下,仅生成用于复制的特殊成员函数,而没有移动功能)。

与“三大法则”不同的是,不显式声明移动构造函数和移动赋值运算符通常是可以的,但通常不如效率高。如上所述,只有在没有显式声明复制构造函数、复制赋值运算符或析构函数时,才会生成移动构造函数和移动赋值运算符。这不对称于传统的C++03行为,即自动生成复制构造函数和复制赋值运算符,但更加安全。因此,定义移动构造函数和移动赋值运算符的可能性非常有用,并且创造了新的可能性(纯移动类),但遵循“三大法则”的类仍然是可以的。

对于资源管理类,如果底层资源无法复制,则可以将复制构造函数和复制赋值运算符定义为已删除的(这被视为定义)。通常您仍需要移动构造函数和移动赋值运算符。复制和移动赋值运算符通常使用swap实现,就像C++03一样。谈论swap;如果我们已经有了移动构造函数和移动赋值运算符,专门化std::swap将变得不重要,因为通用的std::swap会使用移动构造函数和移动赋值运算符(如果可用,则应该足够快)。

不是针对资源管理(即没有非空析构函数)或子类型多态(即没有虚拟析构函数)的类应该不声明五个特殊成员函数;它们都将被自动生成并正确且快速地工作。


2
@Xeo:我认为,如果类不可复制,则即使可以省略复制,也不能按值传递其实例。在这种情况下,您应该使用rvalue引用声明真正的移动赋值运算符(如果一个赋值运算符通过§12.8/19采用值作为其参数,则它是一个复制赋值运算符,如果类不可复制,则不希望这样)。对于可复制和可移动的类,编译器应该使用复制省略或调用移动构造函数。 - Philipp
10
自从C++11通过以来规则有改变吗?我相信现在允许使用struct C { virtual ~C() = default; };,这是最简洁的选项。n3242中的禁止(“-它不得是虚拟的”)在n3290中已经不存在了,而且GCC现在允许使用,之前是不允许的。 - Luc Danton
3
不,这不是打字错误。这里有一个很好的解释:https://dev59.com/SWct5IYBdhLWcg3wKqQd#12306344 - Mihai Todor
1
еҰӮжһңжӮЁжӯЈеңЁеңЁе…¶д»–йқһPODз»“жһ„дёӯж·»еҠ std::unique_ptr<>жҲҗе‘ҳпјҢ并еёҢжңӣдҪҝе…¶еҸҜ移еҠЁдҪҶдёҚеҸҜеӨҚеҲ¶пјҢйӮЈиҜҘжҖҺд№ҲеҠһе‘ўпјҹжӮЁжҳҜеҗҰеҸҜд»ҘжңҖз»ҲеҸӘжңүдёӨдёӘжҲҗе‘ҳпјҲ移еҠЁиөӢеҖјпјҢ移еҠЁжһ„йҖ еҮҪж•°пјүпјҹпјҲжҠұжӯүпјҢжҲ‘дёҚжҳҜж•…ж„ҸеҚ–еј„пјҢиҝҷжҳҜжҲ‘йҒҮеҲ°зҡ„дёҖдёӘе®һйҷ…жғ…еҶөпјҒпјүйЎәдҫҝиҜҙдёҖеҸҘпјҢеҫҲжЈ’зҡ„зӯ”жЎҲпјҢжҲ‘зӮ№дәҶ+1并еҠ жҳҹж Үи®°дәҶгҖӮ - kfmfe04
1
不错的回答,只是多态基类部分看起来有问题。为什么成员是“private”?如果意思是“protected”,那么析构函数就不应该是“virtual”。如果意思是“public”,那么您难道不担心基类的这些公共运算符会发生意外的对象切片吗?特别是复制赋值和移动赋值应该是“= delete”,而不是“= default”。通常情况下,多态对象不会公开可复制或可移动。当需要复制时,使用虚拟的“clone()”。智能指针指向对象时通常会进行复制或移动。 - Öö Tiib
显示剩余10条评论

73

我无法相信没有人链接到这篇文章

基本上,这篇文章提倡使用“零规则”。 引用整篇文章对我来说不太合适,但我认为这是主要观点:

有自定义析构函数、复制/移动构造函数或复制/移动赋值运算符的类应仅处理所有权。 其他类不应具有自定义析构函数、复制/移动构造函数或复制/移动赋值运算符。

此外,以下内容在我看来也很重要:

常见的“包装所有权”的类已包含在标准库中:std::unique_ptrstd::shared_ptr。通过使用定制的删除器对象,两者都变得足够灵活,可以管理几乎任何类型的资源。


4
请点击这里查看我对整个问题的想法。 :) - Xeo

21
我认为三法则是一个经验法则,规定一个实现以下某些功能但不是全部的类可能存在漏洞:
  1. 拷贝构造函数
  2. 赋值运算符
  3. 析构函数
然而,省略移动构造函数或移动赋值运算符并不意味着存在漏洞。这可能是一次优化机会(在大多数情况下),或者移动语义对于该类不相关,但这并不是漏洞。
虽然最佳实践是在适当的情况下定义移动构造函数,但这并非强制要求。有很多情况下,移动构造函数对于一个类并不相关(例如std :: complex),所有在C++03中正确行为的类,即使它们没有定义移动构造函数,在C++0x中仍将继续正确行为。

14

是的,我认为为这些类提供一个移动构造函数会很不错,但请记住:

  • 这只是一种优化。

    仅实现拷贝构造函数、赋值运算符或析构函数中的一个或两个可能会导致错误,而没有移动构造函数只会潜在地降低性能。

  • 移动构造函数并不总是可以直接使用。

    有些类始终分配它们的指针,因此这些类在析构函数中总是要删除它们的指针。在这些情况下,您需要添加额外的检查来判断它们的指针是否已被分配或已移动(现在为 null)。


21
移动语义不仅仅是一种优化,它在完美转发和某些类(如unique_ptr)的实现中也非常重要。 - Puppy
@DeadMG:总的来说你是对的,但在这种情况下,移动语义只是一种优化。我在这里谈论的是已经存在的遵守三大法则的类;unique_ptr和完美转发是一些特殊情况... - peoro
@peoro:这就像是在暗示C++只是在C的基础上添加了类一样。auto_ptr 遵循了三五法则,而 unique_ptr 明显没有遵循五法则。 - Puppy
1
@peoro:我认为,声明私有复制构造函数和复制赋值运算符的C++03类(或继承自boost::noncopyable)可以被称为遵守三法则。 (否则,我们必须引入不同的术语,例如“大一和小二法则”)。 - Philipp
我完全同意你的观点,但我认为这些是特殊情况。我不认为OP在考虑auto_ptr或不可复制的类。话虽如此,Philipp的答案比这个更好(更完整和详细)。 - peoro
4
在某些情况下,一些类总是分配指针...在这种情况下,移动通常被实现为交换。 这样就简单而快速。(实际上更快,因为它将解除分配延迟到rvalue的析构函数中) - Mooing Duck

8
这是关于自2011年1月24日以来的现状和相关发展的简短更新。
根据C++11标准(请参阅附录D的[depr.impldec]):
隐式声明复制构造函数将被弃用,如果类具有用户声明的复制赋值运算符或用户声明的析构函数。如果类具有用户声明的复制构造函数或用户声明的析构函数,则隐式声明复制赋值运算符将被弃用。
实际上,曾经建议废除弃用的行为,使C++14真正实现“五个规则”而不是传统的“三个规则”。 2013年,EWG投票反对将此提案实施在C++2014中。对该提案的决定的主要原因与打破现有代码有关。
最近,建议再次修改C++11措辞,以实现非正式的五个规则,即
如果任何一个函数由用户提供,则编译器不会生成复制函数、移动函数或析构函数。如果得到EWG的批准,这个“规则”可能会被采用到C++17中。

1
感谢更新。由于一些 C++ 问题变得陈旧,看到问题和/或答案如何受新语言版本的影响是很有帮助的。 - cb4

4
基本上,情况是这样的:如果您没有声明任何移动操作,那么您应该遵守三法则。如果您声明了一个移动操作,在“违反”三法则方面没有什么害处,因为编译器生成的操作已经变得非常严格。即使您没有声明移动操作并且违反了三法则,C++0x编译器也应该在用户声明了一个特殊函数而其他特殊函数由于现在已弃用的“C ++03兼容性规则”而被自动生成的情况下给出警告。
我认为可以说此规则变得不太重要。在C++03中,真正的问题在于实现不同的复制语义需要您声明所有相关的特殊函数,以便其中没有一个被编译器生成(否则会做错事情)。但是,C++0x更改了特殊成员函数生成的规则。如果用户声明了其中一个函数以更改复制语义,它将阻止编译器自动生成其余的特殊函数。这很好,因为缺少声明现在会将运行时错误转换为编译错误(或至少是警告)。作为C++03兼容性措施,一些操作仍然会生成,但此生成被视为已弃用,并且应该在C++0x模式下产生警告。
由于对编译器生成的特殊函数有相当严格的规则和C++03兼容性,三法则仍然是三法则。
这里有一些最新C++0x规则下应该可以正常运行的示例:
template<class T>
class unique_ptr
{
   T* ptr;
public:
   explicit unique_ptr(T* p=0) : ptr(p) {}
   ~unique_ptr();
   unique_ptr(unique_ptr&&);
   unique_ptr& operator=(unique_ptr&&);
};

在上面的例子中,没有必要将任何其他特殊函数声明为已删除。由于限制性规则,它们不会被生成。用户声明的移动操作的存在会禁用编译器生成的复制操作。但在像这样的情况下:
template<class T>
class scoped_ptr
{
   T* ptr;
public:
   explicit scoped_ptr(T* p=0) : ptr(p) {}
   ~scoped_ptr();
};

现在预计C++0x编译器会发出警告,提醒可能会出现错误的编译器生成复制操作。在这里,三法则很重要,应该受到尊重。在这种情况下发出警告是完全合适的,并给用户处理错误的机会。我们可以通过删除函数来解决这个问题:

template<class T>
class scoped_ptr
{
   T* ptr;
public:
   explicit scoped_ptr(T* p=0) : ptr(p) {}
   ~scoped_ptr();
   scoped_ptr(scoped_ptr const&) = delete;
   scoped_ptr& operator=(scoped_ptr const&) = delete;
};

因为需要兼容C++03,所以三法则仍然适用于这里。

事实上,N3126确实将unique_ptr的复制构造函数和复制赋值运算符定义为已删除 - 有人知道为什么吗? - Philipp
@Philipp:限制性规则比N3126更新。然而,N3225仍将unique_ptr的复制操作声明为已删除。虽然这不再必要,但也不是错误的。因此,没有真正需要更改unique_ptr的规范。 - sellibitze
N3126规则相对宽松,如果有用户声明的移动构造函数,则不会隐式声明复制构造函数;如果有用户声明的移动赋值运算符,则不会隐式声明复制赋值运算符。unique_ptr具有用户声明的移动构造函数和移动赋值运算符,因此我认为即使应用了N3126规则,也不需要用户声明复制构造函数和复制赋值运算符。虽然不是很重要,但由于标准库类使用的约定可能被解释为最佳实践。 - Philipp
实践中,了解显式声明的复制构造函数和复制赋值运算符是否是有意的会很好。 - Philipp

3
我们不能说现在规则3变成了规则4(或5),因为这样会破坏所有已经实施规则3且没有实现任何形式的移动语义的现有代码。
规则3意味着如果你实现一个,你必须实现全部三个。
同时也不知道是否会有任何自动生成的移动操作。 "规则3" 的目的是因为它们自动存在,如果你实现了其中一个,很可能另外两个的默认实现是错误的。

2

通常情况下,确实是这样的,规则从三变为五,包括移动赋值运算符和移动构造函数。但是,并不是所有的类都可以复制和移动,有些只能移动,有些只能复制。


1
我相信即使一个类不可复制,你也想要定义复制构造函数和赋值运算符(作为删除)。因此,一个可移动的资源管理类也应该定义所有五个。 - Philipp
1
@Philipp,我强烈不同意,许多类不支持移动语义,仅为了一些美学感觉而定义两个冗余函数是没有意义的。为什么std::complex要关心右值引用? - Motti
@Motti:为什么它定义了常规的复制语义?几乎所有可以复制的资源都可以移动。 - Puppy
@Motti:我不知道C++0x中代码生成函数的详细信息。我理解Philipp的评论是在暗示,如果它们没有被明确定义为已删除,则它们将被自动生成。显然这是一个错误。 - Konrad Rudolph
更正:我刚刚读到,如果声明了移动构造函数或移动赋值运算符,则不会生成复制构造函数或复制赋值运算符,因此在这种情况下确实不必定义复制构造函数和复制赋值运算符。我已相应地编辑了我的答案。 - Philipp
显示剩余4条评论

0

简单来说,只需记住以下几点。

0号规则:

类没有自定义析构函数、复制/移动构造函数或复制/移动赋值运算符。

3号规则: 如果您实现了任何一个自定义版本,您需要实现所有这些版本。

析构函数、复制构造函数、复制赋值运算符

5号规则: 如果您实现了自定义移动构造函数或移动赋值运算符,则需要定义所有5个版本。需要用于移动语义。

析构函数、复制构造函数、复制赋值运算符、移动构造函数、移动赋值运算符

四个半的规则: 与5号规则相同,但使用复制和交换惯用语。通过包含swap方法,复制赋值和移动赋值合并为一个赋值运算符。

析构函数、复制构造函数、移动构造函数、赋值、交换(半部分)

参考资料:

https://www.linkedin.com/learning/c-plus-plus-advanced-topics/rule-of-five?u=67551194 https://en.cppreference.com/w/cpp/language/rule_of_three


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