Must constant evaluator reject undefined behavior (union example) in C++?

Viewed 109

As far as I know, undefined behavior shall be a compile error during constant evaluation.

But if one takes an example of undefined behavior from C++20 standard class.union#6.3 with minor modification to activate constant evaluation:

struct X { const int a; int b; };
union Y { X x; int k; };
constexpr bool g() {
  Y y = { { 1, 2 } };   // OK, y.x is active union member ([class.mem])
  int n = y.x.a;
  y.k = 4;              // OK: ends lifetime of y.x, y.k is active member of union
  y.x.b = n;            // undefined behavior: y.x.b modified outside its lifetime,
                        // S(y.x.b) is empty because X's default constructor is deleted,
                        // so union member y.x's lifetime does not implicitly start
  return y.x.b > 0;
}

int main() {
  static_assert( g() );
}

then it is accepted by all compilers without any warnings. Demo: https://gcc.godbolt.org/z/W7o4n5KrG

Are all compilers wrong here, or there is no undefined behavior in the example, or no diagnostic is required?

2 Answers

In the original versions of the C and C++ Standards, the phrase "Undefined Behavior" was intended to mean nothing more nor less than "the Standard imposes no requirements". There was no perceived need for the Standard to ensure that every possible execution of every possible construct as either having unambiguously defined behavior or as being readily and unambiguously recognizable as invoking Undefined Behavior.

Both the C and C++ drafts explicitly state that in cases where the Standard imposes no requirements, implementations may behave "in a documented manner characteristic of the environment". If there were some execution environment where cache lines were twice as large as int, and where storing an int value into the first half of cache line and zeroing the rest would be faster than a read-modify-write sequence necessary to update just the first half of the cache line while leaving the remainder undisturbed, an implementation for that platform might process the act of writing to y.k in a manner which would disturb the storage associated with y.x.b. On the other hand, for most environments the "characteristic behavior" of writing y.k would be to modify an int-sized chunk of storage, while leaving the remainder of the storage associated with the union undisturbed.

Treating the act of writing to y.k and then reading y.x.b as UB was intended to allow implementations to process the write to y.k in the fastest fashion, without having to consider whether code might care about the contents of y.x.b. It was not intended to require that implementations make any effort to prevent code from accessing y.x.b after writing y.k. Although C++ mandates that integer constant expressions within a template expansion be viewed as substitution failures in cases where they invoke certain actions upon which the Standard would otherwise impose no requirements, requiring that all such actions be treated as substitution failures would create contradictions where the Standard could be interpreted both as requiring that a compiler make a particular template substitution, and as requiring that it refrain from doing so.

Huh, I guess it is the compiler being a bit lax - but there is technically nothing undefined about this at compile time, as there is no way for y.x.a to ever be accessed. Indeed, if you change your definition of g to return y.x.a; instead of y.x.b > 0 then it does spit out an error message ("expression did not evaluate to a constant" on my machine).

When a compiler evaluates a constexpr expression, instead of compiling the relevant parts of the code it is (universally, as far as I'm aware but don't quote me on that) delegated to an interpreter to evaluate and the constant result is then given to the compiler to be compiled along with the rest of the non-constexpr code. Interpreter's are generally far worse at catching what we would call "compile time errors", and so if nothing is actually undefined about the execution of the code, then this is probably good enough for the interpreter. For instance, there is some documentation on the clang interpreter which shows that the execution model is very different to how one would expect the compiled code to run.

Related