Pull to refresh
905.37
OTUS
Цифровые навыки от ведущих экспертов

Daily bit(e) of C++ | С числами не так все просто

Reading time6 min
Views6.5K
Original author: Šimon Tóth

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

Tags:
Hubs:
Total votes 21: ↑19 and ↓2+17
Comments31

Articles

Information

Website
otus.ru
Registered
Founded
Employees
101–200 employees
Location
Россия
Representative
OTUS