C++ needs undefined behavior, but maybe less

The behavior of a C++ program is defined by the C++ standard. However, it does not describe the behavior to the full extent and leaves some of it up in the air: the implementation-defined, unspecified, and undefined behavior.

  • Implementation-defined behavior is behavior that can be decided by the implementation (i.e. the compiler or CPU). An example is the result of sizeof(int). The idea is that C++ should be available on a wide range of platforms and be able to accommodate their individual quirks to execute faster. A conforming implementation is required to document its actual behavior, so a programmer can safely rely on implementation-defined behavior. They just need to be aware that it makes their program non-portable.
  • Unspecified behavior is also up to the implementation, but it does not need to be documented. As such, while it is not an error to write code with unspecified behavior, we cannot rely on a specific behavior. An example is whether g() or h() is evaluated first in the call to f(g(), h()).
  • Undefined behavior is behavior where the standard imposes absolutely no requirements on the implementation. The difference to unspecified behavior is that it is a programmer error if a program execution manifests in undefined behavior. An example is the dereference of a null pointer.

It should be clear why C++ (and languages operating in a similar design space) need implementation-defined behavior: If everything were exactly specified by the standard, performance would suffer on platforms where that behavior needs to be emulated. Unspecified behavior is necessary for a similar reason, but the category could (and should?) be removed by making all unspecified behavior implementation-defined.

Undefined behavior is more controversial. Some argue that it should be removed, as it leads to dangerous compiler optimizations; some conflate it with implementation-defined behavior ("If I know the hardware, there is no undefined behavior"). While I agree with the first point, and it might make sense to remove some undefined behavior, removing all undefined behavior makes it impossible to have efficient code.

Undefined behavior is essential

Consider the following identity function:

int identity(int x) {
	return x;
}

According to the C++ standard, x is an object, which has an address. However, on the assembly level the value of x is passed in a CPU register, which does not have an address. Ignoring any optimizations, to satisfy the standard the compiler needs to generate assembly code that allocates memory for x and stores the register value in there. The return needs to load the value from memory, then put it in the result register:

identity(int):
        // function setup
        push    rbp
        mov     rbp, rsp
        // allocate memory for x and store it
        mov     dword ptr [rbp - 4], edi
        // load value of x from memory
        mov     eax, dword ptr [rbp - 4]
        // return
        pop     rbp
        ret
The unoptimized version of identity

This is a bit silly—nobody cares whether x has an address; there is no operator& anywhere. A sensible optimization is to eliminate the store/load from memory and use mov eax, edi directly. Then you also don't need to worry about the function setup:

identity(int):
        mov     eax, edi
        ret
The optimized version of identity

This optimization is allowed under the as-if rule of the standard. The unoptimized and optimized version of identity have the same observable behavior. Even though in the optimized version x no longer has an address, that cannot be observed by the programmer, and we are still conforming.

Note that it is irrelevant for the discussion whether or not the optimization is enabled by an optimizer flag, like in the case of clang, or whether the compiler generates such assembly directly. Strictly speaking, the optimized assembly is not a 1:1 representation of the function, but as nobody can tell, it doesn't matter.

However, without undefined behavior, we would be able to tell that the optimization takes place!

Let's say we have a main function that looks like this:

int main() {
	std::jthread thread([]{ *reinterpret_cast<int*>(0x12345678) = 42; }); // don't mind me
	return identity(0);
}
Calling identity while doing something else in the background

While we're calling identity, we're also executing a background thread that stores 42 at address 0x12345678. Without extra knowledge, this is undefined behavior in C++. So let's pretend that we're writing a version of C++ where everything undefined is implementation-defined instead.

If it just so happens that 0x12345678 is the address of the x function parameter in the unoptimized version, and it just so happens that the background thread stores 42 between the store and load in the implementation of identity, the optimization changes program behavior! The unoptimized program returns 42, while the optimized one returns 0.

Yes, the example is contrived (here's a somewhat more realistic example that doesn't rely on background threads and pulling numbers out of thin air) and nobody should write code like that, but that's not the point: The possible existence of code like that completely disables many crucial optimizations. The optimizer is not allowed to change the program semantics, so it would need whole-program analysis to determine whether such shenanigans are taking place.

If *reinterpret_cast<int*>(0x12345678) = 42 is implementation-defined, an implementation that wants optimizations may define it as "store 42 to whatever happens to be stored at that address, which might trigger access violations, and whose behavior may change with optimizations". However, that's just a convoluted way of spelling undefined behavior. It might make sense to not call undefined behavior "undefined behavior" for marketing reasons, but it's still essentially the same thing.

So if a language has unrestricted pointers and an implementation wants to do load/store elimination, the specification needs to introduce (something like) undefined behavior for it. For similar reasons, the list of essential undefined behavior also includes race conditions and potentially even more operations.

Undefined behavior goes too far

However, not all cases of undefined behavior are essential, some are problematic. Consider signed integer overflow: As it's undefined, the compiler can do some arithmetic simplifications of expressions, e.g. x * c1 / c2 can be optimized to x * c1_div_c2 (if c1 is divisible by c2).

On the flip side, it can also lead to sudden infinite loops:

void foo(int* ptr) {
    for (auto i = 0; i != 5; ++i) {
        std::printf("%d\n", i * 1000 * 1000 * 1000);
        *ptr++ = i * 1000 * 1000 * 1000;
    }
}
A finite loop that the optimizer can turn into an infinite one due to integer overflow

Here, GCC turns for (auto i = 0; i != 5; ++i) to for (auto i = 0; i != 5'000'000'000; i += 1'000'000'000), and realizes that the loop condition is always true, resulting in an infinite loop.

This is not ideal.

Making integer overflow implementation-defined instead of undefined can be an acceptable trade-off: While the arithmetic simplifications are nice, they are not essential because the programmer can do them manually if needed (unlike load/store elimination!). Unsigned integers don't have them anyway.

However, unintended integer overflow is an error that you want to catch. The advantage of having it undefined is that you can compile with -fsanitize=undefined and you'll have runtime checks for integer overflow. If it is implementation-defined, it is no longer considered an error by the C++ standard.

Similarly, reading uninitialized variables is undefined behavior. It could be changed to be well-defined by requiring zero initialization or specifying that you get an unspecified value, but this change might hide a program bug. We want to prevent dangerous compiler optimizations while keeping it as an error.

Erroneous behavior

This is the idea behind erroneous behavior, as proposed by P2795.

erroneous behavior: well-defined behavior (including implementation-defined and unspecified behavior) which the implementation is recommended to diagnose

P2795

Crucially, executing erroneous behavior is an error, but the result is implementation-defined not undefined. That way, we have the best of both worlds: the implementation can generate code which is most efficient for their platform, the user can use tools to catch the bugs, and the optimizer is not allowed to change the program behavior.

For the integer overflow example, the compiler can still issue a diagnostic warning about the integer overflow but is not allowed to transform the program into an infinite loop. Automatic variables still do not need to be initialized, but the compiler can choose to initialize it with a specific pattern to catch bugs.

Notably, erroneous behavior allows an implementation that inserts runtime checks in debug mode and generates the optimal assembly in release.

I'd argue that most cases of undefined behavior should be erroneous behavior instead. The paper list some examples:

  • uninitialized variable read
  • signed integer overflow, unrepresentable arithmetic conversions, invalid bit shifts
  • calling a pure-virtual function in an abstract base constructor

Better language design

Learning from C and C++'s history with non-standard behavior, a more nuanced approach seems useful.

  • A minimal set of undefined behavior for unchecked memory operations to enable essential optimizations.
  • A wide range of erroneous behavior for language operations with checkable preconditions (integer overflow, null pointer dereference, etc.). When in doubt, erroneous behavior should be preferred over undefined behavior.
  • Implementation-defined behavior for platform dependent functionality.

That way, we still have most of the performance of an aggressively optimizing C++ compiler while keeping guarantees about program behavior.

— by Jonathan Müller

Do you have feedback? Send us a message at devblog@think-cell.com !

Sign up for blog updates

Don't miss out on new posts! Sign up to receive a notification whenever we publish a new article.

Just submit your email address below. Be assured that we will not forward your email address to any third party.

Please refer to our privacy policy on how we protect your personal data.

Share