C++ float-to-int conversion can be undefined behavior
The Hidden Danger: Undefined Behavior in C++ Float-to-Int Conversions
In C++, the act of converting a floating-point number to an integer is not always safe. Specifically, it triggers undefined behavior (UB) if the value of the truncated float exceeds the capacity of the destination integer type.
The Illusion of Safety
Consider the following snippet. Despite the different syntaxes, all three lines perform the same operation:
void foo(float f) {
int i0 = f; // Implicit conversion
int i1 = int(f); // C-style cast
int i2 = static_cast<int>(f); // Static cast
}
Surprisingly, this code is perfectly fine dangerous. Even when using strict compiler flags like -Wall and -Wextra, the compiler remains silent, offering no warnings that these operations could lead to UB.
What the Standard Says
According to the Floating-integral conversions section of the Cppreference implicit conversion guide, the rules are as follows:
- A floating-point
prvaluecan be converted to any integer type. - The conversion process involves truncation: the fractional part is simply discarded. Mathematically, this is similar to:
- The Critical Warning: If the resulting value cannot be represented by the target type, the behavior is undefined. This holds true even for unsigned integers; the usual modulo arithmetic does not apply here.
| Conversion Type | Action | Result if Out-of-Range |
|---|---|---|
float int | Truncate decimals | Undefined Behavior |
double uint32_t | Truncate decimals | Undefined Behavior |
Case Study: The GSL gsl::narrow Failure
The Guidelines Support Library (GSL), which implements the C++ Core Guidelines, provides a utility called gsl::narrow. This function is intended to be a "safe" narrowing cast that throws an exception if the value cannot fit in the target type T.
Internally, it performs a static_cast<T>(x). However, because it uses static_cast before checking the bounds, it triggers the UB described above. When this was brought to the attention of the maintainers, the response was dismissive:
"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)."
Hardware vs. Language Semantics
The argument that UB is "benign" stems from how specific hardware handles these errors. For instance, on x86 architectures, the CVTTSS2SI instruction is often used.
While the hardware might simply return INT_MIN (a placeholder), the C++ language views this as UB. As Ralf Jung explains in his insightful post, "What The Hardware Does" is not What Your Program Does, relying on hardware quirks is a mistake. Compilers optimize code based on the assumption that UB never occurs. If a compiler decides to optimize away a check because it assumes the value must fit, your program could crash or behave erratically after a simple compiler update.
How to Protect Your Code
To avoid these pitfalls, you can adopt a "saturating" approach (similar to how Rust handles these conversions) or use specialized tooling.
Prevention Checklist:
- Avoid direct
static_castfor potentially large floats. - Implement saturating casts (clamping the value to
minormaxof the type). - Use runtime sanitizers during testing.
To detect these issues automatically, compile your code with the following flag in Clang or GCC:
-fsanitize=float-cast-overflow
Ultimately, the reasoning used by the GSL—that UB is acceptable if it "usually works"—has unfortunately leaked from the issue tracker directly into the production codebase, leaving a latent bug for users to find.