三原则的例外情况是什么?

16
我已经阅读了大量关于C++ 三法则的内容,很多人都对它深信不疑。但是当这个规则被说明时,几乎总会包含像“通常”,“可能”或“大概”之类的词语,表明有例外情况。我还没有看到过关于这些特殊情况的讨论--即三法则不适用的情况,或者至少遵循它没有任何优势的情况。 我的问题是,我的情况是否是三法则的合法例外。 我认为,在我描述的情况下,需要显式定义复制构造函数和复制赋值运算符,但默认(隐式生成的)析构函数可以正常工作。以下是我的情况:
我有两个类,A和B。这里涉及的是A。B是A的朋友。A包含一个B对象。B包含一个指向A对象的指针,该指针旨在指向拥有B对象的A对象。B使用此指针来操作A对象的私有成员。除了在A构造函数中,B永远不会被实例化。就像这样:
// A.h

#include "B.h"

class A
{
private:
    B b;
    int x;
public:
    friend class B;
    A( int i = 0 )
    : b( this ) {
        x = i;
    };
};

和...

// B.h

#ifndef B_H // preprocessor escape to avoid infinite #include loop
#define B_H

class A; // forward declaration

class B
{
private:
    A * ap;
    int y;
public:
    B( A * a_ptr = 0 ) {
        ap = a_ptr;
        y = 1;
    };
    void init( A * a_ptr ) {
        ap = a_ptr;
    };
    void f();
    // this method has to be defined below
    // because members of A can't be accessed here
};

#include "A.h"

void B::f() {
    ap->x += y;
    y++;
}

#endif

我为什么要这样设置我的类呢?我保证,我有很好的理由。这些类实际上比我在这里列出的功能要多得多。

那么剩下的部分就很容易了,对吧?没有资源管理,没有大三问题,没问题。错!A的默认(隐式)复制构造函数将不足以满足我们的需求。如果我们这样做:

A a1;
A a2(a1);

我们得到了一个新的A对象a2,它与a1完全相同,这意味着a2.ba1.b相同,也就是说a2.b.ap仍然指向a1!这不是我们想要的。我们必须为A定义一个复制构造函数,该函数复制默认复制构造函数的功能,然后将新的A::b.ap设置为指向新的A对象。我们将以下代码添加到class A中:
public:
    A( const A & other )
    {
        // first we duplicate the functionality of a default copy constructor
        x = other.x;
        b = other.b;
        // b.y has been copied over correctly
        // b.ap has been copied over and therefore points to 'other'
        b.init( this ); // this extra step is necessary
    };

需要拷贝赋值运算符的原因相同,实现方式也相同,即复制默认拷贝赋值运算符的功能,然后调用b.init(this);

但是不需要显式析构函数,因此这种情况是三大法则的例外。我是正确的吗?


9
请注意,您的包含保护符“_B”是非法的,因为所有下划线后跟大写字母的情况都被系统保留。请修改为合法的名称。 - metal
5
对于C++11来说,“零规则”更好:http://flamingdangerzone.com/cxx11/2012/08/15/rule-of-zero.html 在这种情况下,您可以使用std::unique_ptr、std::shared_ptr以及对此有些用处的std::weak_ptr(或类似拥有类)来管理A和B的生命周期。这样做可以消除代码读者(包括您在6个月内)的所有疑惑。 - metal
1
@metal 能否详细说明一下它是如何帮助的?我已经大致浏览了那篇文章,但据我所见,它仅涉及资源所有权和生命周期管理,完全忽略了这个问题所涉及的“循环”依赖类型。规则零如何处理这种情况?! - us2012
1
是的,总体来说这是一个例外,因为B并不实际拥有资源,所以您不需要析构函数。然而,您需要定义赋值运算符,因为它与默认复制构造函数具有相同的问题。 - Dave S
1
@us2012,你说得对,标准的RAII类并不能完全解决你所述的问题。3、5和0规则与资源所有权有关,正如Loki Astari所指出的那样,你大多数情况下谈论的是其他事情(当然你应该关注所有权,并且这些类是管理它的好方法)。如果你严格遵循0规则,除非在特殊情况下,否则你永远不会看到任何3个(或C++11中的5个)特殊函数手动编码,而这似乎就是其中之一。 - metal
显示剩余6条评论
3个回答

9
不必过于拘泥于“三法则”。规则并非要盲目遵守;它们存在是为了让你思考。你已经思考过了,并得出了析构函数不需要的结论。因此,不需要编写一个析构函数。该规则的存在是为了防止你忘记编写析构函数而导致资源泄漏。
尽管如此,这种设计会导致 B::ap 可能错误。如果将它们合并成一个类或以某种更牢固的方式联系起来,则可以消除这种潜在的 bug 类型。

4
实际上,我赞同问题提出者所采取的方法。也就是说,如果有一个规则似乎每个人都同意,而你正在考虑打破它,不要只是思考,还应该寻求建议。当你偏离接受的惯例时,你可能会冒险遭遇被开发用来避免的陷阱,因此请不要轻易舍弃多年智慧的积累。 - Nate C-K

4
似乎 BA 强耦合,并且总是应该使用包含它的 A 实例?并且 A 总是包含一个 B 实例?它们通过友元访问彼此的私有成员。
因此,人们想知道为什么它们是单独的类。
但是假设您出于某些其他原因需要两个类,则以下是一个简单的修复方法,可以消除所有构造函数/析构函数混淆:
class A;
class B
{
     A* findMyA(); // replaces B::ap
};

class A : /* private */ B
{
    friend class B;
};

A* B::findMyA() { return static_cast<A*>(this); }

您仍然可以使用包含关系,并使用offsetof宏从Bthis指针中找到A的实例。但这比使用static_cast更混乱,需要手动计算指针。


哦,我从未想过以那种方式使用继承。在这个例子中,“findMyA()”是继承的唯一目的,对吗?这让我有点不安。也许我无法处理那种优雅。 - Sam Kauffman
@Sam:就对象布局而言,这实际上只是一个小改变:我用一个私有基类子对象替换了一个私有成员子对象。 - Ben Voigt
啊,这是私有继承。我以前从未遇到过。总是有新东西要学习。 - Sam Kauffman
那就是特征嫉妒。 - v.oddou

2

我同意@dspeyer的观点。你需要自己思考并做出决定。实际上,有人已经得出结论:通常情况下(如果在设计过程中做出正确的选择),三个规则可以简化为两个规则:通过库对象(如上述智能指针)管理资源,就可以通常情况下摆脱析构函数。如果你足够幸运,你可以摆脱所有东西,并依靠编译器为你生成代码。

顺便说一句:你的复制构造函数并没有复制编译器生成的那个。你在其中使用了复制赋值,而编译器会使用复制构造函数。摆脱构造函数体中的赋值,并使用初始化列表。这样会更快、更清洁。

很好的问题,Ben给出了很好的答案(这是在我的工作中让我的同事们感到困惑的另一个技巧),我很高兴给你们两个点赞。


啊,你说的拷贝构造函数是对的。那是我的疏忽。谢谢。点赞支持。 - Sam Kauffman

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