四(加半个)的规则是什么?(关于IT技术的问题)

28
为了正确处理对象复制,原则是三法则。在C++11中,移动语义是一种技术,所以现在是五法则。然而,在这里和互联网上的讨论中,我也看到了参考四个半法则的内容,它是五个法则和复制-交换惯用法的组合。
那么,什么是四个半法则?哪些函数需要被实现,每个函数的主体应该是什么样子的?哪个函数是半个?与五个法则相比,这种方法有没有任何缺点或警告?
这里有一个类似于我的当前代码的参考实现。如果不正确,正确的实现应该是什么样子的?
#include <utility>

// "Handle management" functions. These would be defined in an external library.
typedef int* handle;
#define NO_HANDLE nullptr
extern handle get_handle(int value);
extern handle copy_handle(handle h);
extern void free_handle(handle h);

// This class automatically obtains a handle, and then frees it when the object
// leaves scope.
class AutoHandle {
public:
    //We must have a default constructor so we can swap during copy construction.
    //It need not be useful, but it should be swappable and deconstructable.
    //It can be private, if it's not truly a valid state for the object.
    AutoHandle() : resource(NO_HANDLE) {}
    
    //Normal constructor, acquire resource
    AutoHandle(int value) : resource(get_handle(value)) {}
    
    //Copy constructor
    AutoHandle(AutoHandle const& other) {
        resource = copy_handle(other.resource);
    }
    
    //Move constructor
    //Delegates to default constructor to put us in safe state.
    AutoHandle(AutoHandle&& other) : AutoHandle() {
        swap(other);
    }
    
    //Assignment
    AutoHandle& operator=(AutoHandle other) {
        swap(other);
        return *this;
    }
    
    //Destructor
    ~AutoHandle() {
        //Free the resource here.
        //We must handle the default state that can appear from the copy ctor.
        if (resource != NO_HANDLE)
            free_handle(resource);
    }
    
    //Swap
    void swap(AutoHandle& other) {
        using std::swap;
        
        //Swap the resource between instances here.
        swap(resource, other.resource);
    }
    
    //Swap for ADL
    friend void swap(AutoHandle& left, AutoHandle& right) {
        left.swap(right);
    }
    
private:
    handle resource;
};

2
if (resource != nullptr) delete resource; 中的 if 是不必要的。 - alain
2
由于不应使用原始的拥有指针,因此很少需要编写自己的析构函数。坦白地说:我不再相信0、3、4、5、6规则中的任何一条。我尝试以尽可能少的特殊成员函数来编写我的类。 - MikeMB
2
@MikeMB 这就是零规则。 - Caleth
1
@jpfx1342:如果我的意思不太清楚,那我很抱歉。我的评论是针对“4个半规则”的问题而言的(有时需要dtor-> 5,有时不需要-> 4)。由于这只回答了部分问题,所以我没有将其作为答案。 - MikeMB
1
与大多数玩具示例一样,很难得出任何一般性结论。例如,为什么有人要存储 int* 然后想要深度复制?拥有一个“无用”的默认构造函数似乎不是最好的选择,特别是如果它只在奇怪的移动构造函数中使用 - 这将在 std::swap 中构造另一个对象。这似乎不是移动构造函数应该具有的明显速度优化。此外,赋值运算符可能会调用 std::swap,当交换移动分配对象时,似乎会发生递归... - Bo Persson
显示剩余6条评论
3个回答

20

那么什么是四个半规则呢?

“四个半大法则”指的是,如果你实现了以下其中一个:

  • 拷贝构造函数
  • 赋值运算符
  • 移动构造函数
  • 析构函数
  • 交换函数

那么你必须有关于其他函数的政策。

需要实现哪些函数,每个函数的主体应该如何编写?

  • 默认构造函数(可以是私有的)

  • 拷贝构造函数(深度复制你的资源。这里有真正的代码来处理你的资源)

  • 移动构造函数(使用默认构造函数和swap):

      S(S&& s) : S{} { swap(*this, s); }
    
  • 赋值运算符(使用构造函数和交换)

  •   S& operator=(S s)
      {
          swap(*this, s);
          return *this;
      }
    
  • 析构函数(释放资源)

  • 友元交换函数(没有默认实现 :/,您可能应该想要交换每个成员)。与成员交换方法相反,这个非常重要:std::swap 使用移动(或复制)构造函数,这会导致无限递归。

哪个函数是“半个函数”?

从之前的文章中:

"要实现复制-交换惯用法,您的资源管理类还必须实现一个交换(swap)函数来执行逐成员交换(这就是您的“…(半个)”)"

所以是swap方法。

与五大规则相比,这种方法有什么缺点或警告吗?

我已经写过的警告是编写正确的交换函数以避免无限递归。


9
这种方法与五个规则相比有什么缺点或警告吗?
尽管它可以节省代码重复,但使用复制和交换只会导致更差的类。你会损害类的性能,包括移动赋值(如果你使用统一赋值运算符,我也不喜欢),这应该非常快。作为交换,你会得到强异常保证,在开始时看起来很不错。问题是,你可以从任何具有简单通用函数的类中获得强异常保证:
template <class T>
void copy_and_swap(T& target, T source) {
    using std::swap;
    swap(target, std::move(source));
}

就是这样。所以需要强有力的异常安全性的人仍然可以得到它。而实际上,强有力的异常安全性在任何情况下都相当小众。

真正节省代码重复的方法是通过零规则:选择成员变量,使您不需要编写任何特殊函数。在现实生活中,我会说,90%以上的时间,我看到特殊成员函数,它们本来可以很容易地避免。即使您的类确实需要某种特殊逻辑才能执行特殊成员函数,您通常最好将其推入成员中。您的记录器类可能需要在其析构函数中刷新缓冲区,但这并不是编写析构函数的理由:编写一个处理刷新的小缓冲区类,并将其作为您的记录器的成员。记录器可能具有各种其他可以自动处理的资源,您希望让编译器自动生成复制/移动/销毁代码。

C ++的问题在于,特殊函数的自动生成是全部或无内容的,每个函数。也就是说,复制构造函数(例如)要么自动生成,考虑所有成员,要么您必须手动编写(更糟糕的是,维护)它们全部。因此,它强烈推动您向向下推送事物的方法。

在编写用于管理资源并需要处理此类问题的类的情况下,应该通常是:a)相对较小,b)相对通用/可重复使用。前者意味着一些重复的代码并不是什么大问题,而后者意味着您可能不想在性能上留有余地。

总之,我强烈反对使用复制和交换,并使用统一的赋值运算符。尽量遵循零规则,如果不能,请遵循五个规则。只有当您可以使其比通用交换更快时,才编写swap,但通常您不必费心。


1
你能提供任何关于4.5规则比直接的5规则更慢的参考吗?在Godbolt上玩耍,我可以看到生成了稍微不同的代码,但是也不清楚哪个明显更糟。并非所有的资源管理类都很小;考虑vector,它通常需要管理自己的资源。 - Daniel H
2
@DanielH 有点晚了,但是...4.5规则更慢是因为移动赋值是一种更小、更有限的操作,比交换要慢。在三个移动的术语中,交换基本上是最优的,除了排序、特定机器指令等非常低级的细节。以交换为基础的移动赋值显然是次优的,即使对于像unique_ptr这样的只是底层原始指针的情况也是如此。移动赋值只是一个赋值操作(读取旧值+写入新值),还有另一个写入操作(将旧值置空)。而交换需要进行三个赋值操作(读取新值+写入临时变量,读取旧值+写入新值,读取临时变量+写入旧值)。 - Nir Friedman
2
所以这是2次写入+1次读取,而不是3次写入+3次读取。当然,实际情况比这更复杂(例如,最后一次读取没有实际成本,因为它已经在寄存器中),编译器可能会帮助您优化掉一些东西。但由于各种原因,很难依赖它来进行优化,这需要更多的时间来解释。基本上,归根结底,4.5规则只是要求做更多的工作,其中一些是不必要的(但可以给您强大的异常保证)。 - Nir Friedman
1
@DanielH,Howard Hinnant谈到了复制和交换习语的缺点。例如,https://dev59.com/Rms05IYBdhLWcg3wFOC8#7458222 还有,在这个演讲中:https://www.youtube.com/watch?v=vLinb2fgkHk(从“我能否用另一个特殊成员来定义一个特殊成员?”开始)。 - Hari
@Hari 我还没有看过这个视频,但是SO答案或者评论中唯一涉及到这个问题的部分是“它可能不够优化,尤其是在没有仔细分析的情况下应用。”然后剩下的内容都是关于替代方法的;这并没有解释为什么它是不好的。 - Daniel H
显示剩余2条评论

1

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

0号规则:

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

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

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

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

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

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

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

Destructor: ~Class();
Copy constructor: Class(Class &);
Move constructor: Class(Class &&);
Assignment: Class & operator = (Class);
Swap: void swap(Class &);

没有警告,优点是在赋值时更快,因为按值传递的副本比在方法体中创建临时对象更有效率。

现在我们有了那个临时对象,我们只需对临时对象执行交换操作。当它超出范围时,它会自动销毁,我们现在拥有了运算符右侧的值。

参考资料

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 提供, 点击上面的
可以查看英文原文,
原文链接