Search code examples
c++language-lawyerbase-classincomplete-typetemplate-instantiation

Inconsistent type completeness in the destructor of a template base class


Please ignore the dubious inheritance pattern from a design point of view. Thanks :)

Consider the following case:

#include <memory>

struct Foo;

struct Bar : std::unique_ptr<Foo> {
    ~Bar();
};

int main() {
    Bar b;
}

In both GCC and Clang, compiling this as an independent TU raises an error:

In instantiation of 'void std::default_delete<_Tp>::operator()(_Tp*) const [with _Tp = Foo]':
  required from 'std::unique_ptr<_Tp, _Dp>::~unique_ptr() [with _Tp = Foo; _Dp = std::default_delete<Foo>]'
error: invalid application of 'sizeof' to incomplete type 'Foo'
  static_assert(sizeof(_Tp)>0,
                      ^

This is GCC's, Clang's one is similar, and both point at the definition of struct Bar. Additionally, adding the missing definitions after main() fixes the error:

// Same as above

struct Foo { };

Bar::~Bar() = default;

It doesn't sound right to me that std::unique_ptr's destructor needs to be instantiated right when defining Bar, since it is only called by Bar's destructor which is defined out-of-line.

I find it even weirder that adding the definitions after everything else , where they shouldn't be reachable, apparently fixes the problem.

Should the first snippet be correct, and if not, why? What's happening in the second one that fixes it?


Solution

  • See [class.base.init]/12

    In a non-delegating constructor, the destructor for each potentially constructed subobject of class type is potentially invoked ([class.dtor]). [Note 5: This provision ensures that destructors can be called for fully-constructed subobjects in case an exception is thrown ([except.ctor]). — end note]

    So when you do

    Bar b;
    

    then you force the compiler to emit a definition for the implicit default constructor Bar::Bar(), and that means that the destructor for the base class, std::unique_ptr<Foo>, is potentially invoked. That, in turn, causes it to be odr-used, which implies that its definition is required, which causes it to be instantiated, which ultimately instantiates std::default_delete<Foo>::operator()(Foo*), which is ill-formed because Foo is incomplete at that point.

    If the constructor of Bar is declared in the class definition (not as = default), but then defined out-of-line at some later point where Foo has been completed, then the problem is fixed.

    Note that the issue has nothing to do with whether Bar itself may be further derived from. Bar already derives from std::unique_ptr<Foo> and it is this that causes each constructor of Bar to "potentially invoke" the destructor of std::unique_ptr<Foo>. Now, in some cases, such as the case above, we can tell that a defaulted Bar::Bar() can never fail by throwing an exception, so there is no situation where this defaulted Bar::Bar() actually invokes the destructor of a base class. However, from the point of view of the language specification, it is easier to say that a (non-delegating) constructor always "potentially invokes" each base class destructor than to try to carve out a narrow set of exceptions where it does not do so (and thus does not cause the instantiation of the definitions of those destructors). So that's just how the rules are.

    When you add the missing definition of Foo after the point where std::default_delete<Foo>::operator()(Foo*) is instantiated, the result you are seeing has to do with [temp.point]/7:

    A specialization for a function template [...] may have multiple points of instantiations within a translation unit, and in addition to the points of instantiation described above,

    • for any such specialization that has a point of instantiation within the declaration-seq of the translation-unit, prior to the private-module-fragment (if any), the point after the declaration-seq of the translation-unit is also considered a point of instantiation, and
    • [...]

    If two different points of instantiation give a template specialization different meanings according to the one-definition rule, the program is ill-formed, no diagnostic required.

    Since std::default_delete<Foo>::operator()(Foo*) is a function template specialization, it has two points of instantiation: one at the end of main (see [temp.point]/1) and one at the end of the translation unit. The rule is designed to give implementations the freedom to defer instantiation of function templates until the end of the translation unit. If they choose to defer such instantiation, then they will see a complete Foo at that point, and not notice any problem. They are not required to diagnose the fact that Foo was incomplete at an earlier point of instantiation. That's what "ill-formed, no diagnostic required" means.

    (Well, one little issue is that it's not totally clear whether the "ill-formed, no diagnostic required" thing actually applies here, since it's not clear whether the instantiations at the two points of instantiation actually produce "different meanings according to the one-definition rule". You have no way of knowing that unless you know the implementation of std::default_delete<Foo>::operator()(Foo*). In my opinion the rule needs to be reworded a bit to reflect the intent better.)