1

Let's consider classes below. I am not asking if this is a good practice or a bad one. Naming of the classes are also arbitrary so it doesn't show any pattern or design.

class Proxy;

class ProxyImplementation
{
public:
    Proxy * proxy_;

    ProxyImplementation(Proxy * proxy): proxy_(proxy)
    {
    }

    ~ProxyImplementation()
    {
        // Is proxy_ valid here? or is it undefined behavior? 
        // is proxy_->number_ still valid here?  
    }


};

class Proxy
{
public:
    int number_;
    shared_ptr<ProxyImplementation> proxyImplementation_;
    Proxy()
    {
        number_ = 10;
        proxyImplementation_.reset(new ProxyImplementation(this));
    }

    ~Proxy() {}
};


In main

   Proxy *p = new Proxy();
   delete p;

Proxy passes this to ProxyImplementation in its constructor, when delete is called, Proxy destructor is called, then its member destructors in order of definition.

Is this valid during member destructors? In other words, is it valid to access proxy_ pointer in ~ProxyImplementation?

pmoubed
  • 3,842
  • 10
  • 37
  • 60
  • Non-static member variables are destroyed in ***reverse*** order of declaration. As for being "valid": What do you define as "valid". The only member of the `Proxy` object is currently being destructed at the time the destructor of `ProxyImplementation` runs, and any access to this variable is undefined behaviour. This of course ignores the possibility of a copy of the shared pointer still existing. I think you realized yourself that writing code like this is a terrible idea... – fabian Feb 27 '23 at 19:50
  • I updated my question, added `number` to make it more obvious what I mean by valid – pmoubed Feb 27 '23 at 20:01
  • @fabian but are members destroyed at the *beginning* of the destructor function, or at the *end*? I believe it's the end but I don't have a reference. – Mark Ransom Feb 27 '23 at 20:21
  • @MarkRansom - based on my test, it is when destructor returns - so at the end – pmoubed Feb 27 '23 at 20:22
  • 1
    Tests aren't a good way to determine these things, you never know if you're seeing guaranteed behavior or an implementation detail. – Mark Ransom Feb 27 '23 at 20:24
  • 1
    @MarkRansom - agreed! that's why I posted this in the first place and need someone to point me to a standard documentation to confirm. – pmoubed Feb 27 '23 at 20:25
  • @MarkRansom: After the destructor body completes... but the destructor body is empty so that's really not the important factor. The important factor is the ordering between the destructor of the `shared_ptr` (which is what invokes `~ProxyImplementation()`) and the destruction of other members of `Proxy` which `~ProxyImplementation()` wishes to access. And of course, what the C++ standard says. – Ben Voigt Feb 27 '23 at 20:28
  • The standard section [basic.life] talks about what things you are allowed to do with an object under destruction – M.M Feb 27 '23 at 20:44
  • @M.M: That section entirely passes the buck to [class.cdtor] – Ben Voigt Feb 27 '23 at 21:22
  • @MarkRansom: It *must* be after the destructor body has completed. Destructor bodies would lose *most* functionality if they couldn't access their own members after all; without access to the members, destructor bodies would be limited to manipulating global state and members with trivial destructors (read: not actually destroyed) only. So you could write an RAII class to manage a string (trivial pointer remains to be `delete[]`-ed), but if you used that class to store the generated path in a RAII temp file manager, the string would be gone before the destructor could unlink the path. – ShadowRanger Feb 28 '23 at 17:34
  • @pmoubed: Minor side-note: You almost always want to use `std::make_shared` over raw `new` calls when working with simple `shared_ptr`s. The only time `std::make_shared` is worse is when `sizeof` the object being managed is big, and you'll be using `std::weak_ptr`s that outlive all `std::shared_ptr`s; even then, you'll just waste an extra `sizeof CLASS` bytes between when the last `shared_ptr` is destroyed and when the last `weak_ptr` is destroyed. In exchange, you'll only perform one allocation for combined control block + object, not two, getting better memory locality/lower fragmentation. – ShadowRanger Feb 28 '23 at 17:42
  • See [this post on the differences between `make_shared` and direct `shared_ptr` construction](https://stackoverflow.com/q/20895648/364696). `make_shared` is almost always what you want, but in rare cases it might be inappropriate. – ShadowRanger Feb 28 '23 at 17:49

4 Answers4

7

The standard guarantees that members are destroyed in reverse order of declaration. All members must be destroyed before the object that contains them is destroyed (this is obvious by inspection; you can't destroy the contents of something that no longer exists). So, when it comes time for a Proxy instance to self-destruct, it must:

  1. Call the Proxy destructor (does nothing)
  2. Perform cleanup for proxyImplementation_
  3. Perform cleanup for number_ (nothing to do for simple primitive)
  4. Perform final cleanup for Proxy object itself

As such, if proxyImplementation_ was a unique_ptr (and the pointer always pointed to the original Proxy), this would be enough to say that both number_ and the Proxy object itself must still exist; they're not allowed to be destroyed until proxyImplementation_ is fully destroyed.

The problem is that you used a shared_ptr, which implies that somewhere, there may be other shared_ptrs to the same ProxyImplementation object. So when a given Proxy is destroyed, there is no guarantee the ProxyImplementation it holds will get destroyed at that time. If it gets destroyed later, proxy_ won't exist, and terrible things will happen.

In short, this is safe if you change to using a unique_ptr that you never replace after construction and unsafe if you actually share a shared_ptr. If you do use a unique_ptr to make it safe, since you declared it as a public member, I'd recommend making it a properly initialized const unique_ptr, which guarantees neither you, nor any consumer of your class can (without evil const_casts) replace the unique_ptr's owned pointer, so it will always be the original value. Fixed up code would be (comments on changed lines):

class Proxy;

class ProxyImplementation
{
public:
    Proxy *const proxy_; // Explicitly declare constant pointer; 1-1 permanent
                         // relationship between Proxy and Implementation that
                         // consumers of your class can't change

    ProxyImplementation(Proxy *proxy): proxy_(proxy) {}

    ~ProxyImplementation()
    {
        // proxy_ is valid here
        // proxy_->number_ is valid here
    }
};

class Proxy
{
public:
    int number_;
    // Changed from shared_ptr to const unique_ptr: initialize once, can't be
    // reassigned, guaranteed to perform cleanup during destruction (no possibility
    // of shared_ptr copied by class consumer keeping it alive after Proxy is gone)
    const unique_ptr<ProxyImplementation> proxyImplementation_;

    // Use initializers, not assignment in constructor body, so const unique_ptr
    // can be assigned precisely once
    Proxy() : number_(10), proxyImplementation_(new ProxyImplementation(this)) {}
    // Or if you can guarantee C++14 or higher, and want new/delete-free code:
    Proxy() : number_(10),
              proxyImplementation_(std::make_unique<ProxyImplementation>(this)) {}

    // Got rid of explicitly declared empty destructor; let compiler generate it
};
ShadowRanger
  • 143,180
  • 12
  • 188
  • 271
  • Your point about sharing ownership with another `shared_ptr` is completely valid. However according to my reading of the Standard, your other point about the order in which member subobjects are destroyed seems not to matter. – Ben Voigt Feb 27 '23 at 20:50
  • @BenVoigt: The order of member subobjects being destroyed only matters insofar as, if `number_` were declared *after* `proxyImplementation_ `, it would be destroyed before it, and couldn't be relied on. For a primitive type like `int`, this (probably?) doesn't make a difference, but if `number_`'s destruction did something meaningful, and it got destroyed before `proxyImplementation_ `, `ProxyImplementation`'s destructor couldn't safely access it. – ShadowRanger Feb 27 '23 at 20:59
  • That makes perfect sense, and probably is how the rules should be written. But the actual rule as written (https://eel.is/c++draft/class.cdtor#3) says that you can access the value of a member subobject if the containing object hasn't finished destruction. – Ben Voigt Feb 27 '23 at 21:20
  • @BenVoigt: The memory would exist, and I suppose you could manually perform placement `new` and manual destruction into said memory, but I'm pretty sure the ability to form a pointer to that member isn't sufficient to *use* said member (in any useful manner based on its original value) when the member has already undergone destruction. I think the language there lets you legally form the pointer, and use what it points to if it has a useful value, but if the member subobject's destructor has run already, you'd run afoul of https://eel.is/c++draft/class.cdtor#1 on that same page. – ShadowRanger Feb 27 '23 at 22:01
  • Again, this is assuming `number_` isn't just an `int`, but something with a non-trivial destructor. When `number_` is an `int`, the destructor is trivial, so the declaration order (probably) doesn't matter, because destroying an `int` does nothing, so you could still access it after the `int` was destroyed so long as the object containing it still existed (which it would, in this case). – ShadowRanger Feb 27 '23 at 22:03
  • I quoted that in my answer... in cdtor#1, it's talking about the destructor of the containing object... isn't it? It's badly (ambiguously) worded and should not say "**the** destructor" with multiple destructors in play. Note the old wording unambiguously refers to the destructor of the containing object, because the subobjects were not mentioned until later in the sentence: "For an object of non-POD class type, before the constructor begins execution and after the destructor finishes execution, referring to any nonstatic member or base class of the object results in undefined behavior" – Ben Voigt Feb 27 '23 at 22:21
  • unique_ptr is safe in my example but if main is changed to move unique_ptr to `copy` then same issue as shared_ptr still exists: `Proxy *p = new Proxy(); unique_ptr copy = move(p->proxyImplementation_); delete p;` – pmoubed Feb 28 '23 at 16:01
  • @pmoubed: Yep, that's why I qualified that claim with "and the pointer always pointed to the original `Proxy`". It's not that `unique_ptr` is uniquely safe, it's that `shared_ptr` *implies* (without strictly requiring) that sharing will occur (you wouldn't usually use `shared_ptr` unless you intended to, y'know, *share* it), and actually *using* said sharing means you can screw things up in new and exciting ways. – ShadowRanger Feb 28 '23 at 16:38
  • 1
    @pmoubed: To be clear, I believe you could make the `unique_ptr` truly safe by making it `const` and initializing it properly, e.g. declaring with `const unique_ptr proxyImplementation_;` and changing the `Proxy` constructor to use initializers `Proxy() : number_(10), proxyImplementation_(std::make_unique(this)) {}`, [guaranteeing the owned object can't be replaced for the life of the object](https://stackoverflow.com/q/36518985/364696). You must be careful of circular dependencies (the `this` passed to `ProxyImplementation` isn't fully constructed). – ShadowRanger Feb 28 '23 at 16:41
  • 1
    @pmoubed: I've edited the answer to include the example code for the fully safe design (that makes all these pointers `const` [to be clear, only the pointers, not what they point to]). If the pointer members were private, you'd at least be able to apply personal discipline to avoid misusing them, but as public members, a consumer of your class could Do Evil Things™ and break your invariants if they're non-`const` (you didn't say they were `const`, they're allowed to do it!); leaving them public but `const`, means they can't break invariants (without special effort, and that'd be their fault). – ShadowRanger Feb 28 '23 at 17:24
  • correct me if I am wrong: proxy_ is a valid pointer (in `~ProxyImplementation`) but is pointing to an object under destruction – pmoubed Feb 28 '23 at 19:48
  • 1
    @pmoubed: Yep. But because `~ProxyImplementation` is the *first* thing being destructed as part of that other object's destruction (due to being the last declared member of `Proxy`), the rest of the object it points to has not actually done any destruction yet (all the other members can't destruct until `proxyImplementation_` has *finished* being destroyed), so the rest of the `Proxy` members are fully usable. – ShadowRanger Feb 28 '23 at 21:41
2
// Is proxy_ valid here? or is it undefined behavior? 

The pointer is "valid" but refers to the storage of an object whose lifetime has ended.

The lifetime of an object o of type T ends when:

...

  • if T is a class type, the destructor call starts, or

See https://eel.is/c++draft/basic.life#1.4

after the lifetime of an object has ended and before the storage which the object occupied is reused or released, any pointer that represents the address of the storage location where the object will be or was located may be used but only in limited ways

See https://eel.is/c++draft/basic.life#6

// is proxy_->number_ still valid here?

It maybe shouldn't be, but it is (assuming this was the last outstanding reference to the ProxyImplementation, and so its destructor runs inside the shared_ptr member destructor. See @ShadowRanger's answer for more details on why that's important). The rules presented below allow access to all member subobjects up until the moment the whole containing object finishes destruction.

The program has undefined behavior if:

...

  • the pointer is used to access a non-static data member

See https://eel.is/c++draft/basic.life#6.2

However, there is an escape clause written in, namely

For an object under construction or destruction, see [class.cdtor].

And the rules we find there seem to indicate it will be allowed:

For an object with a non-trivial destructor, referring to any non-static member or base class of the object after the destructor finishes execution results in undefined behavior.

See https://eel.is/c++draft/class.cdtor#1

The destructor body is trivial, but the entire destructor, including compiler-generated calls to member destructors, is non-trivial. It's the latter that matters. But the latter also hasn't finished execution, so by this rule we're still ok.

To form a pointer to (or access the value of) a direct non-static member of an object obj, the construction of obj shall have started and its destruction shall not have completed, otherwise the computation of the pointer value (or accessing the member value) results in undefined behavior.

See https://eel.is/c++draft/class.cdtor#3

obj is the Proxy instance, and its destructor is still in progress, so accessing the member value is still ok according to this rule as well.

Ben Voigt
  • 277,958
  • 43
  • 419
  • 720
1
  1. An object of a class type in the middle of destruction is still an object. You can refer to its members that are not destroyed yet. ref
  2. Destruction of subobjects happens in the reverse order of their construction. ref
  3. Subojects are destroyed after the main object's destructor body finishes. ibid

In your example, *proxy_ is being destroyed. Its destructor body has finished, and destruction of its subobjects is taking place. The one that is currently being destroyed is proxy_->proxyImplementation_. Destruction of proxy_->number_ has not started yet. It is thus OK to touch proxy_->number_ from the destructor body of proxy_->proxyImplementation_.

n. m. could be an AI
  • 112,515
  • 14
  • 128
  • 243
  • The order of destruction of subobjects does apparently not matter. See https://eel.is/c++draft/class.cdtor#3 – Ben Voigt Feb 27 '23 at 20:47
  • @BenVoigt the passage you refer to does not grant you permission to access the value of a completely-destroyed object, even if it is a subobject of an incompletely-destroyed object. [Other](https://eel.is/c++draft/basic.life#6) [restrictions](https://eel.is/c++draft/basic.life#7) do not magically disappear. – n. m. could be an AI Feb 27 '23 at 21:10
  • They do contain the magic "otherwise" incantation to make them disappear, actually. "For an object under construction or destruction, see [class.cdtor]. Otherwise" Pointed that out in my answer, where I quoted from those paragraphs. – Ben Voigt Feb 27 '23 at 21:16
  • @BenVoigt It is not magic. "You must be at least 21 year old otherwise they won't sell you beer". It does not follow that if you are 21 year old, they will sell you beer. Perhaps they are closed, or thet don't have any beer, or you don't have enough money to buy beer. – n. m. could be an AI Feb 27 '23 at 21:36
0

To answer the questions in the quoted code:

~ProxyImplementation()
{
    // Is proxy_ valid here? or is it undefined behavior? 
    // is proxy_->number_ still valid here?  
}

At the time ~ProxyImplementation() executes, the Proxy object that proxy_ is pointing to is in an "interesting" partially-valid state:

  • The ~Proxy() destructor has already finished executing, which means that any resources that were explicitly freed up inside that destructor are no longer available

  • The member-variables in the Proxy object that were declared after proxyImplementation_ in the Proxy class-header have already been destroyed. (the member-variables declared before proxyImplementation_, like number_, and proxyImplementation_ itself, are still valid)

  • Perhaps needless to say, any virtual-method-calls to the Proxy class are no longer going to call their method-overrides in Proxy's subclasses, since those subclasses' destructors have also already finished executing and the Proxy object's vtable-pointer has been modified to reflect that.

My recommendation is that ~ProxyImplementation() try to avoid dereferencing its proxy_ pointer if at all possible... or if it does dereference it, you'll need to be very careful to only access the still-valid member-variables (and which ones are still-valid could change if/when someone reorders the member-variable declarations in the Proxy class declaration, so watch out!)

Jeremy Friesner
  • 70,199
  • 15
  • 131
  • 234