C++ float-to-int conversion can be undefined behavior

Converting a float to an int in C++ is undefined behavior when the truncated float does not fit into the destination integer. C++ makes it easy to do this accidentally. Much code gets this wrong.

void foo(float f) {
    int i0 = f;
    int i1 = int(f);
    int i2 = static_cast<int>(f);
}

This code does not generate any warnings, not even with -Wall and -Wextra. -Wconversion only warns about the implicit conversion. Yet each of the three conversions is undefined behavior for some inputs.

Cppreference's implicit conversion page, section Floating-integral conversions, states:

A prvalue of floating-point type can be converted to a prvalue of any integer type. The fractional part is truncated, that is, the fractional part is discarded. If the value cannot fit into the destination type, the behavior is undefined (even when the destination type is unsigned, modulo arithmetic does not apply).

I have seen this mistake many times in the wild. For example, in Microsoft's Guidelines Support Library, which supports the C++ Core Guidelines.

GSL provides a function for safe narrowing conversions, gsl::narrow:

gsl::narrow<T>(x) is a named cast that does a static_cast<T>(x) for narrowing conversions with no signedness promotions. If the argument x cannot be represented in the target type T, then the function throws.

I was curious how they addressed float-to-int conversion. It turns out, contrary to the docs, that they do not address it. gsl::narrow is undefined behavior for some inputs instead of throwing. I pointed this out in a comment and was dismissed with the following reasoning:

Regarding the use of UB internally: It's okay and if anyone is worried about it the use of UB is benign on the platforms we target (e.g., they don't involve hitting any hardware trap representations for these types).

The undefined behavior is real, but with current processors and compilers, the program usually works anyway. Compilers tend to pick an instruction like x86's CVTTSS2SI, which maps every unrepresentable input to the same placeholder integer INT_MIN. AArch64 has FCVTZS, which saturates and maps NaN to zero.

Your program not crashing can make the problem seem benign. It is not. Different results on different hardware are problematic. More importantly, any undefined behavior that executes is problematic. Ralf Jung explains this well in his post "What The Hardware Does" is not What Your Program Does. (I recommend his blog.) Your code could suddenly stop working when the compiler happens to apply a different transformation.

The correct fix is to bounds check before casting. I turned this into a proof of concept library based on Rust's saturating approach.

You can also detect the undefined behavior with Clang's and GCC's Undefined Behavior Sanitizer, see -fsanitize=float-cast-overflow. I recommend testing all C++ code with UBSan anyway.

The faulty GSL reasoning has made it from the issue comment into the code. The problem has not been fixed.