Daily bit(e) of C++ | Numbers are not easy
Daily bit(e) of C++ #27, The C++ integral and floating-types zoo.
Arguably one of the most error-prone parts of C++ is integral and floating-point expressions. As this part of the language is inherited from C, it relies heavily on fairly complex implicit conversion rules and sometimes interacts unintuitively with more static parts of C++ language.
This article will cover the rules and several surprising corner cases one can encounter when working with integral and floating-point types and expressions.
Integral types
There are two phases of potential type changes when working with integral types. First, promotions are applied to types of lower rank than int, and if the resulting expression still contains different integral types, a conversion is applied to arrive at a common type.
The ranks of integral types are defined in the standard:
bool
char
,signed char
,unsigned char
short int
,unsigned short int
int
,unsigned int
long int
,unsigned long int
long long int
,unsigned long long int
Promotions
As mentioned, integral promotions are applied to types of lower rank than int
(e.g. bool
, char
, short
). Such operands will be promoted to int
(if int
can represent all the values of the type, unsigned int
if not).
Promotions are generally harmless and invisible but can pop up when we mix them with static C++ features (more on that later).
Conversions
Conversions apply after promotions when the two operands are still of different integral types.
If the types are of the same signedness, the operand of the lower rank is converted to the type of the operand with the higher rank.
Mixed signedness
I left the complicated part for last. When we mix integral types of different signedness, there are three possible outcomes.
When the unsigned operand is of the same or higher rank than the signed operand, the signed operand is converted to the type of the unsigned operand.
When the type of the signed operand can represent all values of the unsigned operand, the unsigned operand is converted to the type of the signed operand.
Otherwise, both operands are converted to the unsigned version of the signed operand type.
Due to these rules, mixing integral types can sometimes lead to non-intuitive behaviour.
C++20 safe integral operations
The C++20 standard introduced several tools that can be used to mitigate the issues when working with different integral types.
Firstly, the standard introduced std::ssize()
, which allows code that relies on signed integers to avoid mixing signed and unsigned integers when working with containers.
Second, a set of safe integral comparisons was introduced to correctly compare values of different integral types (without any value changes caused by conversions).
Finally, a small utility std::in_range
will return whether the tested type can represent the supplied value.
Floating-point types
The rules for floating-point types are a lot simpler. The resulting type of an expression is the highest floating-point type of the two arguments, including situations when one of the arguments is an integral type (highest in order: float
, double
, long double
).
Importantly, this logic is applied per operator, so ordering matters. In this example, both expressions end up with the resulting type long double
; however, in the first expression, we lose precision by first converting to float
.
Ordering is one of the main things to remember when working with floating-point numbers (this is a general rule, not specific to C++). Operations with floating-point numbers are not associative.
Any operation with floating-point numbers of different magnitudes should be done with care.
Interactions with other C++ features
Before I close this article, I need to note two areas where the more static C++ features can cause potential issues when interacting with the implicit behaviour of integral and floating-point types.
References
While integral types are implicitly inter-convertible, references to different integral types are not related types and will, therefore, not bind to each other. This has two consequences.
First, trying to bind an lvalue reference to a non-matching integral type will not succeed. Second, if the destination reference can bind to temporaries (rvalue, const lvalue), the value will go through an implicit conversion, and the reference will bind to the resulting temporary.
Type deduction
Finally, we need to talk about type deduction. Because type deduction is a static process, it does remove the opportunity for implicit conversions. However, this also brings potential issues.
But at the same time, when mixed with concepts, we can mitigate implicit conversions while only accepting a specific integral type.