Перед арифметикой операнды меняют тип по правилам языка, а не по типу переменной, в которую пишется результат. Сначала малые типы продвигаются (integral promotion), затем оба операнда приводятся к общему типу (usual arithmetic conversions).

Зачем нужно

Процессор выполняет операцию над двумя значениями одного типа, и язык должен однозначно сказать, какой это тип. От ответа зависит и результат, и сам факт корректности программы: x + y для двух int по два миллиарда — это Неопределённое поведение, а те же операнды в long long дают честный ответ.

Главное следствие: тип выражения не зависит от того, куда вы его присваиваете.

int x = 2'000'000'000;
int y = 2'000'000'000;
long long result = x + y;   // всё ещё UB: сложение идёт в int

Шаг 1: продвижение малых типов

bool, char и short не участвуют в арифметике напрямую — их значения сначала продвигаются до int, если int представляет все значения исходного типа, иначе до unsigned int. false становится 0, true — 1.

short x = 1;
short y = 2;
 
std::cout << sizeof(x) << '\n';       // 2
std::cout << sizeof(x + y) << '\n';   // 4

Переменные не выросли — вырос тип выражения. sizeof не вычисляет сумму, а исследует её тип, поэтому им удобно наблюдать продвижение. Но размер не восстанавливает тип однозначно: разные типы бывают одинаковой ширины.

Объяснять продвижение фразой «процессор не умеет складывать короткие числа» не нужно. Это правило языка, и из разрядности процессора оно не выводится.

Шаг 2: общий тип

После продвижения операнды приводятся к общему типу.

Оба знаковые или оба беззнаковые — берётся тип большего ранга. Порядок рангов: int, long, long long.

0 + 0L      // long
0LL + 0     // long long
0L + 0LL    // long long
0U + 0      // unsigned int
0 + 0UL     // unsigned long

Знаковый с беззнаковым — правило «всегда побеждает беззнаковый» неверно. Работает такая цепочка:

  1. если ранг беззнакового не меньше ранга знакового — выбирается беззнаковый;
  2. иначе, если знаковый тип вмещает весь диапазон беззнакового — выбирается знаковый;
  3. иначе — беззнаковый вариант знакового типа большего ранга.

Поэтому тип 0UL + 0LL зависит от реализации: при 32-битном unsigned long и 64-битном long long это long long, а если оба 64-битные — уже unsigned long long, потому что знаковый тип не вмещает все значения беззнакового.

С плавающей точкой — порядок float, double, long double, второй операнд приводится к старшему присутствующему дробному типу.

1.0F + 2     // float
1.0 + 2      // double
1.0 + 2.0L   // long double

Не любая операция с дробным числом выполняется в double.

Где это видно в задачах

Самая частая ловушка — сравнение знакового с беззнаковым:

std::cout << (-1 < 1U) << '\n';   // false

int приводится к unsigned int, −1 превращается в 4 294 967 295, и оно не меньше единицы. Сравнение отработало правильно — неверным было ожидание, что оно сравнит исходные математические числа. Подробнее — Беззнаковые типы.

Второй сюжет — промежуточное переполнение. Чинится не типом переменной слева, а типом операнда:

long long ok   = static_cast<long long>(x) + y;
long long ok2  = 0LL + x + y;
long long bad  = x + y + 0LL;   // UB: x + y вычислено раньше

См. static_cast.

Тонкости

  • Схема из этой заметки описывает обычную арифметику. Не все операции ей следуют: у сдвигов свои правила определения типа результата — см. Побитовые операции.
  • Продвижение применяется и к беззнаковым малым типам: unsigned short на обычной системе продвигается до знакового int, потому что int вмещает все его значения. Арифметика по модулю там уже не работает.
  • sizeof(x + y) не вычисляет x + y — операнд sizeof не выполняется. Даже если внутри написать выражение с переполнением, UB не будет.

Частые ошибки

  • Ожидать, что широкий тип слева от = расширит вычисление справа.
  • Считать правило «беззнаковый побеждает знаковый» универсальным.
  • Складывать счётчики разной знаковости и удивляться сравнению.
  • Думать, что short + short даёт short.