Daily bit(e) C++. Числа не так просты
Daily bit(e) C++ 27. Зоопарк целочисленных типов и типов с плавающей запятой в C++.

Пожалуй, одной из самых подверженных ошибкам частей C++ являются целочисленные выражения и выражения с плавающей запятой. Поскольку эта часть языка унаследована от C, она в значительной степени опирается на довольно сложные неявные правила преобразования и иногда неинтуитивно взаимодействует с более статическими частями языка C++.
В этой статье будут рассмотрены правила и несколько неожиданных частных случаев, с которыми можно столкнуться при работе с целочисленными типами, с типами с плавающей запятой и с выражениями, использующими их.
Целочисленные типы
При работе с целочисленными типами существует два этапа потенциальных изменений типа. Во-первых, применяется продвижение типов ниже ранга int, и если результирующее выражение всё еще содержит различные целочисленные типы, применяется преобразование для получения общего типа.
В стандарте определены ранги целочисленных типов:
boolchar,signed char,unsigned charshort int,unsigned short intint,unsigned intlong int,unsigned long intlong long int,unsigned long long int
Продвижение (повышение ранга)
Как уже упоминалось, целочисленное продвижение (повышение ранга) применяется к типам с более низким рангом, чем int (например, bool, char, short). Такие операнды будут повышены до int (если int может представлять все значения типа, либо до unsigned int, если нет).
Повышение ранга обычно безвредно и незаметно, но может возникнуть при смешивании его со статическими функциями C++ (подробнее об этом позже).
uint16_t a = 1;
uint16_t b = 2;
// оба операнда повышены до int
auto v = a - b;
// v == -1, decltype(v) == int
Преобразования
Преобразования применяются после повышения ранга, когда два операнда по-прежнему имеют разные целочисленные типы.
Если типы имеют одинаковую знаковость, операнд более низкого ранга преобразуется в тип операнда более высокого ранга.
int a = -100;
long int b = 500;
auto v = a + b;
// v == 400, decltype(v) == long int
Смешанная знаковость
Сложную часть я оставил напоследок. При смешивании целочисленных типов с разной знаковостью возможны три результата.
Когда беззнаковый операнд имеет тот же или более высокий ранг, что и знаковый операнд, знаковый операнд преобразуется в тип беззнакового операнда.
int a = -100;
unsigned b = 0;
auto v = a + b;
// v ~ -100 + (UINT_MAX + 1), decltype(v) == unsigned
Когда тип знакового операнда может представлять все значения беззнакового операнда, беззнаковый операнд преобразуется в тип знакового операнда.
unsigned a = 100;
long int b = -200;
auto v = a + b;
// v = -100, decltype(v) == long int
В противном случае оба операнда преобразуются в беззнаковую версию типа знакового операнда.
long long a = -100;
unsigned long b = 0; // предполагая, что sizeof(long) == sizeof(long long)
auto v = a + b;
// v ~ -100 + (ULLONG_MAX + 1), decltype(v) == unsigned long long
Из-за этих правил смешивание целочисленных типов иногда может приводить к неинтуитивному поведению.
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
Безопасные целочисленные операции в C++20
Стандарт C++20 представил несколько инструментов, которые можно использовать для решения проблем, возникающих при работе с различными целочисленными типами.
Во-первых, стандарт ввел функцию std::ssize(), которая позволяет коду, работающему со знаковыми целыми числами, избегать смешивания знаковых и беззнаковых целых чисел при работе с контейнерами.
#include <vector>
#include <utility>
#include <iostream>
std::vector<int> data{1,2,3,4,5,6,7,8,9};
// std::ssize возвращает ptrdiff_t, избегая смешивания
// знакового и беззнакового целых чисел при сравнении
for (ptrdiff_t i = 0; i < std::ssize(data); i++) {
std::cout << data[i] << " ";
}
std::cout << "\n";
// напечатает: "1 2 3 4 5 6 7 8 9"
Во-вторых, был введен набор безопасных сравнений целочисленных типов для корректного сравнения значений различных целочисленных типов (без каких-либо изменений значений, вызванных преобразованиями).
#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
Наконец, небольшая утилита std::in_range вернет результат проверки, может ли проверяемый тип представлять заданное значение.
#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
Типы с плавающей запятой
Правила для типов с плавающей запятой намного проще. Результирующий тип выражения – это наивысший тип с плавающей запятой из двух аргументов, включая ситуации, когда один из аргументов является целочисленным типом (порядок типов: float, double, long double).
Важно отметить, что эта логика применяется для каждого оператора, поэтому порядок имеет значение. В следующем примере оба выражения в итоге получают тип long double; однако в первом выражении мы теряем точность, сначала преобразуя его в 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
Порядок операций – один из главных моментов, которые следует помнить при работе с числами с плавающей запятой (это общее правило, не специфичное для C++). Операции с числами с плавающей запятой не являются ассоциативными.
#include <vector>
#include <numeric>
#include <cmath>
float v = 1.0f;
float next = std::nextafter(v, 2.0f);
// next - это следующее большее число с плавающей запятой
float diff = (next-v)/2;
// diff меньше порога разрешения float
// важно: 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
// мы добавили diff 99 раз, но каждый раз значение не менялось
std::vector<float> data2(100, diff);
data2.back() = v; // data2 == { ..., v }
float r2 = std::accumulate(data2.begin(), data2.end(), 0.f);
// r2 != v
// мы добавили diff 99 раз, но сделали это до добавления к v
// сумма из 99 значений diff превышает порога разрешения
Любые операции с числами с плавающей запятой разной величины следует выполнять с осторожностью.
Взаимодействие с другими возможностями C++
Прежде чем закрыть эту статью, мне необходимо отметить две области, где более статичные возможности C++ могут вызывать потенциальные проблемы при взаимодействии с неявным поведением целочисленных типов и типов с плавающей запятой.
Ссылки
Хотя целочисленные типы неявно взаимопреобразуемы, ссылки на разные целочисленные типы не являются связанными типами и, следовательно, не будут связываться друг с другом. Это имеет два последствия.
Во-первых, попытка привязать lvalue-ссылку на несовпадающий целочисленный тип закончится неудачей. Во-вторых, если целевая ссылка может связываться с временными значениями (rvalue, const lvalue), значение пройдет через неявное преобразование, и ссылка будет связываться с результирующим временным значением.
void function(const int& v) {}
long a = 0;
long long b = 0;
// Даже если long и long long имеют одинаковый размер
static_assert(sizeof(a) == sizeof(b));
// Эти два типа не связаны в контексте ссылок
// Следующие два выражения не скомпилируются:
// long long& c = a;
// long& d = b;
// Хорошо, но опасно, неявное преобразование в int
// Временное значение int может быть привязано к const int&
function(a);
function(b);
Вывод типов
Наконец, нам нужно поговорить о выводе типов. Поскольку вывод типов – это статический процесс, он исключает возможность неявных преобразований. Однако это также создает потенциальные проблемы.
#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 — это литерал типа int. Внутри это означает, что
// тип аккумулятора (и результата) алгоритма будет
// int, несмотря на итерацию по контейнеру типа unsigned.
// v == 45, decltype(v) == int
Но в то же время, при использовании концептов, мы можем уменьшить неявные преобразования, принимая только определенный целочисленный тип.
#include <concepts>
template <typename T>
concept IsInt = std::same_as<int, T>;
void function(const IsInt auto&) {}
function(0); // OK
// function(0u); // компиляция завершится ошибкой, выведенный тип unsigned
