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.
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
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).
uint16_t a = 1;
uint16_t b = 2;// both operands promoted to int
auto v = a - b;
// v == -1, decltype(v) == int
Open this example in Compiler Explorer.
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.
int a = -100;
long int b = 500;auto v = a + b;
// v == 400, decltype(v) == long int
Open this example in Compiler Explorer.
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.
int a = -100;
unsigned b = 0;
auto v = a + b;
// v ~ -100 + (UINT_MAX + 1), decltype(v) == unsigned
Open this example in Compiler Explorer.
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.
unsigned a = 100;
long int b = -200;
auto v = a + b;
// v = -100, decltype(v) == long int
Open this example in Compiler Explorer.
Otherwise, both operands are converted to the unsigned version of the signed operand type.
long long a = -100;
unsigned long b = 0; // assuming sizeof(long) == sizeof(long long)
auto v = a + b;
// v ~ -100 + (ULLONG_MAX + 1), decltype(v) == unsigned long long
Open this example in Compiler Explorer.
Due to these rules, mixing integral types can sometimes lead to non-intuitive behaviour.
int x = -1;
unsigned y = 1;
long z = -1;auto t1 = x > y;
// x -> unsigned, t1 == true
auto t2 = z < y;
// y -> long, t2 == true
(Video) Want to Catch Numbers of Bass? - This Lure is Hard to Beat
Open this example in Compiler Explorer.
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.
#include <vector>
#include <utility>
#include <iostream>std::vector<int> data{1,2,3,4,5,6,7,8,9};
// std::ssize returns ptrdiff_t, avoiding mixing
// a signed and unsigned integer in the comparison
for (ptrdiff_t i = 0; i < std::ssize(data); i++) {
std::cout << data[i] << " ";
}
std::cout << "\n";
// prints: "1 2 3 4 5 6 7 8 9"
Open this example in Compiler Explorer.
Second, a set of safe integral comparisons was introduced to correctly compare values of different integral types (without any value changes caused by conversions).
#include <utility>int x = -1;
unsigned y = 1;
long z = -1;
auto t1 = x > y;
auto t2 = std::cmp_greater(x,y);
// t1 == true, t2 == false
auto t3 = z < y;
auto t4 = std::cmp_less(z,y);
// t3 == true, t4 == true
Open this example in Compiler Explorer.
Finally, a small utility std::in_range
will return whether the tested type can represent the supplied value.
#include <climits>
#include <utility>auto t1 = std::in_range<int>(UINT_MAX);
// t1 == false
auto t2 = std::in_range<int>(0);
// t2 == true
auto t3 = std::in_range<unsigned>(-1);
// t3 == false
Open this example in Compiler Explorer.
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
.
#include <cstdint>auto src = UINT64_MAX - UINT32_MAX;
auto m = (1.0f * src) * 1.0L;
auto n = 1.0f * (src * 1.0L);
// decltype(m) == decltype(n) == long double
std::cout << std::fixed << m << "\n"
<< n << "\n" << src << "\n";
// prints:
// 18446744073709551616.000000
// 18446744069414584320.000000
// 18446744069414584320
Open this example in Compiler Explorer.
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.
#include <vector>
#include <numeric>
#include <cmath>float v = 1.0f;
float next = std::nextafter(v, 2.0f);
// next is the next higher floating pointer number
float diff = (next-v)/2;
// diff is below the resolution of float
// importantly: v + diff == v
std::vector<float> data1(100, diff);
data1.front() = v; // data1 == { v, ... }
float r1 = std::accumulate(data1.begin(), data1.end(), 0.f);
// r1 == v
// we added diff 99 times, but each time, the value did not change
std::vector<float> data2(100, diff);
data2.back() = v; // data2 == { ..., v }
float r2 = std::accumulate(data2.begin(), data2.end(), 0.f);
// r2 != v
// we added diff 99 times, but we did that before adding to v
// the sum of 99 diffs is above the resolution threshold
Open this example in Compiler Explorer.
Any operation with floating-point numbers of different magnitudes should be done with care.
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.
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.
void function(const int& v) {}long a = 0;
long long b = 0;
// Even when long and long long have the same size
static_assert(sizeof(a) == sizeof(b));
// The two types are unrelated in the context of references
// The following two statements wouldn't compile:
// long long& c = a;
// long& d = b;
// OK, but dangerous, implict conversion to int
// int temporary can bind to const int&
function(a);
function(b);
Open this example in Compiler Explorer.
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.
#include <vector>
#include <numeric>std::vector<unsigned> data{1, 2, 3, 4, 5, 6, 7, 8, 9};
auto v = std::accumulate(data.begin(), data.end(), 0);
// 0 is a literal of type int. Internally this means that
// the accumulator (and result) type of the algorithm will be
// int, despite iterating over a container of type unsigned.
// v == 45, decltype(v) == int
Open this example in Compiler Explorer.
But at the same time, when mixed with concepts, we can mitigate implicit conversions while only accepting a specific integral type.
#include <concepts>template <typename T>
concept IsInt = std::same_as<int, T>;
void function(const IsInt auto&) {}
function(0); // OK
// function(0u); // will fail to compile, deduced type unsigned
(Video) Emails EXPOSE a New โScience-Based' Gun Control Scheme at CDC