Привет, Хабр! Меня зовут Владимир, я работаю в 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. Однако эти рассуждения верны соответственно для всех знаковых и беззнаковых типов. 

Ссылки

Стандарт C++17

Комментарии (24)


  1. screwer
    31.08.2022 14:06
    +5

    Чтобы понять, что здесь происходит, достаточно взглянуть на раздел 8.3.1.8 стандарта C++ 17

    Чтобы понять что происходит, достаточно знать о "two’s complement". Знаете как изменить знак у знакового целого ? Нет, не инверсией старшего бита. А инверсией всех бит и добавлением 1цы.

    Но это реакция на переполнение знакового целого числа.

    Нет там никакого переполнения. Просто одни и те же биты интерпретируются по-разному.

    Очень рекомендую почитать Hacker Delight, чтобы не делать подобных "открытий".


    1. BykoIanko Автор
      31.08.2022 14:20
      +3

      Нет там никакого переполнения. Просто одни и те же биты интерпретируются по-разному.

      @screwer Уточните п-та, какой параграф стандарта С++ гарантирует интерпретацию битов по разному в случае знаковых типов? Ссылку на драфт я привел в конце статьи.

      Очень рекомендую почитать Hacker Delight, чтобы не делать подобных "открытий".

      Спасибо, обязательно почитаю!


      1. screwer
        31.08.2022 14:52
        -6

        Уточните п-та, какой параграф стандарта С++ гарантирует интерпретацию битов по разному в случае знаковых типов?

        Даже пытаться не буду, мне достаточно только здравого смысла и принципа "не платишь за то, что не используешь". Всегда удивляло, когда с пеной у рта начинают спорить о
        вещах навроде количестве бит в байте, жонглировать параграфами, и утверждать что код должен заработать на некой теоретической, в будущем возможной архитектуре. А потом удивляются совершенно базовым вещам, как в этой статье, например

        Нет ни одной [массовой] современной архитектуры, где целые числа работают по-другому.. И Risc/Cisc, и Be/Le, и vliw - везде дополнительный двоичный код. (Про массовость уточнил только чтобы отсеять совсем маргинальные и местечковые штучные "изобретения", кои и если вдруг найдутся).


        1. vamireh
          01.09.2022 14:27
          +2

          Чтобы понять что происходит, достаточно знать о "two’s complement".

          Очень рекомендую почитать Hacker Delight

          Давно пора понять, что код пишется на языке программирования. Именно на нём. А не под операционную систему. И не под процессор. Потому сначала необходимо читать документацию на язык программирования. И при программировании на C++ мы (внезапно!) пишем для абстрактной машины C++. А не для вашего мирка.

          Даже пытаться не буду, мне достаточно только здравого смысла и принципа
          "не платишь за то, что не используешь". Всегда удивляло, когда с пеной у
          рта начинают спорить о
          вещах навроде количестве бит в байте,
          жонглировать параграфами, и утверждать что код должен заработать на
          некой теоретической, в будущем возможной архитектуре. А потом удивляются
          совершенно базовым вещам, как в этой статье, например

          А меня всегда удивляли люди, которые думают, что мир должен быть устроен исключительно в соответствии с их "здравым смыслом", а в неопределённом поведении, которое такой человек наговнокодил, виноваты разработчики компилятора.


        1. allcreater
          01.09.2022 15:32
          +1

          На самом деле, реальная реализация всего этого безобразия на реальном железе совершенно неважна, потому что переполнение знакового целого - undefined behavior, отсутствие важного для правильной работы программы ограничения.

          Тот факт, что процессор хранит знаковые числа с дополнением до двойки - Вас не спасет. Преобразования с переполнением выливаются в отбрасывание компилятором условий, вызовов функций и тд (выше уже выложили пример), причем самое веселое - что теряются гарантии на правильную работу программы даже до момента вызова проблемного участка кода.

          Можно быть хоть трижды знатоком физических архитектур и алгоритмов работы всех действующих компиляторов(что уже близко к невозможному, кмк), все, что это даст - лишь позволит писать программы, которые работают здесь и сейчас. Для написания действительно переносимого и безопасного кода нужно строго следовать документации на язык программирования, то есть стандарт, и не допускать UB. Это гораздо проще, чем изобретать способы решать созданные на ровном месте проблемы.


        1. BykoIanko Автор
          01.09.2022 16:33
          +1

          Уверен, при написании кода, имеет смысл полагаться на стандарт и на то, что он разрешает, запрещает как-то регламентирует.

          Я не мало писал на C++ под мобильные девайсы. Мне иногда доводилось сталкиваться с ошибками, к которым приводил unspecified behaviour. Как раз та самая ситуация, когда обычно поведение одно и тоже (хотя и unspecified), но на каком-нибудь девайсе, в редком случае и только в релизной сборке оно другое. Притом, как правило, этот класс ошибок очень не просто выявить и исправить, т.к. они редко воспроизводятся.


        1. Videoman
          02.09.2022 15:28

          Более того, начиная с С++20 битовое представление signed integers фиксировано стандартом как дополнение до двух (two's complement).

          However, all C++ compilers use two's complement representation, and as of C++20, it is the only representation allowed by the standard, with the guaranteed range from -2^(N-1) to +2^(N-1) — 1 (e.g. -128 to 127 for a signed 8-bit type).

          8-bit ones' complement and sign-and-magnitude representations for char have been disallowed since C++11 (via CWG 1759), because a UTF-8 code unit of value 0x80 used in a UTF-8 string literal must be storable in a char element object.


      1. habr_do
        31.08.2022 15:11
        +2

        Уточните п-та, какой параграф стандарта С++ гарантирует интерпретацию битов по разному в случае знаковых типов?

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

        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


        1. BykoIanko Автор
          31.08.2022 17:38

          Спасибо! Интересный документ.


        1. WASD1
          01.09.2022 14:03

          Вроде бы из живых архитектур остались только те, что представляют знаковые целые как "two's complement code".
          И это даже было внесено в стандарт С++ (не пишу на С++ - поэтому не знаю в С++17 или С++ 20).

          И да опять же это вроде implementation behavior - т.е. не UB и компилятор должен создать код с некоторой семантикой (разумной) и в дальнейшем этой семантики придерживаться (в отличии от UB когда он не обязан придерживаться никакой семантики).


    1. fk0
      01.09.2022 01:30
      +2

      Ну строго говоря, существуют (антикварные преимущественно) вычислительные машины, где знаковые числа представлены не в дополнительном коде, а например, в обратном коде. И результат там будет действительно чёрти какой.

      Для стандарта языка C есть предложение исключить из стандарта возможность представления знаковых чисел в обратном коде (https://www.open-std.org/jtc1/sc22/wg14/www/docs/n2218.htm), но пока воз и ныне там, ибо существуют всё ещё современные компьютеры где это актуально.

      PS: вдогонку,  SEI CERT C Coding Standard имеет соответствующее предупреждение.


  1. aamonster
    31.08.2022 14:09
    +5

    Вы в примере в выводах "signed_width" использовать забыли.
    Да и проще написать int32_t signed_offset = -(int32_t)width;


    1. BykoIanko Автор
      31.08.2022 14:23

      Спасибо огромное! Поправил.

      Да и проще написать int32_t signed_offset = -(int32_t)width;

      Ага. Как вариант.


    1. Kamarr
      31.08.2022 15:48
      +2

      В плюсах в таких случаях принято использовать static_cast


      1. aamonster
        31.08.2022 16:13
        +1

        Старые привычки трудно изживаются :-(. Да и раздражают телеги текста там, где хотел просто константу написать (а вот в более сложных случаях новые привычки могут выручить, да).


      1. fk0
        01.09.2022 01:35

        В плюсах принято использовать std::make_signed, ибо неизвестно кастить к какому именно (с какой разрядностью) типу. А захардкодить подсмотренное значение -- моветон.


    1. crackedmind
      02.09.2022 07:56

      уж лучше что-нибудь типа такого заюзать

      template<class T> auto as_signed (T t){ return make_signed_t <T>(t); }


  1. 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, как я понимаю.


    1. 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, и мы можем не приводить руками.


      1. BykoIanko Автор
        03.09.2022 08:28

        Кстати, значит, приведение больше не implementation defined, и мы можем не приводить руками

        Кажется, что так начиная с С++20. Я писал статью полагаясь на стандарт С++17, и не досмотрел, что-то поменяется в стандарте C++20 :)

        С другой стороны, пока есть вероятность, что код могут скомпилировать, компилятором C++17, я бы не поленился и привел руками.


  1. nerudo
    31.08.2022 17:34
    +4

    Это все понятно, но было бы интересно увидеть живой пример, когда будет не -7.


  1. 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.

    То есть, как обычно, следует обращать внимание именно на совместимость данных разных типов.


    1. 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;


      1. code_panik
        02.09.2022 20:55

        Всё просто. Либо рабочий код буквально повторяет пример, тогда signed_offset = -7. Либо signed_offset нужно получить из произвольного сложного выражения без знака, тогда, по-видимому, единственное решение - приводить вручную все достаточно большие значения без знака к отрицательным со знаком.