Rationale for non-virtual derived class not being pointer-interconvertible with its first base
Asked Answered
T

2

5

There are a few questions and answers on the site already concerning pointer-interconvertibility of structs and their first member variable, as well as structs and their first public base. This question is one of them, for example.

However, what I'm interested in is not the fact that it's undefined behavior to reinterpret_cast (or static_cast through a void *) between a non-standard-layout struct and its public base, but rather the reasoning why the C++ standard currently forbids such casts. The existing questions and answers don't cover this aspect.

Consider the following example in particular (Godbolt):

#include <type_traits>

struct Base {
  int m_base_var = 1;
};

struct Derived: public Base {
  int m_derived_var = 2;
};

Derived g_derived;

constexpr Derived *g_pDerived = &g_derived;
constexpr Base *g_pBase = &g_derived;
constexpr void *g_pvDerived = &g_derived;

//These assertions all hold
static_assert(!std::is_pointer_interconvertible_base_of_v<Base, Derived>);
static_assert((void *)g_pDerived == (void *)g_pBase);
static_assert((void *)g_pDerived == g_pvDerived);
static_assert((void *)g_pBase == g_pvDerived);

//This is well-defined and returns &g_derived
Derived * getDerived() {
  return static_cast<Derived *>(g_pvDerived);
}

//This is also well-defined; outer static_cast added to illustrate the sequence of conversions
Base * getBase() {
  return static_cast<Base *>(static_cast<Derived *>(g_pvDerived));
}

//This is UB due to the first static_assert!
Base * getBaseUB() {
  return static_cast<Base *>(g_pvDerived);
}

As you can see from the Godbolt link, all three functions compile to the exact same assembly on x86-64 GCC. However, the standard forbids the third variant since Base is not a pointer-interconvertible base of Derived.

My question is: Is there an obvious reason why the standard forbids this kind of cast? In particular, on all implementations that I know of, the value of a pointer to the Base subobject is the same as that of the pointer to the whole Derived, and I don't see a particular reason why Derived should not be considered standard-layout anymore. (In other words, Base lives at offset zero within Derived.) Would it be legal for a C++ implementation to place Base at a non-zero offset within Derived? (Is there maybe an implementation already that does this?)

Note that this question is only about cases without virtual member functions / virtual inheritance / multiple inheritance.

Thirtythree answered 5/2, 2023 at 14:17 Comment(4)
Non-standard layout classes, well, don't have a standard layout. The line has to be drawn somewhere.Frozen
That's the thing: The line has been drawn at the point where Base and Derived both have non-static data members. At that point, Derived is no longer standard-layout, and this kind of cast / aliasing is forbidden. It seems rather arbitrary to me, so I'm wondering whether there's a technical reason for it or whether the committee just chose to draw the line somewhere, and it happened to be there.Thirtythree
Seems to me the obvious answer is that it is "forbidden" (undefined behavior) because there could be virtual member functions, virtual inheritance, multiple inheritance. And this situation was not special cased to be allowed.Geller
@Eljay: That makes no sense. If there were virtual anything, the type wouldn't be standard layout, as that is explicitly forbidden already. You don't need to double-forbid it.Decisive
D
5

This is really two question:

  1. What is standard layout, like really?

  2. Why is pointer-interconvertibility linked to standard layout?

Standard layout was constructed out of one half of the pre-C++11 concept of "plain old data" types. The other half is trivial copyability (ie: memcpying an instance of the object is just as good as a copy constructor). These two halves didn't really interact, but POD required both.

From a purely standard C++ perspective, standard layout is a requirement for the ability to construct a type whose layout matches an existing type, such that if you shove both of those types into a union, you can access subobjects of the non-active members. This is the core functionality that standard layout enabled within the language since it was invented in C++11.

This is why standard layout only allows one member in the class hierarchy to have non-static data members. If only one class has NSDMs, then there's no question about the ordering between NSDMs of different base classes and so forth. And being able to know a priori what that ordering is is vital for being able to know that two types match.

This is also useful for communicating across languages.

Once standard layout was defined however, it started getting used for things that were... less clearly part of its domain.

For example, standard layout became the determinator for whether offsetof was valid behavior. This is due to offsetof oroginally being based on being a POD type, so when the layout part was spun off, offsetof was updated to use that. However, this was suboptimal, as the only layout-based thing that would break offsetof is having virtual base classes (the offset of members of a virtual base class depends on the most-derived class, which depends on the runtime type of the object). Now, the new restriction was still was better than POD, but it could have been expanded to include more stuff. But that would mean coming up with a new definition.

Something similar likely goes with pointer-interconvertibility. This concept was invented in C++17 to resolve various issues with the object model. There is no evidence in the papers explaining why they picked standard layout to hang pointer-interconvertibility on. But it was an existing tool with well-defined rules that already had well-defined rules on what the "first subobject" was for any given type.

Expanding the rules like you wants requires creating a new definition for "first suboject".

Is a base class pointer-interconvertible to a particular derived class? Well, that depends on what other classes that derived class inherits from. Is the first NSDM pointer-interconvertible to the class it is a member of? That depends on what other classes are involved in the inheritance diagram.

These dependencies already exist, but they all key off of a specific, pre-existing rule. What you want requires creating a new rule that's more complex. It will have to copy 90% of the existing standard-layout rules (forbidding virtual, public/private members, etc) and then add its own rules. The first NSDM is pointer-interconvertible unless any base classes are non-empty. Any particular base class is pointer-interconvertible only so long as all previous base classes in declaration order are empty of NSDMs.

It's so much easier to just piggyback off of the standard layout rules and say "a standard-layout type is pointer-interconvertible with its first NSDM and all of its base classes."

Having an extra rule also imposes some burden on the specification. It's a partial redundancy, and that breeds errors. For example, C++23 is on track to expand standard layout types by removing the forbidding of mixing public and private members, forcing layout to be ordered strictly by declaration. If pointer-interconvertibility had its own rules, it would have been possible to update standard-layout but not pointer-interconvertibility.

Decisive answered 5/2, 2023 at 16:30 Comment(0)
T
1

Both C and C++ were defined by tradition before standards were written. When the first standards were written, it was more important to avoid requiring that any implementations break programs that existed for them, than to forbid implementations that would processing various programs from changing in such a fashion as to break them.

Although it would be typical for a derived class object to store all of the parent data in a sequence of consecutive bytes, followed by the remainder of the data for the object, it could sometimes be useful for implementations to deviate from that. If a base class had a char field, a derived class had an int, and sub-derived class had another five char fields, an implementation that nestles one of the sub-derived class fields between the base-class field and first-derived class might be able to store data more compactly than one which places everything from each layer in a single sequence of consecutive bytes.

Note that the C++ Standard expressly waives jurisdiction over what C++ programs should be viewed as "conforming". As such, there was no perceived need to ensure that it didn't classify as UB any programs which most implementations should process usefully. If the authors had foreseen the way compilers would treat the Committee's decisions to waive jurisdiction over various constructs as an invitation to process them nonsensically, they probably would have defined behavior in many more corner cases than they actually did.

Tacye answered 5/2, 2023 at 23:44 Comment(0)

© 2022 - 2025 — McMap. All rights reserved.