Reinterpreting a union to a different union
Asked Answered
S

4

36

I have a standard-layout union that has a whole bunch of types in it:

union Big {
    Hdr h;

    A a;
    B b;
    C c;
    D d;
    E e;
    F f;
};

Each of the types A thru F is standard-layout and has as its first member an object of type Hdr. The Hdr identifies what the active member of the union is, so this is variant-like. Now, I'm in a situation where I know for certain (because I checked) that the active member is either a B or a C. Effectively, I've reduced the space to:

union Little {
    Hdr h;

    B b;
    C c;
};

Now, is the following well-defined or undefined behavior?

void given_big(Big const& big) {
    switch(big.h.type) {
    case B::type: // fallthrough
    case C::type:
        given_b_or_c(reinterpret_cast<Little const&>(big));
        break;
    // ... other cases here ...
    }
}

void given_b_or_c(Little const& little) {
    if (little.h.type == B::type) {
        use_a_b(little.b);
    } else {
        use_a_c(little.c);
    }
}

The goal of Little is to effectively serve as documentation, that I've already checked that it's a B or C so in the future nobody adds code to check that it's an A or something.

Is the fact that I am reading the B subobject as a B enough to make this well-formed? Can the common initial sequence rule meaningfully be used here?

Showdown answered 14/2, 2018 at 14:55 Comment(15)
This: "If two union members are standard-layout types, it's well-defined to examine their common subsequence on any compiler." but I think we need more info to be sure; from: en.cppreference.com/w/cpp/language/unionDecury
My gut feel is that it is probably UB. @RichardCritten The quoted section means that it is legal to look at big.b.h.type (or big.h.type) even though the active member is big.c. I don't think it is legal to reinterpret cast to Little.Montevideo
A simpler question: Given union Big2 { Hdr h; A a; B b; C c; D d; E e; F f; }; would it be legal to reinterpet_cast from Big to Big2? My gut feel is "no" (but I can't prove it).Montevideo
@MartinBonner was thinking if types B and C had a common initial sub-sequence, then possibly...Decury
if based on the fact that all union members start at the same address this code is correct, if on formal documentation - The details of that allocation are implementation-defined - if Big have large size compare Little (due some extra members have lage size) - the layout of Big can be already another (say a and b can be placed not at begin of Big address ) - convert to Lttle via reinterpret_cast will be already incorrect. however i not believe that some member can start not at union begin.Wessling
What is the very point of this design? If the types A, B etc. all share a Hdr as their first member, then why not simply use polymorphism? Alternatively, why not use a layout where the types A, B etc. don't contain a Hdr, which is kept outside of the union: struct Big { Hdr h; union {A a; B b; /* etc */ }; };Odelia
and anyway you just use big.h.type which already UB by formal documentation :)Wessling
@Wessling Use of big.h.type is legal. The "common initial subsequence rule" that the OP referred to forces it.Montevideo
answer is based on question - are by given union address - we know address of some it member ? if address of all members of union is the same and equal to address of union itself - this cast pointer is correct. if admit that different members of union can have different addresses ( not start of union begin) - cast incorrect. anyway when you use big.h.type you already implicit assume and use that all members start at the same addressWessling
@MartinBonner - i absolute sure that use big.h.type is legal (if all other union members begin exactly from Hdr) but from formal reference i not view why this is legal, if this is "inactive member"Wessling
@Wessling see here, in particular the special properties of standard-layout types.Odelia
@Wessling : Or, if you want to get nearer the source, check 9.2 [class.mem] para 19 in N4296: "In a standard-layout union with an active member (9.5) of struct type T1, it is permitted to read a non-static data member m of another union member of struct type T2 provided m is part of the common initial sequence of T1 and T2."Montevideo
@MartinBonner - but from your note follow that all members of Big have the same address (same as h address) - only in this case we can read Hdr via different members with same result. and address of some member is equal to union address itself (The union is only as big as necessary to hold its largest data member). so address of h is same as Big and Little. so we can reinterpret cast pointerWessling
RbMm : You are doing this wrong. Where do you find the wording in the standard that if two objects have the same address then you can reinterpret cast the pointer? Don't just think about a naive implementation of the compiler, but a heavily optimizing one which assumes your code invokes no undefined behaviour. (The OP is not asking "will it probably work", but "must it work on any conforming compiler.)Montevideo
@MartinBonner - same address and same memory layout of members. this of course always will be work on all compilers. we reference the same binary data at the same address.Wessling
W
20

To be able to take a pointer to A, and reinterpret it as a pointer to B, they must be pointer-interconvertible.

Pointer-interconvertible is about objects, not types of objects.

In C++, there are objects at places. If you have a Big at a particular spot with at least one member existing, there is also a Hdr at that same spot due to pointer interconvertability.

However there is no Little object at that spot. If there is no Little object there, it cannot be pointer-interconvertible with a Little object that isn't there.

They appear to be layout-compatible, assuming they are flat data (plain old data, trivially copyable, etc).

This means you can copy their byte representation and it works. In fact, optimizers seem to understand that a memcpy to a stack local buffer, a placement new (with trivial constructor), then a memcpy back is actually a noop.

template<class T>
T* laundry_pod( void* data ) {
  static_assert( std::is_pod<Data>{}, "POD only" ); // could be relaxed a bit
  char buff[sizeof(T)];
  std::memcpy( buff, data, sizeof(T) );
  T* r = ::new( data ) T;
  std::memcpy( data, buff, sizeof(T) );
  return r;
}

the above function is a noop at runtime (in an optimized build), yet it converts T-layout-compatible data at data to an actual T.

So, if I am right and Big and Little are layout-compatible when Big is a subtype of the types in Little, you can do this:

Little* inplace_to_little( Big* big ) {
  return laundry_pod<Little>(big);
}
Big* inplace_to_big( Little* big ) {
  return laundry_pod<Big>(big);
}

or

void given_big(Big& big) { // cannot be const
  switch(big.h.type) {
  case B::type: // fallthrough
  case C::type:
    auto* little = inplace_to_little(&big); // replace Big object with Little inplace
    given_b_or_c(*little); 
    inplace_to_big(little); // revive Big object.  Old references are valid, barring const data or inheritance
    break;
  // ... other cases here ...
  }
}

if Big has non-flat data (like references or const data), the above breaks horribly.

Note that laundry_pod doesn't do any memory allocation; it uses placement new that constructs a T in the place where data points using the bytes at data. And while it looks like it is doing lots of stuff (copying memory around), it optimizes to a noop.


has a concept of "an object exists". The existence of an object has almost nothing to do with what bits or bytes are written in the physical or abstract machine. There is no instruction on your binary that corresponds to "now an object exists".

But the language has this concept.

Objects that don't exist cannot be interacted with. If you do so, the C++ standard does not define the behavior of your program.

This permits the optimizer to make assumptions about what your code does and what it doesn't do and which branches cannot be reached and which can be reached. It lets the compiler make no-aliasing assumptions; modifying data through a pointer or reference to A cannot change data reached through a pointer or reference to B unless somehow both A and B exist in the same spot.

The compiler can prove that Big and Little objects cannot both exist in the same spot. So no modification of any data through a pointer or reference to Little could modify anything existing in a variable of type Big. And vice versa.

Imagine if given_b_or_c modifies a field. Well the compiler could inline given_big and given_b_or_c and use_a_b, notice that no instance of Big is modified (just an instance of Little), and prove that fields of data from Big it cached prior to calling your code could not be modified.

This saves it a load instruction, and the optimizer is quite happy. But now you have code that reads:

Big b = whatever;
b.foo = 7;
((Little&)b).foo = 4;
if (b.foo!=4) exit(-1);

that is optimzied to

Big b = whatever;
b.foo = 7;
((Little&)b).foo = 4;
exit(-1);

because it can prove that b.foo must be 7 it was set once and never modified. The access through Little could not modify the Big due to aliasing rules.

Now do this:

Big b = whatever;
b.foo = 7;
(*laundry_pod<Little>(&b)).foo = 4;
Big& b2 = *laundry_pod<Big>(&b);
if (b2.foo!=4) exit(-1);

and it the assume that the big there was unchanged, because there is a memcpy and a ::new that could legally change the state of the data. No strict aliasing violation.

It can still follow the memcpy and eliminate it.

Live example of laundry_pod being optimized away. Note that if it wasn't optimized away, the code would have to have a conditional and a printf. But because it was, it was optimized into the empty program.

Wonacott answered 14/2, 2018 at 18:9 Comment(3)
In laundry_pod the expression new (data) T; just ends the lifetime of the object which was at data, which is the object refered by the parameter Big const& big of the function given_big. This is realy bad, because any caller of the function given_big will not expect that the object passed as const parameter is terminated inside this function. Any use of this object after the call would be UB.Rework
@oliv yes, that reference is invalidated. It can be reinvigorated by laundering the pod back, so long as you get the (full) type correct, and there is no const or reference data members. Notice I call inplace_to_big in my sample code; that revives the Big object there. Exception safety not guaranteed.Wonacott
What happens when you use inplace_to_big without using inplace_to_little first when sizeof(Big) > sizeof(Little)?Yehudit
M
4

I can find no wording in n4296 (draft C++14 standard) which would make this legal. What is more, I cannot even find any wording that given:

union Big2 {
    Hdr h;

    A a;
    B b;
    C c;
    D d;
    E e;
    F f;
};

we can reinterpret_cast a reference to Big into a reference to Big2 and then use the reference. (Note that Big and Big2 are layout-compatible.)

Montevideo answered 14/2, 2018 at 15:31 Comment(1)
In [basic.lval]/10.6 it is said that the a reference of type Big2 or Little can be used to access an object of type Big, see my answer.Rework
J
2

This is UB by omission. [expr.ref]/4.2:

If E2 is a non-static data member and the type of E1 is “cq1 vq1 X”, and the type of E2 is “cq2 vq2 T”, the expression [E1.E2] designates the named member of the object designated by the first expression.

During the evaluation of the given_b_or_c call in given_big, the object expression in little.h does not actually designate a Little object, and ergo there's no such member. Because the standard "omits any explicit definition of behavior" for this case, the behavior is undefined.

Jahdol answered 14/2, 2018 at 18:9 Comment(5)
Why this would not be UB delctype(std::declval<Little&>().h) x;? I do not believe that this statement imposes the need for the object to exist. Whatsoever the concept of existence is not defined but life time is.Rework
@Rework why is 1/0 UB but not decltype(1/0)?Jahdol
The problem is the access to the value or the fact it is evaluated, not the expression in itself.Rework
Your reading is too litteral. This paragraph of the standard just deffines the semantic. Not the evaluation (the meaning). decltype says to the compiler to just check the grammar, not the evaluability (the meaning). If the standard paragraph defining the semantic where interpreted litteraly as you do, to specify the evaluability, decltype would not be usefull, and probably that one can show that C++ program can not produce significant code.Rework
In the absence of anything in the Standard that would imply otherwise, an access to a member of a standard-layout struct or union would be equivalent to forming the address of that member and then reading or writing the object in question from that address. Other parts of the Standard may give a compiler permission to behave in some other fashion in cases where either the compiler writer judges that such behavior would benefit customers, or where the compiler writer values other things ahead of customers' interests, but the behavior would not be "undefined by omission" in any case.Kata
S
-1

I'm not sure, if this really applies here. In the reinterpret_cast - Notes section they talk about pointer-interconvertible objects.

And from [basic.compound]/4:

Two objects a and b are pointer-interconvertible if:

  • they are the same object, or
  • one is a union object and the other is a non-static data member of that object, or
  • one is a standard-layout class object and the other is the first non-static data member of that object, or, if the object has no non-static data members, the first base class subobject of that object, or
  • there exists an object c such that a and c are pointer-interconvertible, and c and b are pointer-interconvertible.

If two objects are pointer-interconvertible, then they have the same address, and it is possible to obtain a pointer to one from a pointer to the other via a reinterpret_­cast.

In this case, we have Hdr h; (c) as a non-static data member in both unions, which should allow to (because of the second and last bullet point)

Big* (a) -> Hdr* (c) -> Little* (b)
Shawanda answered 14/2, 2018 at 15:53 Comment(4)
Where is the Little object here? The actual object. Pointer-interconvertible is about objects, not types.Wonacott
I also think there's a confusion between classes and objects. Otherwise, using the same reasoning and that relation being reflexive (at least, the wording suggests it's reflexive), that would imply you could reinterpret_cast a Little& into a Big& (instead of the opposite) even if Big had stricter alignment requirements than Little.Scandal
@Yakk : I was going to ask what is the point of the last bullet? The answer is that it allows to pointer interconvert from the base of the first member of the base to the outer object.Montevideo
Regarding my earlier comment, I meant "symmetric" instead of "reflexive"Scandal

© 2022 - 2024 — McMap. All rights reserved.