c++destructorportability

Why MSVC is allowing direct instantiations of objects with protected destructor?


In a library I am working on, whose purpose is to wrap a C modelling library, I have a type EntityBase which serves as the base class for other classes like Body, Face, etc...:

class EntityBase
{
   public:

      // default ctor
      EntityBase() = default;

      // constructing from native handle
      explicit constexpr EntityBase(int handle) noexcept
         : m_handle{handle}{}

      [[nodiscard]] constexpr int handle() const noexcept { return m_handle; }

      // ... operations common among different entity types ...

   protected:

      // protected destructor to prevent deletion from pointer to base
      ~EntityBase() = default;

   private:

      int m_handle{0};
};

class Face : public EntityBase
{
   public:

      Face() = default;

      explicit constexpr Face(int handle) noexcept
         : EntityBase{handle}{}

      // ... operations specific for a face ...

   // IMPORTANT: no additional member is added to the Face class (and will never be by design)
};

// ... other classes (Body, Edge, etc...) ...

An important info is that EntityBase and its derived classes have the same size, i.e. the size of the integer handle that EntityBase is wrapping. That's because, by design, I want those types to represent strongly typed handles to be passed to the underlying C library, enforcing type safety but without overhead. Each type has therefore its semantics (body, face, edge, etc...), but some operations are in common (that's why the base class is there).

EntityBase does not have a virtual destructor, to avoid the overhead of the vtable, and to maintain value semantics for the library types.

Therefore, the destructor is declared protected to prevent someone from deleting a derived class from a pointer to EntityBase.

What I've found is that, oddly enough, MSVC allows instantiating instances of EntityBase directly:

consteval int test() noexcept
{
    EntityBase e{42};
    return e.handle();
}

int main()
{
    Face f{ 42 };

    EntityBase eb1;      // compiles on MSVC
    EntityBase eb2{ 1 }; // compiles on MSVC
    EntityBase eb3{ f }; // compiles on MSVC

    constexpr int value = test();

    return eb1.handle() + eb2.handle() + eb3.handle() + value; // returns 85 on MSVC 
}

The same code is instead rejected by GCC and Clang.

<source>: In function 'int main()':
<source>:40:16: error: 'constexpr EntityBase::~EntityBase()' is protected within this context
   40 |     EntityBase eb1;
      |                ^~~
<source>:19:5: note: declared protected here
   19 |     ~EntityBase() = default;
      |     ^
<source>:41:23: error: 'constexpr EntityBase::~EntityBase()' is protected within this context
   41 |     EntityBase eb2{ 1 };
      |                       ^
<source>:19:5: note: declared protected here
   19 |     ~EntityBase() = default;
      |     ^
<source>:42:23: error: 'constexpr EntityBase::~EntityBase()' is protected within this context
   42 |     EntityBase eb3{ f };
      |                       ^
<source>:19:5: note: declared protected here
   19 |     ~EntityBase() = default;
      |     ^
Compiler returned: 1

Note that MSVC (correctly) does not compile if I try to delete a Face from a pointer to EntityBase. Furthermore, as soon as I add a data member to a derived class, MSVC starts rejecting the direct instantiations of EntityBase.

It seems that, if and only if the base type has the same size as the derived one, MSVC allows direct instantiation of the base, even if it has a protected destructor. Furthermore, MSVC allows instantiations of EntityBase even in consteval functions.

My questions therefore are:

Thanks to anybody who'll try to clarify this situation.

Code is on Compiler Explorer: https://godbolt.org/z/njfPT5aqT.


Solution

  • who is correct here: MSVC or Clang/GCC?

    MSVC is clearly not conforming to the standard. The destructor must be accessible from the scope in which the variables of type EntityBase are defined per [class.dtor]/7 in connection with [class.dtor]/14.3. Specifically there is no exception for a trivial destructor. The destructor is invoked implicitly regardless. As a consequence it must be accessible.

    is it still UB to delete Face through a pointer to EntityBase if Face does not define any other data member and it is trivially destructible?

    Yes, the only thing that matters is whether or not the base destructor is virtual. It isn't here, so it is UB. See [exp.delete]/3.

    This is core language undefined behavior and therefore an expression which would perform this action is not a constant expression. The compiler has to diagnose this if used in a context that requires a constant expression.