Daily bit(e) of C++ | С числами не так все просто
Daily bit(e) of C++ #27, Неразбериха с целочисленными типами и типами с плавающей запятой в C++.
Пожалуй, одной из наиболее подверженных ошибкам частей C++ являются выражения с целочисленными типами и типами с плавающей запятой. Поскольку эта часть языка унаследована от C, она сильно зависит от довольно сложных неявных правил преобразования и порой взаимодействует с более статическими частями языка C++ совсем неинтуитивным образом.
В этой статье мы рассмотрим правила и несколько неожиданных тупиковых ситуаций, с которыми можно столкнуться при работе с выражениями, содержащими целочисленные типы и типы с плавающей запятой.
Целочисленные типы
При работе с целочисленными типами, мы проходим через две фазы потенциального изменения типа. Для начала, продвижения применяются к типам более низкого ранга, чем int
, и если результирующее выражение все еще содержит разные целочисленные типы, то типы преобразуются до наименьшего общего типа.
Ранги целочисленных типов, определенные в стандарте:
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) применяются к типам более низкого ранга, чем 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
Преобразования
Преобразования (conversions) применяются после повышения, когда два операнда все еще имеют разные целочисленные типы.
Если типы имеют одинаковую знаковость, операнд с более низким рангом преобразуется в тип операнда с более высоким рангом.
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
Открыть этот пример в 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.
Безопасные целочисленные операции С++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.
CMake — удобный инструмент для автоматизации сборки приложений, популярен в мире C++ и используется в большом количестве проектов. Завтра состоится открытое занятие, на котором рассмотрим преимущества и базовые возможности CMake. В результате занятия научимся: писать простые настройки сборки с помощью CMake и собирать простые проекты с его использованием. Записаться можно здесь.