Enforce a member destruction order compile-time in C++
Asked Answered
M

3

6

I have a C++ class A like this:

// third-party classes / methods, unable to change
class C {};
class B {
public:
    C* getC();
};
B* legacyAPIToGetB(const char*);

// the target class
class A {
public:
    A() {
        // not using initialization lists
        // because something have to be calculated prior B construction
        b = std::unique_ptr<B>(legacyAPIToGetB("calculated value"));
        // throw an exception (don't construct A) if B cannot be constructed
        if (!b) throw std::exception{"Cannot get B"};

        c = std::unique_ptr<C>(b->getC());
        if (!c) throw std::exception{"Cannot get C"};
    }

    std::unique_ptr<B> b = nullptr;
    std::unique_ptr<C> c = nullptr;
};

Say b is a dynamic library handle, and c is an object created with that library. In this case, destructor of b is called after c, which is fine.

However if someone later reorganizes the code, they could accidentally swap the declaration of b and c. Thus the destruction order is reversed, leading to possibly hard-to-diagnose runtime crash.

I want to make sure if someone accidentally makes that change, the code should fail to compile (possibly with a custom error message). Is this possible?

(Or maybe this design is flawed from the beginning?)


I tried to use static_assert to ensure a member order:

static_assert(
    reinterpret_cast<void*>(&reinterpret_cast<A*>(nullptr)->b)
    < reinterpret_cast<void*>(&reinterpret_cast<A*>(nullptr)->c),
    "destruction of b should be after c");

However, this seems invalid C++ code which only works in MSVC.

Update: One may use the offsetof macro to check the layout of members, see IlCapitano's answer for detail. While "if type is not a standard layout type (since C++11), the result of offsetof is undefined (until C++17) / use of the offsetof macro is conditionally-supported (since C++17)", it seems major compilers supported it at least in this situation.

(I'm not even sure whether asserting the memory layout guarantees the destruction order!)
Update: Unfortunately, it's not guaranteed that the members declared later will have higher addresses, until C++23.

(until C++23): Members separated by an access specifier (until C++11) / with different access control (since C++11) are allocated in unspecified order (the compiler may group them together). source


As noted in the comment, it's possible to use a custom destructor to reset the pointers in the correct order:

A::~A() {
    // destruction of b should be after c
    c.reset();
    b.reset();
}

However if a constructor throws an exception, the object’s destructor is not run. As noted above, I'm throwing an exception from A::A() if something goes wrong. This is also a reason why I use unique_ptrs.

It's possible to use some tricks (e.g. keep the other values local, and std::swap them into the class in one go) to achieve exception safety, however, this is added complexity.

Mew answered 11/7, 2023 at 7:58 Comment(8)
How about creating a destructor which release the objects in the correct order? Then the order of destruction is irrelevant. Remember to add documentation and comments to say why this is needed.Elbring
You could define the destructor and manually release the unique pointers yourself, with a comment.Dorran
@some-programmer-dude @Dorran If a constructor throws an exception, the object’s destructor is not run. This is a practical situation in my class, which is why I use unique_ptr for automatic delete-ing. Sure we can use other tricks to achieve exception safety, but that's added complexity.Mew
That should have been a very important part of the question itself. This is why we so often ask posters to include a proper minimal reproducible example. Please read the help pages, take the SO tour, read How to Ask, as well as how to write the "perfect" question, especially its checklist. Then edit your question to improve it.Elbring
@some-programmer-dude It's easy to get overcomplicated this way. Never mind, additional context added.Mew
std::unique_ptr<B> b = nullptr; - this is a bit weird. The default ctor does the same thing.Jesusitajet
For your static_assert, you might do it with offsetof.Consistency
"// not using initialization lists". You might, even with your constraints; immediate called lambda (or regular function) might do the job.Consistency
J
4

This looks like a case where the ownership is artificially simplified. Since c needs b alive, it would make sense for c to own a shared_ptr<B>. This ownership would be shared with class A.

Swapping A::b and A::c would cause A::c to be reset early, but the C library remains loaded because b still has a pointer.

Jesusitajet answered 11/7, 2023 at 8:27 Comment(2)
This is the ideal solution if I'm able to modify B and C. However, in this situation, these classes are not under our control (i.e. from a third-party). Question updated for clarification.Mew
@AlexGuo1998: You can always wrap B and/or C. In particular, here it should be easy to combine C and std::shared_ptr<B>.Jesusitajet
P
1

You can use the offsetof macro to get the offsets of the members as constant expressions. You will need to put the static_assert either outside of the class (if the members are public), or inside of a member function (if the members are private) for it to work.

class A {
    std::unique_ptr<B> b = nullptr;
    std::unique_ptr<C> c = nullptr;
    
    static void ensure_order() {
        static_assert(offsetof(A, b) < offsetof(A, c), "'b' needs to be declared before 'c'");
    }
};
Pagoda answered 11/7, 2023 at 9:10 Comment(4)
Unfortunately this doesn’t work because offsetof only works on standard layout types, and having a std::unique_ptr member automatically makes the type non-standard layout.Dichlorodiphenyltrichloroethane
@KonradRudolph std::unique_ptr can be (and usually is because default_delete would have no members) a standard layout type, meaning that A would be standard layout. Even so, offsetof is "conditionally supported" on non-standard layout types, and most compilers allow it to be used for direct members of all typesPrecautious
@Precautious The compiler seems to disagree. I have to admit that I am a bit rusty, not having used C++ in over a year, but I also remember the informal requirement that offsetof only works on PODs which can’t have a custom destructor, and std::unique_ptr<T, std::default_delete<T>> definitely has that.Dichlorodiphenyltrichloroethane
I think the real question is "Is the compiler required to put b at a lower memory address than c, if b is defined earlier?" However this is not guaranteed before C++23. Members separated by an access specifier (until C++11) / with different access control (since C++11) are allocated in unspecified order (the compiler may group them together). Meanwhile, checking (MSVC, clang, GCC) in C++14 mode shows they do not group members. resultMew
P
0

You can actually enforce that the destructor is run when a constructor throws an expression. The trick is to use a delegating constructor:

class A {
private:
    explicit A(nullptr_t) {}    // does the "default" construction
public:
    A() :
        A(nullptr)     // delegate to a "default" constructor
    {
        b = std::unique_ptr<B>(legacyAPIToGetB("calculated value"));
        // throw an exception if B cannot be constructed
        if (!b) throw std::exception{"Cannot get B"};
        // if the exception is thrown, the destructor will run
        // because the A object is already fully constructed

        c = std::unique_ptr<C>(b->getC());
        if (!c) throw std::exception{"Cannot get C"};
    }
    A::~A() {
        // destruction of b should be after c
        c.reset();
        b.reset();
    }


    std::unique_ptr<B> b;
    std::unique_ptr<C> c;
};

In the example, I used a constructor with an argument (A(nullptr_t)) to which construction is delegated. But if in your code the "actual" constructor takes arguments, you can delegate to the default constructor instead.

Prather answered 12/7, 2023 at 6:23 Comment(0)

© 2022 - 2025 — McMap. All rights reserved.