Daily bit(e) C++. Числа не так просты

Добавлено 16 апреля 2026 в 07:34

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

Daily bit(e) C++

Пожалуй, одной из самых подверженных ошибкам частей C++ являются целочисленные выражения и выражения с плавающей запятой. Поскольку эта часть языка унаследована от C, она в значительной степени опирается на довольно сложные неявные правила преобразования и иногда неинтуитивно взаимодействует с более статическими частями языка C++.

В этой статье будут рассмотрены правила и несколько неожиданных частных случаев, с которыми можно столкнуться при работе с целочисленными типами, с типами с плавающей запятой и с выражениями, использующими их.

Целочисленные типы

При работе с целочисленными типами существует два этапа потенциальных изменений типа. Во-первых, применяется продвижение типов ниже ранга int, и если результирующее выражение всё еще содержит различные целочисленные типы, применяется преобразование для получения общего типа.

В стандарте определены ранги целочисленных типов:

  1. bool
  2. char, signed char, unsigned char
  3. short int, unsigned short int
  4. int, unsigned int
  5. long int, unsigned long int
  6. long 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

Пример на Compiler Explorer

Преобразования

Преобразования применяются после повышения ранга, когда два операнда по-прежнему имеют разные целочисленные типы.

Если типы имеют одинаковую знаковость, операнд более низкого ранга преобразуется в тип операнда более высокого ранга.

int a = -100;
long int b = 500;

auto v = a + b;
// v == 400, decltype(v) == long int

Пример на Compiler Explorer

Смешанная знаковость

Сложную часть я оставил напоследок. При смешивании целочисленных типов с разной знаковостью возможны три результата.

Когда беззнаковый операнд имеет тот же или более высокий ранг, что и знаковый операнд, знаковый операнд преобразуется в тип беззнакового операнда.

int a = -100;
unsigned b = 0;
auto v = a + b;
// v ~ -100 + (UINT_MAX + 1), decltype(v) == unsigned

Пример на Compiler Explorer

Когда тип знакового операнда может представлять все значения беззнакового операнда, беззнаковый операнд преобразуется в тип знакового операнда.

unsigned a = 100;
long int b = -200;
auto v = a + b;
// v = -100, decltype(v) == long int

Пример на Compiler Explorer

В противном случае оба операнда преобразуются в беззнаковую версию типа знакового операнда.

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

Пример на Compiler Explorer

Из-за этих правил смешивание целочисленных типов иногда может приводить к неинтуитивному поведению.

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

Пример на Compiler Explorer

Безопасные целочисленные операции в 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"

Пример на Compiler Explorer

Во-вторых, был введен набор безопасных сравнений целочисленных типов для корректного сравнения значений различных целочисленных типов (без каких-либо изменений значений, вызванных преобразованиями).

#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

Пример на Compiler Explorer

Наконец, небольшая утилита 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

Пример на Compiler Explorer

Типы с плавающей запятой

Правила для типов с плавающей запятой намного проще. Результирующий тип выражения – это наивысший тип с плавающей запятой из двух аргументов, включая ситуации, когда один из аргументов является целочисленным типом (порядок типов: 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

Пример на Compiler Explorer

Порядок операций – один из главных моментов, которые следует помнить при работе с числами с плавающей запятой (это общее правило, не специфичное для 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 превышает порога разрешения

Пример на Compiler Explorer

Любые операции с числами с плавающей запятой разной величины следует выполнять с осторожностью.

Взаимодействие с другими возможностями 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);

Пример на Compiler Explorer

Вывод типов

Наконец, нам нужно поговорить о выводе типов. Поскольку вывод типов – это статический процесс, он исключает возможность неявных преобразований. Однако это также создает потенциальные проблемы.

#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

Пример на Compiler Explorer

Но в то же время, при использовании концептов, мы можем уменьшить неявные преобразования, принимая только определенный целочисленный тип.

#include <concepts>

template <typename T>
concept IsInt = std::same_as<int, T>;

void function(const IsInt auto&) {}

function(0); // OK
// function(0u); // компиляция завершится ошибкой, выведенный тип unsigned

Пример на Compiler Explorer

Теги

C++ / CppDaily bit(e) C++Программирование