Exception to the Rule of Three?
Asked Answered
S

3

16

I've read a lot about the C++ Rule of Three. Many people swear by it. But when the rule is stated, it almost always includes a word like "usually," "likely," or "probably," indicating that there are exceptions. I haven't seen much discussion of what these exceptional cases might be -- cases where the Rule of Three does not hold, or at least where adhering to it doesn't offer any advantage.

My question is whether my situation is a legitimate exception to the Rule of Three. I believe that in the situation I describe below, an explicitly defined copy constructor and copy assignment operator are necessary, but the default (implicitly generated) destructor will work fine. Here is my situation:

I have two classes, A and B. The one in question here is A. B is a friend of A. A contains a B object. B contains an A pointer which is intended to point to the A object that owns the B object. B uses this pointer to manipulate private members of the A object. B is never instantiated except in the A constructor. Like this:

// A.h

#include "B.h"

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

and...

// 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

Why would I set up my classes like that? I promise, I have good reasons. These classes actually do way more than what I've included here.

So the rest is easy, right? No resource management, no Big Three, no problem. Wrong! The default (implicit) copy constructor for A will not suffice. If we do this:

A a1;
A a2(a1);

we get a new A object a2 that is identical to a1, meaning that a2.b is identical to a1.b, meaning that a2.b.ap is still pointing to a1! This is not what we want. We must define a copy constructor for A that duplicates the functionality of the default copy constructor and then sets the new A::b.ap to point to the new A object. We add this code to 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
    };

A copy assignment operator is necessary for the same reason and would be implemented using the same process of duplicating the functionality of the default copy assignment operator and then calling b.init( this );.

But there is no need for an explicit destructor; ergo this situation is an exception to the Rule of Three. Am I right?

Selfish answered 21/3, 2013 at 20:27 Comment(11)
We need more of this type of questions.Eardrum
Also note that your include guard _B is illegal since all underscores followed by a capital letter are reserved for the system.Marlenmarlena
For C++11, better is the Rule of Zero: flamingdangerzone.com/cxx11/2012/08/15/rule-of-zero.html In that case, you'd manage the lifetimes of A and B with std::unique_ptr, std::shared_ptr, and of some use here, std::weak_ptr (or similar owning classes). That takes all the mystery out of it for your code's readers, including you in 6 months.Marlenmarlena
@Marlenmarlena Care to elaborate how that helps? I've had an (admittedly brief) look at that article, but as far as I can see it deals with resource ownership and lifetime management only, completely neglecting the kind of 'circular' dependency that this question is about. How does the Rule of Zero handle that case?!Ambassador
Yes, it is overall an exception, in that you do not need a destructor (because the B doesn't actually own the resource) However, you WILL need to define the assignment operator, as it has the same problem the default copy constructor has.Waring
@us2012: std::weak_ptr is for circular references in combination with std::shared_ptr. The general point is, wrap resource management up in separate, reusable classes so you don't need the rule of 3 (or 5).Marlenmarlena
@Marlenmarlena Maybe I'm being stupid here, but - weak_ptr takes care of ownership when you have circular references. How does it, or the article, or the rule of zero, help with the problem mentioned in this question (which is NOT a problem about ownership)?Ambassador
The rule of three does not apply here as neither class owns the other. So this question is nothing to do with the rule of three and is simply about wiring.Madi
@us2012, you're right that the standard RAII classes don't really solve your problem as stated. The Rules of 3, 5, and 0 are related to ownership of resources, and as Loki Astari notes, you're mostly talking about something else (though of course you should be concerned about ownership, and these classes are good ways to manage it). If you follow the Rule of 0 religiously, you should never see any of the 3 (or 5 in C++11) special functions coded manually, except in exceptional circumstances, and this would seem to be one of them.Marlenmarlena
Since this will be on StackOverflow for all eternity, I went ahead and fixed the include guard. I don't want to promote illegal activities.Selfish
@Marlenmarlena rule of 0 was already perfectly valid in C++03 with boost.Sodamide
C
9

Don't worry so much about the "Rule of Three". Rules aren't there to be obeyed blindly; they're there to make you think. You've thought. And you've concluded that the destructor wouldn't do it. So don't write one. The rule exists so that you don't forget to write the destructor, leaking resources.

All the same, this design creates a potential for B::ap to be wrong. That's an entire class of potential bugs that could be eliminated if these were a single class, or were tied together in some more robust way.

Chirurgeon answered 21/3, 2013 at 21:33 Comment(1)
Actually I agree with the approach that the question poster has taken here. That is: If there is a rule that everyone seems to agree on, and you're thinking about breaking it, don't just think, also ask for advice. When you deviate from accepted practice you risk running into the pitfalls that the accepted practice was developed to avoid, so do hesitate to discard the wisdom of many people's accumulated years of wisdom.Ignoramus
S
4

It seems like B is strongly coupled to A, and always should use the A instance that contains it? And that A always contains a B instance? And they access each other's private members via friendship.

One therefore wonders why they are separate classes at all.

But assuming you need two classes for some other reason, here is a straightforward fix that gets rid of all your constructor/destructor confusion:

class A;
class B
{
     A* findMyA(); // replaces B::ap
};

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

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

You could still use containment, and find the instance of A from B's this pointer using the offsetof macro. But that's messier than using static_cast and enlisting the compiler to the the pointer math for you.

Satinwood answered 21/3, 2013 at 20:42 Comment(4)
Hmmm, I'd never thought of using inheritance that way. In this example, findMyA() is the only purpose for the inheritance, right? That makes me a bit uneasy. Maybe I can't handle that kind of elegance.Selfish
@Sam: It's actually a small change as far as object layout is concerned: I replaced a private member subobject with a private base subobject.Satinwood
Ah, it's private inheritance. I've never encountered that before. Always new things to learn.Selfish
that is feature envy.Sodamide
L
2

I go with @dspeyer. You think and you decide. Actually someone has already concluded that the rule of three usually (if you make right choices during your design) goes down to the rule of two: make your resources managed by library objects (like above mentioned smart pointers) and you usually can get rid of destructor. If you are lucky enough you can get rid of all and rely on compiler to generate the code for you.

On the side note: your copy constructor DOES NOT duplicate compiler generated one. You use copy assignment inside of it while compiler would use copy constructors. Get rid of assignments in your constructor body and use initializer list. It will be faster and cleaner.

Nice question, nice answer form Ben (another trick to confuse my colleagues at work) and I am happy to give both of you upvotes.

Lam answered 21/3, 2013 at 22:41 Comment(1)
Ah, you're right about the copy constructor. That was an oversight. Thanks. Upvote for that.Selfish

© 2022 - 2024 — McMap. All rights reserved.