Daily bit(e) of C++ #27, Неразбериха с целочисленными типами и типами с плавающей запятой в 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

Продвижения

Как уже было сказано выше, целочисленные продвижения (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 и собирать простые проекты с его использованием. Записаться можно здесь.

Комментарии (31)


  1. sergio_nsk
    00.00.0000 00:00

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

    Я бы добавил туда "у новичков". В оригинале это упоминание тоже отсутствует. Может быть, автор пишет серию для начинающих. Целая статья, вместо того чтобы просто объяснить их частую ошибку в выражении float f = a / b , где a и b - целые.


    1. zzzzzzerg
      00.00.0000 00:00

      Я думаю, что автор имеет в виду в том числе и UB - например, https://github.com/Nekrolm/ubbook/blob/master/numeric/overflow.md и https://github.com/Nekrolm/ubbook/blob/master/numeric/integer_promotion.md


  1. kovserg
    00.00.0000 00:00

    Интересно что мешает в C++ добавить целый тип произвольной длинны, как в питоне?

    print(2**300)
    

    2037035976334486086268445688409378161051468393665936250636140449354381299763336706183397376


    1. VBDUnit
      00.00.0000 00:00

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


      1. kovserg
        00.00.0000 00:00
        -2

        В процессорах есть сложение и вычитание с переносом. Что бы это использовать эти команды в C++ придётся использовать костыли или писать на ассемблере. Что мешает это встроить в компилятор как штатный тип данных?


        1. Kelbon
          00.00.0000 00:00
          +1

          вы очевидно не понимаете чего просите. Вы знаете что придется выделять память на число в этом случае?


          1. kovserg
            00.00.0000 00:00
            +1

            Можно подумать что строки память не выделяют. И потом что мешает помимо динамических иметь фиксированные размеры этой памяти (есть же шаблоны с параметрами).


        1. speshuric
          00.00.0000 00:00
          +1

          1. Не во всех процессорах сложение и вычитание с переносом есть.

          2. Как правильно указали - один из ключевых вопросов: как и где выделять память и как оно будет лежать в ней. В том числе - вопросы констант времени компиляции. И обосновать, почему именно такой layout должен поддерживаться компилятором. И это надо будет поддерживать всем компиляторам для всех платформ.

          3. Кроме сложения и умножения есть еще много операций. С ними свои проблемы. Для умножения/деления надо делать карацубы всякие. Для битовых сдвигов и and/or/xor либо хранить в двоичном дополнении, либо при выполнении операций туда-сюда преобразовывать. Для операций преобразования - прорабатывать дизайн.

          4. Даже для "произвольной" длины надо обговорить границы (например, 2^31-1 бит - а это не так много в современном мире) и корректно обработать в коде.

          5. Для литералов надо аккуратно встроить их в дизайн языка. Для типа в целом - решить вопросы обратной совместимости кода (шаблонов и т. п.)

          Короче - гора работы, которая на уровне языка, декларирующего прозрачность абстракций, никакого выигрыша по сравнению с отдельной библиотекой скорее всего не даст. А библиотеки уже и так есть.
          Python и 1С поддерживают такие числа "из коробки" - но это именно потому что они явно могут себе позволить быть небыстрыми (и тот же Python не поддерживает такие числа в производительных библиотеках). JS (EcmaScript) лишь относительно недавно добавил такие числа (и это явно не его киллер-фича). JVM, dotnet - поддерживают их как отдельные библиотеки в рамках стандартной библиотеки. Rust - отдельные библиотеки, С++ - отдельные библиотеки (и весьма крутые). При этом реально числа неограниченной длины нужны очень нечасто и ограниченно.

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


          1. kovserg
            00.00.0000 00:00

            1,2,3,4,5… можно подумать с тем зоопарком что есть эти операции не реализованы: 1U,1UL,1L,0xFULL

            Так язык планируют улучшать или как. Если штатные типы вводят в ступор новичков, им можно работать с типом который отлавливает переполнения или не переполняется в разумных пределах доступных ресурсов. Когда освоится сможет и задумается о ресурсах будет применять std::int_fast32_t или даже по отдельным битам складывать.


          1. kovserg
            00.00.0000 00:00

            Не во всех процессорах сложение и вычитание с переносом есть

            Приведите хотя бы 2 примера таких процессоров.


            1. yatanai
              00.00.0000 00:00
              +1

              Почти любой производительный RISC процессор. Там тебе придётся считать числа как signed и ловить, довольно дорогие, исключения от процессора, просто ради того чтобы посчитать расширенное число. Фичи с флагами как в х86 есть далеко не у каждой платформы.

              (Давно в другие платформы не тыкал, но допустим тот де MIPS очень долго не имел флагов переноса. RICSV делают те же люди что и мипс, потому не думаю что там он будет)

              Переводя дед выше говорит, что идеология языка в том, чтобы создать инструмент для сборки чего угодно и для максимально точного задания поведения. Ты в языке можешь даже перегружать методы класса для lvalue/rvalue ссылок, лол. Делать инструменты для типовой работы это задача не комитета языка а сообщества.


              1. kovserg
                00.00.0000 00:00

                RISC-V делают водолазы им можно.

                Тем не менее даже в таких убогих архитектурах существуют приемлемые варианты решения данной задачи. И есть костыли для их использования.


                1. Videoman
                  00.00.0000 00:00

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


                  1. yatanai
                    00.00.0000 00:00

                    Яб поспорил, всё же грамотные реализации с флагами будут лучше чем без них, другая проблема что это отдельная сущность которую надо делать и которая жрёт транзисторы. Плюс компиляторы давно уже не имеют нормальной поддержки тех хитровыдуманных инструкций которые есть в x86, а поддерживать приходится. (А на асме ща пишут разве что SIMD, как по мне)


                1. yatanai
                  00.00.0000 00:00

                  BigInt это всё ещё ненужная фича. Ибо она буквально по разному работает на разных платформах.

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


        1. Videoman
          00.00.0000 00:00

          Потому-что на практике это не сложно сделать в виде сторонней библиотеки, а это принцип С++ - не тащить в компилятор то, что не нужно.
          Есть подход big integer - когда число не ограничено по длине и фактически его придется хранить в динамической памяти (привет Питону). В этом случае придется расплачиваться быстродействием.
          Есть подход long integer - когда необходимо работать с числами большими, чем поддерживаются процессором, но при этом фиксированной длины. Такие числа можно хранить на стеке и работать с ними почти также быстро как со встроенными, с поправкой на длину. Например, данный подход используется в Simple long integer math library for C++. Такие числа почти взаимозаменяемые со стандартными, не считая некоторых нюансов в автоматических преобразованиях.


          1. kovserg
            00.00.0000 00:00

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


            1. Videoman
              00.00.0000 00:00

              Он и так это без костылей оптимизирует: adc, sbb всякие, simd где может использует. О каких костылях речь ? На 32-х битной архитектуре 64-х битные целочисленные операции (long int) уже через вызовы отдельных функции выполняются, а как иначе, это костыли?


              1. kovserg
                00.00.0000 00:00

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


                1. Videoman
                  00.00.0000 00:00

                  Это будет в два раза медленнее, чем это возможно. Зачем так делать? Я с трудом представляю архитектуру, где не было бы adc, но даже, если это так, то перенос легко вычислить для полной суммы:

                  value1 += value2;
                  bool carry = value1 < value2;


                  1. kovserg
                    00.00.0000 00:00

                    Нет это не верная постановка задачи.
                    Задача такая:

                    // adc value1,value2
                    value1 = value1 + value2 + carry;
                    carry  = ...;
                    


                    1. Videoman
                      00.00.0000 00:00

                      В любом случае, если в архитектуре нет команды adc, то будут накладные расходы, но не большие.

                      constexpr bool addc(uint32_t& value1, uint32_t value2, bool carry) noexcept
                      {
                          value1 += value2;
                          bool carry_new = value1 < value2;
                          value1 += carry;
                          carry_new = carry_new || (value1 < uint32_t(carry));
                          return carry_new;
                      }

                      Вот например выхлоп для RISC-V:

                       add     a0, a0, a1
                       sltu    a1, a0, a1
                       add     a2, a2, a0
                       sltu    a0, a2, a0
                       or      a0, a0, a1
                       add     a0, a0, a2

                      Выглядит очень не плохо, на мой взгляд.


                      1. kovserg
                        00.00.0000 00:00

                        Конечно же 6 команд выглядит лучше чем одна.


                      1. Videoman
                        00.00.0000 00:00

                        Современные процессоры устроены совсем не так, как в 80-х. Здесь, мне кажется, нужны замеры - насколько это медленнее. Нельзя же производительность в этом случае линейно, по количеству команд, мерить.


                      1. kovserg
                        00.00.0000 00:00

                        Как бы они не были устроены тут дополнительные вычисления используются. Вместо 1 сложения будет 3 сложения 2 вычитания и один or. Как бы они ни были устроены 1 операция против 6 операций потребляют немного разное количество ресурсов.


  1. fk0
    00.00.0000 00:00
    +1

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

    Мой любимый пример:

    #define BIT_MASK(bit) (~(1 << (bit)))

    В результате сотрясение мозга: https://coliru.stacked-crooked.com/a/ecdcb441287496c4


    1. OverThink
      00.00.0000 00:00

      интересно, а объяснение есть, почему так?


      1. me21
        00.00.0000 00:00

        Результат макроса имеет тип int. А дальше

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

        Он преобразуется в uint64_t. При этом результат макроса положительный, знаковый бит у него сброшен. Поэтому старшие биты оказываются нулевыми.

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


  1. NeoCode
    00.00.0000 00:00
    +1

    Вообще, обратная совмесимость - зло, из-за нее множество проблем, в том числе и с числами.
    Прежде всего, я бы отметил то, что в C/С++ (как и во многих языках) числовые литералы по умолчанию типизированы, что ИМХО неверно. Для типизации есть всякие постфиксы типа "u", "s" или "l", а вот просто числовые литералы (равно как и просто строковые литералы) должны быть нетипизированными, абстрактными, и свободно приводиться алгоритмами вывода типа к любым типам. Чем должен быть например литерал 0? Это может быть и байт, и целое, и число с плавающей точкой, любой длины и любой точности - в зависимости от контекста; такой подход ближе всего к математике.

    Отсутствует поддержка длинной арифметики "из коробки", и соответственно, нет числовых литералов любой длины (всякие GMP для представления длинных числовых литералов используют строки).

    Отсутствует поддержка fixed point, что весьма странно для низкоуровневых языков, применяемых в том числе во множестве микроконтроллеров. Хотя вся поддержка реализуется в чистом виде на уровне компилятора, внутри это обычные целые числа.


  1. SH33011
    00.00.0000 00:00

    bool разве не логический тип?


    1. me21
      00.00.0000 00:00

      Не-а :) Однобитный целочисленный в контексте статьи. На английском используются два термина: integer type и integral type. Так вот bool - integral, но не integer.