C++ даёт программисту много свободы, а компилятору ещё больше. Почти на каждом собеседовании по C++ проверяют, понимаете ли вы, где заканчиваются гарантии языка. Начнём с типов.
Целочисленные типы
Стандарт гарантирует не размеры, а минимальные диапазоны: char ровно 1 байт, short и int не меньше 16 бит, long не меньше 32, long long не меньше 64. На практике:
| Модель | int | long | указатель |
|---|---|---|---|
| Linux, macOS x86-64 (LP64) | 32 | 64 | 64 |
| Windows x64 (LLP64) | 32 | 32 | 64 |
Отсюда правило: если важен размер, берите std::int32_t, std::int64_t, std::uint8_t из <cstdint>, а для размеров и индексов std::size_t. Знаковость char зависит от платформы: на x86 он обычно знаковый, на ARM Linux беззнаковый.
С C++20 знаковые целые обязаны храниться в дополнительном коде (two's complement). Но переполнение знакового остаётся неопределённым поведением. Беззнаковые же считаются по модулю 2ⁿ: 0u - 1 равно UINT_MAX, это определено.
Продвижения и преобразования
Перед арифметикой типы меньше int (char, short, bool) продвигаются до int. Затем операнды приводятся к общему типу по «обычным арифметическим преобразованиям». Главная ловушка: при смешении int и unsigned int знаковый операнд превращается в беззнаковый.
std::cout << (-1 < 1u); // 0: -1 стал 4294967295
std::vector<int> v;
for (int i = 0; i < v.size() - 1; ++i) {} // v.size() - 1 == SIZE_MAX при пустом векторе
Деление целых обрезает к нулю: -7 / 2 == -3, а -7 % 2 == -1 (знак остатка как у делимого, гарантировано с C++11).
Три вида «как повезёт»
- Неопределённое поведение (UB): стандарт ничего не требует. Программа может работать, упасть, выдать мусор или вести себя по-разному при
-O0и-O2. - Implementation-defined: поведение выбирает реализация и обязана его документировать:
sizeof(int), знаковостьchar. - Unspecified: допустимо несколько вариантов, документировать не надо: порядок вычисления аргументов функции
f(a(), b()).
Типичные источники UB:
- переполнение знакового:
INT_MAX + 1; - выход за границы массива, разыменование
nullptrили висячего указателя; - чтение неинициализированной локальной переменной;
- сдвиг на число бит
≥ширины типа или на отрицательное:1 << 32для 32-битногоint; - нарушение strict aliasing: чтение
floatчерезint*; - гонка данных: два потока без синхронизации, хотя бы один пишет;
- выход из не-
voidфункции безreturn(кромеmain).
Почему компилятор «ломает» код с UB
Оптимизатор вправе считать, что UB никогда не происходит, и строить выводы на этом.
bool will_overflow(int x) { return x + 1 < x; } // часто компилируется в return false
int get(int* p) { int v = *p; if (!p) return 0; return v; } // проверку p убирают: p уже разыменован
В первом случае переполнение знакового UB, значит x + 1 < x «никогда» не истинно. Во втором после *p компилятор знает, что p не нулевой. Поэтому «у меня работает» ничего не доказывает.
Как жить
- Компилируйте с
-Wall -Wextra -Wconversionи прогоняйте тесты с-fsanitize=address,undefined. - Проверяйте переполнение до операции или через
__builtin_add_overflowи родственные встроенные функции GCC и Clang, либо считайте в более широком типе. - Перетолковывайте байты через
std::memcpyилиstd::bit_cast(C++20), а не через приведение указателей. - Не смешивайте знаковые и беззнаковые в сравнениях;
std::ssize(v)(C++20) возвращает знаковый размер.
Частые ошибки
- Думать, что знаковое переполнение «просто завернётся», раз C++20 закрепил дополнительный код.
- Писать
for (size_t i = n - 1; i >= 0; --i): условие всегда истинно, беззнаковое не бывает меньше нуля. - Считать UB «чем-то на редких платформах»: современные GCC и Clang активно используют его при оптимизации.
Что спросят на собесе
- Чем UB отличается от implementation-defined и unspecified, примеры каждого.
- Почему
-1 < 1uложно и как это ловить. - Что изменилось в C++20 для знаковых целых и что осталось UB.
- Как компилятор использует UB для оптимизаций и как искать UB (санитайзеры).
- Как безопасно перетолковать байты одного типа как другой.