Привет, Хабр! Меня зовут Владимир, я работаю в 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
. Однако эти рассуждения верны соответственно для всех знаковых и беззнаковых типов.
Ссылки
Комментарии (24)
aamonster
31.08.2022 14:09+5Вы в примере в выводах "signed_width" использовать забыли.
Да и проще написатьint32_t signed_offset = -(int32_t)width;
BykoIanko Автор
31.08.2022 14:23Спасибо огромное! Поправил.
Да и проще написать
int32_t signed_offset = -(int32_t)width;
Ага. Как вариант.
Kamarr
31.08.2022 15:48+2В плюсах в таких случаях принято использовать static_cast
aamonster
31.08.2022 16:13+1Старые привычки трудно изживаются :-(. Да и раздражают телеги текста там, где хотел просто константу написать (а вот в более сложных случаях новые привычки могут выручить, да).
fk0
01.09.2022 01:35В плюсах принято использовать std::make_signed, ибо неизвестно кастить к какому именно (с какой разрядностью) типу. А захардкодить подсмотренное значение -- моветон.
crackedmind
02.09.2022 07:56уж лучше что-нибудь типа такого заюзать
template<class T> auto as_signed (T t){ return make_signed_t <T>(t); }
BykoIanko Автор
31.08.2022 17:34+1Спасибо за комментарии!
Хочу кое что уточнить. То что я писал выше относится к стандарту C++17. В C++20 приняли "two's complement". Это раздел 6.8.1, параграф 3. То есть код из начала статьи:
uint32_t width = 7; int32_t signed_offset = -width;
должен всегда работать одинаково и
signed_offset
в C++20 должно быть -7, как я понимаю.code_panik
02.09.2022 21:14+1В C++20 изменились правила преобразования целых https://timsong-cpp.github.io/cppwp/n4861/conv.integral#3. Думаю, в нашем случае следует понимать так: в результате преобразования получим знаковое целое, сравнимое с беззнаковым исходным по модулю 2^32. То есть такое знаковое определено единственным образом - это -7.
Кстати, значит, приведение больше не implementation defined, и мы можем не приводить руками.
BykoIanko Автор
03.09.2022 08:28Кстати, значит, приведение больше не implementation defined, и мы можем не приводить руками
Кажется, что так начиная с С++20. Я писал статью полагаясь на стандарт С++17, и не досмотрел, что-то поменяется в стандарте C++20 :)
С другой стороны, пока есть вероятность, что код могут скомпилировать, компилятором C++17, я бы не поленился и привел руками.
nerudo
31.08.2022 17:34+4Это все понятно, но было бы интересно увидеть живой пример, когда будет не -7.
code_panik
02.09.2022 11:01Польза выводов мне кажется немного не очевидной. Выражение
s = -u
для знаковогоs
и беззнаковогоu
состоит из двух: унарного минуса и присваивания.Мы разобрались, что результат выражения
-u
для любого беззнаковогоu
определён. По ссылке8.5.2.1.8 ... The negative of an unsigned quantity is computed by subtracting its value from 2^n, where n is the number of bitsin the promoted operand. The type of the result is the type of the promoted operand.
Беззнаковый тип по определению не может переполниться, потому что вычисления выполняются по модулю наибольшего представимого + 1 для данного типа. Поведение унарного минуса естественно соответствует этой логике - бежим по циферблату назад.
Вся проблема в попытке присвоить целому знакового типа значение, которое не представимо данным типом. Как указано в статье, поведение определяется реализацией.
7.8.3 If the destination type is signed, the value is unchanged if it can be represented in the destination type;otherwise, the value is implementation-defined.
То есть, как обычно, следует обращать внимание именно на совместимость данных разных типов.
BykoIanko Автор
02.09.2022 11:18Все так, но ведь в примере в выводах я предложил написать `int32_t signed_offset = -signed_width;`, где и
signed_offset
иsigned_width
знаковые типы. Как раз это нам и позволит уйти от unspecified behaviour, насколько я понимаю. Тогда 8.5.2.1.8 применен не будет. А 7.8.3 будет задействован в строчке выше: `int32_t signed_width = width;` в части value is unchanged.
@code_panik верно ли я понял вашу идею? Если нет, уточните п-та.Вот пример из выводов:
uint32_t width = 7; int32_t signed_width = width; int32_t signed_offset = -signed_width;
code_panik
02.09.2022 20:55Всё просто. Либо рабочий код буквально повторяет пример, тогда
signed_offset = -7.
Либоsigned_offset
нужно получить из произвольного сложного выражения без знака, тогда, по-видимому, единственное решение - приводить вручную все достаточно большие значения без знака к отрицательным со знаком.
screwer
Чтобы понять что происходит, достаточно знать о "two’s complement". Знаете как изменить знак у знакового целого ? Нет, не инверсией старшего бита. А инверсией всех бит и добавлением 1цы.
Нет там никакого переполнения. Просто одни и те же биты интерпретируются по-разному.
Очень рекомендую почитать Hacker Delight, чтобы не делать подобных "открытий".
BykoIanko Автор
@screwer Уточните п-та, какой параграф стандарта С++ гарантирует интерпретацию битов по разному в случае знаковых типов? Ссылку на драфт я привел в конце статьи.
Спасибо, обязательно почитаю!
screwer
Даже пытаться не буду, мне достаточно только здравого смысла и принципа "не платишь за то, что не используешь". Всегда удивляло, когда с пеной у рта начинают спорить о
вещах навроде количестве бит в байте, жонглировать параграфами, и утверждать что код должен заработать на некой теоретической, в будущем возможной архитектуре. А потом удивляются совершенно базовым вещам, как в этой статье, например
Нет ни одной [массовой] современной архитектуры, где целые числа работают по-другому.. И Risc/Cisc, и Be/Le, и vliw - везде дополнительный двоичный код. (Про массовость уточнил только чтобы отсеять совсем маргинальные и местечковые штучные "изобретения", кои и если вдруг найдутся).
vamireh
Давно пора понять, что код пишется на языке программирования. Именно на нём. А не под операционную систему. И не под процессор. Потому сначала необходимо читать документацию на язык программирования. И при программировании на C++ мы (внезапно!) пишем для абстрактной машины C++. А не для вашего мирка.
А меня всегда удивляли люди, которые думают, что мир должен быть устроен исключительно в соответствии с их "здравым смыслом", а в неопределённом поведении, которое такой человек наговнокодил, виноваты разработчики компилятора.
allcreater
На самом деле, реальная реализация всего этого безобразия на реальном железе совершенно неважна, потому что переполнение знакового целого - undefined behavior, отсутствие важного для правильной работы программы ограничения.
Тот факт, что процессор хранит знаковые числа с дополнением до двойки - Вас не спасет. Преобразования с переполнением выливаются в отбрасывание компилятором условий, вызовов функций и тд (выше уже выложили пример), причем самое веселое - что теряются гарантии на правильную работу программы даже до момента вызова проблемного участка кода.
Можно быть хоть трижды знатоком физических архитектур и алгоритмов работы всех действующих компиляторов(что уже близко к невозможному, кмк), все, что это даст - лишь позволит писать программы, которые работают здесь и сейчас. Для написания действительно переносимого и безопасного кода нужно строго следовать документации на язык программирования, то есть стандарт, и не допускать UB. Это гораздо проще, чем изобретать способы решать созданные на ровном месте проблемы.
BykoIanko Автор
Уверен, при написании кода, имеет смысл полагаться на стандарт и на то, что он разрешает, запрещает как-то регламентирует.
Я не мало писал на C++ под мобильные девайсы. Мне иногда доводилось сталкиваться с ошибками, к которым приводил unspecified behaviour. Как раз та самая ситуация, когда обычно поведение одно и тоже (хотя и unspecified), но на каком-нибудь девайсе, в редком случае и только в релизной сборке оно другое. Притом, как правило, этот класс ошибок очень не просто выявить и исправить, т.к. они редко воспроизводятся.
Videoman
Более того, начиная с С++20 битовое представление signed integers фиксировано стандартом как дополнение до двух (two's complement).
habr_do
Вот тут есть выдержка, какие бывают модели представления знаковых чисел:
https://habr.com/en/post/683714/
Прямая ссылка на документ:
https://github.com/burlachenkok/CPP_from_1998_to_2020/blob/main/Cpp-Technical-Note.md#integer-arithmetic-and-enumerations
BykoIanko Автор
Спасибо! Интересный документ.
WASD1
Вроде бы из живых архитектур остались только те, что представляют знаковые целые как "two's complement code".
И это даже было внесено в стандарт С++ (не пишу на С++ - поэтому не знаю в С++17 или С++ 20).
И да опять же это вроде implementation behavior - т.е. не UB и компилятор должен создать код с некоторой семантикой (разумной) и в дальнейшем этой семантики придерживаться (в отличии от UB когда он не обязан придерживаться никакой семантики).
fk0
Ну строго говоря, существуют (антикварные преимущественно) вычислительные машины, где знаковые числа представлены не в дополнительном коде, а например, в обратном коде. И результат там будет действительно чёрти какой.
Для стандарта языка C есть предложение исключить из стандарта возможность представления знаковых чисел в обратном коде (https://www.open-std.org/jtc1/sc22/wg14/www/docs/n2218.htm), но пока воз и ныне там, ибо существуют всё ещё современные компьютеры где это актуально.
PS: вдогонку, SEI CERT C Coding Standard имеет соответствующее предупреждение.