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 и собирать простые проекты с его использованием. Записаться можно здесь.
ссылка на оригинал статьи https://habr.com/ru/company/otus/blog/719288/
Добавить комментарий