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:
Face
through a pointer to EntityBase
if Face
does not define any other data member and it is trivially destructible?Thanks to anybody who'll try to clarify this situation.
Code is on Compiler Explorer: https://godbolt.org/z/njfPT5aqT.
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.