C++. Унарный минус и беззнаковый тип

Kate

Administrator
Команда форума
Меня зовут Владимир, я работаю в VK Карты. Хочу рассказать про случай, который недавно произошёл у нас в подразделении. Он кажется достаточно типичным и может быть интересен другим программистам.

Нам, программистам на C++, не привыкать, что даже самый безобидный код может таить в себе сюрпризы. Рассмотрим пример:

uint32_t width = 7;
int32_t signed_offset = -width;
Он полон сюрпризов! Каких? Короткий ответ: значение signed_offset не определено стандартом и зависит от реализации. Но это далеко не все неожиданности в этом коде. Статья как раз о них.

Результат, возвращаемый унарным минусом​

Давайте сначала разберемся с тем, что возвращает -width. Это может прозвучать неожиданно, но тип, возвращаемый -width, это uint32_t. И это не опечатка. То есть если унарный минус применить к неотрицательному числу, то в результате получим опять же неотрицательное число. Давайте разберёмся, как такое могло получиться и чему будет равен результат -width.

Если выполнить такой код:

uint32_t width = 7;
auto offset = -width;
std::cout << std::boolalpha << "Is type of -width uint32_t? "
<< std::is_same_v<decltype(offset), uint32_t> << std::endl;
std::cout << "offset = " << offset << '\n';
То результат сообщит нам, что у переменной offset тип uint32_t, а значение — 4 294 967 289.

Чтобы понять, что здесь происходит, достаточно взглянуть на раздел 8.3.1.8 стандарта C++ 17. Из него следует, что тип, возвращаемый -width, будет такой же, как и тип width. А значение, которое возвратит -width, это 232 минус значение width. То есть 4 294 967 296 — 7 = 4 294 967 289. 232 потому, что 32 — это количество бит, необходимое для размещения переменной типа uint32_t.

Уточню относительно записи auto offset = -width;. В некоторых случаях у auto есть особые правила вывода типа, и чтобы вывелся в точности тип значения, возвращаемого выражением, лучше использовать decltype(auto). Но в этом случае auto и decltype(auto) дадут аналогичные результаты, так что я решил не усложнять.

Итак, пока что мы получили определённое стандартом значение, которое возвращает унарный минус (-width). Это 4 294 967 289. Однако в начале я писал, что значение переменной signed_offset типа int32_t будет не определено. Проблема тут в конвертации беззнакового типа uint32_t в знаковый int32_t.

Конвертация беззнакового типа в знаковый​

Рассмотрим пример:

uint32_t width = 7;
int32_t signed_positive_offset = width;
За конвертацию целых типов отвечает раздел стандарта 7.8, в нашем случае, это 7.8.3. Этот пункт, говорит, что если беззнаковый тип поместится в соответствующем знаковом, то всё будет работать предсказуемо. Иными словами, переменной signed_positive_offset будет присвоено значение 7. А вот если значение беззнакового типа не помещается в знаковом, то присвоенное знаковому типу значение зависит от реализации.

Обратимся к примеру в самом начале статьи:

uint32_t width = 7;
int32_t signed_offset = -width;
Как мы выяснили выше, -width вернёт значение 4 294 967 289 типа uint32_t. А оно не поместится в int32_t. Соответственно, значение signed_offset будет не определено. Отмечу, что согласно стандарту это допустимая запись, не приводящая к неопределенному поведению. То есть код допустим, но значение в signed_offset после его исполнения может быть любым.

Если скомпилировать и запустить этот пример, то с большой долей вероятности в signed_offset окажется -7. Так реагирует Clang 13.1.6 на MacOS. Но это реакция на переполнение знакового целого числа. А с точки зрения стандарта в signed_offset может быть любое значение.

Переполнение знаковых и беззнаковых типов​

Отмечу, что стандарт по-разному относится к переполнению знаковых и беззнаковых типов. При присвоении слишком большого значения знаковому типу мы получаем поведение, зависящее от реализации. Этот случай мы подробно рассмотрели выше. При присвоении слишком большого значения беззнаковому типу мы получим вполне определённое и гарантированное стандартом поведение, согласно разделу 7.8.2. Правило такое: если мы хотим присвоить число big_number переменной unsigned_offset беззнакового типа some_unsigned_type, то результатом этой операции будет число big_number по модулю 2n, где n — количество бит, необходимое для хранения типа some_unsigned_type. Звучит запутано, но на самом деле тут всё предельно просто. Достаточно взглянуть на пример:

uint64_t big_unsigned_offset = 4294967296LLU + 4294967296LLU + 7LLU; // 2^32 = 4294967296
uint32_t unsigned_offset = big_unsigned_offset;
unsigned_offset будет равно 7, потому что размер uint32_t — 32 бита. 232 равно 4 294 967 296, а 4 294 967 296 + 4 294 967 296 по модулю 4 294 967 296 будет равно 0. Выходит результат 7.

Выводы и рекомендации​

Рекомендую обращать внимание на неявные переполнения беззнаковых типов. В частности, стоит избегать применения унарного минуса к беззнаковым типам. Если же нужно, чтобы код выше был выполнен, то можно написать, например, так:

uint32_t width = 7;
int32_t signed_width = width;
int32_t signed_offset = -signed_width;

Во всех рассуждениях я преимущественно писал про uint32_t и int32_t. Однако эти рассуждения верны соответственно для всех знаковых и беззнаковых типов.

 
Сверху