我读了很多关于c++三法则的书。许多人对它信誓旦旦。但当规则被陈述时,它几乎总是包含一个像"通常"、"可能"或"可能"这样的词,表明有例外。我还没有看到很多关于这些例外情况的讨论——在这些情况下,三法则不成立,或者至少坚持它不会带来任何好处。
我的问题是我的情况是否是"三原则"的合法例外。我相信在我下面描述的情况下,显式定义的复制构造函数和复制赋值操作符是必要的,但是默认的(隐式生成的)析构函数可以很好地工作。以下是我的情况:
我有两个类,A和B。这里讨论的是A。B是A的友类。A包含一个B对象。B包含一个A指针,该指针指向拥有B对象的A对象。B使用该指针操作A对象的私有成员。B永远不会被实例化,除非在A构造函数中。这样的:
// 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.b
与a1.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 );
的相同过程来实现。
但是不需要显式析构函数;因此,这种情况是"三原则"的例外。我说的对吗?
不要太担心"三原则"。规则不是让你盲目遵守的;它们的存在是为了让你思考。你的想法。你已经得出析构函数不会这样做的结论。所以不要写。该规则的存在是为了避免忘记编写析构函数,从而泄漏资源。
尽管如此,这种设计还是有可能使B::ap出错。这是一整类潜在的bug,如果它们是一个单独的类,或者以某种更健壮的方式捆绑在一起,就可以消除它们。
似乎B
与A
强耦合,并且总是应该使用包含它的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
宏从B
的this
指针中找到A
的实例。但这比使用static_cast
和编译器为您调用指针数学更麻烦。
我选@dspeyer。你思考,你决定。实际上,有人已经得出结论,三通常规则(如果你在设计过程中做出了正确的选择)可以归结为二规则:让你的资源由库对象管理(就像上面提到的智能指针),你通常可以摆脱析析函数。如果你足够幸运,你可以摆脱所有,依靠编译器为你生成代码。
边注:你的复制构造函数不会复制编译器生成的一个。你在它里面使用拷贝赋值,而编译器会使用拷贝构造函数。去掉构造函数体中的赋值,使用初始化列表。这样会更快更干净。
Ben问得好,回答得好(这是另一个让我同事困惑的技巧),我很高兴给你们俩点赞。