c несогласованные результаты вычитания без знака со знаком не удается выполнить только для одной перестановки

#c #unsigned #underflow

Вопрос:

Я понимаю, что существует правило, по которому числа с шириной меньше, чем int можно перевести в более широкий тип для операции сложения. Но я не могу полностью объяснить, как только одна из следующих перестановок print_unsafe_minus потерпит неудачу. Как получается, что только <unsigned, long> пример терпит неудачу, и каково преимущество для программистов в отношении лучших практик?

 #include <fmt/core.h>

template<typename M, typename N>
void print_unsafe_minus() {
        M a = 3, b = 4;
        N c =  a - b;
        fmt::print("{}n", c);
}
int main() {
    // storing result of unsigned 3 minus 4 to a signed type

    print_unsafe_minus<uint8_t, int8_t>(); // -1
    print_unsafe_minus<uint16_t, int8_t>(); // -1
    print_unsafe_minus<uint32_t, int8_t>(); // -1
    print_unsafe_minus<uint64_t, int8_t>(); // -1

    print_unsafe_minus<uint8_t, int16_t>(); // -1
    print_unsafe_minus<uint16_t, int16_t>(); // -1
    print_unsafe_minus<uint32_t, int16_t>(); // -1
    print_unsafe_minus<uint64_t, int16_t>(); // -1

    print_unsafe_minus<uint8_t, int32_t>(); // -1
    print_unsafe_minus<uint16_t, int32_t>(); // -1
    print_unsafe_minus<uint32_t, int32_t>(); // -1
    print_unsafe_minus<uint64_t, int32_t>(); // -1

    print_unsafe_minus<uint8_t, int64_t>(); // -1
    print_unsafe_minus<uint16_t, int64_t>(); // -1
    print_unsafe_minus<uint32_t, int64_t>(); // 4294967295
    print_unsafe_minus<uint64_t, int64_t>(); // -1
}
 

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

 print_unsafe_minus<uint32_t, __int128>(); // 4294967295
print_unsafe_minus<uint64_t, __int128>(); // 18446744073709551615
 

Комментарии:

1. Вы знаете, что именно говорится в правилах акции? Все эти примеры следует объяснить, точно следуя им. Когда вы пытаетесь интерпретировать правила в этих случаях, можете ли вы объяснить свою интерпретацию и чем именно она отличается от того, что происходит?

2. @NateEldredge, если uint8_t и uint16_t получат повышение за добавление к той же ширине , uint32_t что и, то не должны ли они также потерпеть неудачу, как это происходит для int64_t ?

3. что нужно сделать программистам в отношении лучших практик? не смешивайте подписанное и неподписанное. На самом деле unsigned типы нужны только для побитовых операций.

4. only the <unsigned, long> из вашего кода только uint32_t, int64_t сбой, а не unsigned, long

5. @NathanOliver Ну, есть типы контейнеров STL с их раздражающими size_t вещами!

Ответ №1:

Прежде чем мы начнем, давайте предположим, что OP использует реализацию с 32-разрядным int типом. То int32_t есть эквивалентно int .

Пусть X-ширина M, а Y — ширина N.

Давайте разделим ваши тестовые случаи на три категории:

Первая категория: X

Здесь применяется целочисленное продвижение, которое всегда выполняется перед вызовом арифметического оператора.

uint8_t и uint16_t все их диапазоны значений представимы int , поэтому они повышаются int до того, как выполнять вычитание. Затем вы получаете знаковое значение -1 from doing 3 - 4 , которое затем используется для инициализации целочисленного типа со знаком, который может храниться независимо от его ширины -1 . Таким образом, вы получаете -1 в качестве вывода.

Вторая категория: (X >= 32) и (X >>= Y)

Никакого продвижения по службе не происходит до выполнения вычитания.

Здесь применяется правило, согласно которому целочисленная арифметика без знака всегда выполняется по модулю 2 X, где X-ширина целого числа.

Следовательно a - b , всегда давайте вам 2 X — 1, так как это значение равно -1 по модулю 2 в диапазоне M .

Теперь вы назначаете его подписанному типу. Предположим, что C 20 (до C 20 это поведение, определяемое реализацией, при присвоении значения без знака, которое не может быть представлено целевым типом со знаком).

Здесь результат a - b (т. е. 2 X — 1) преобразуется в уникальное значение, соответствующее самому себе по модулю 2 Y в диапазоне назначения (т. е. от -2 Y-1 до 2 Y-1 — 1). Поскольку X >= Y, это всегда будет > -1 .

Таким образом, вы получаете -1 в качестве вывода.

Третья категория: (X >= 32) и (X >

В этой категории есть только один случай, а именно случай , когда M= uint32_t , N = uint64_t .

Вычитание такое же, как и в категории 2, где вы получаете 2 32 — 1.

Правило преобразования в подписанный тип остается прежним. Однако на этот раз 2 32 — 1 равно самому себе по модулю 2 64, поэтому значение остается неизменным.

Примечание: 4294967295 == 2 32 — 1

унести

Это, вероятно, удивительный аспект C , и, как предложил @NathanOliver, вам следует избегать смешивания типов со знаком и без знака и проявлять крайнюю осторожность, когда вы хотите их смешать.

Вы можете указать компилятору генерировать предупреждения для такого преобразования, включив -Wconversion . Ваш код получает много предупреждений, когда это включено.

Комментарии:

1. Итак, допустим, я хочу оставить это c = a - b; в своем коде, зная, что в настоящее время это безопасно, но что в какой-то момент в будущем другой разработчик может изменить размеры подписанного c или неподписанного a или b. Какой static_assert будет инкапсулировать здесь принципы, по которым он остается «безопасным», т. Е. Не перетекает из отрицательного в положительное?

2. @PatrickParker Ну, я думаю, что это заслуживает отдельного вопроса и дополнительных исследований. Так что поправьте меня, если я ошибаюсь, вам в основном нужно следующее: учитывая два целочисленных типа, вы хотите получить во время компиляции знаковый тип, который может содержать любое значение разницы между типами операндов.

3. Ну, это больше похоже на следующее: разработчик A управляет заголовком, который определяет тип операндов со знаком и без знака, которые могут измениться. разработчик B контролирует код signed_c = unsigned_a - unsigned_b , который не изменится. и третий разработчик (я) хотел бы написать static_assert, используя эти переменные и/или символы ввода, чтобы убедиться, что результат не будет переполнен, чтобы стать положительным числом. Вы правы в том, что это, вероятно, должен быть отдельный вопрос. Но это было то, к чему я пытался подойти со своим вопросом «что такое еда на вынос».

4. можете ли вы просто подтвердить, правильно ли этот static_assert предотвращает этот переход к положительному сценарию? static_assert(sizeof(a-b)>=sizeof(c) || sizeof(a-b)<sizeof(int)); правильно ли было писать int здесь вместо этого 32 ?

5. @PatrickParker Вы берете разницу между двумя значениями без знака и преобразуете ее в значение со знаком, результат может быть не тем, что вы хотите (я сделал оговорку об этом ранее в более раннем комментарии). Например, UINT_MAX- 0u и 0u - 1u оба дали бы вам UINT_MAX . Вам придется привести a и b к знаковому типу шире, чем оба, прежде чем выполнять вычитание (т. Е. uint32_t к int64_t )

Ответ №2:

Давайте предположим, что вменяемая двухкомпонентная платформа, int имеющая 32 бита и uint32_t такая же, как unsigned .

     uint32_t a = 3, b = 4;
    int64_t c =  a - b;
 

Операнды - оператору подвергаются целочисленным повышениям*. int не могут представлять все значения uint32_t , но 32-разрядные unsigned могут представлять все значения uint32_t . Эти ценности пропагандируются unsigned . Тип результата — - это общий тип операндов после продвижения — оба операнда являются unsigned . Тип результата - оператора является unsigned . a - b это математически -1 . Результат таков (unsigned)-1 , но unsigned не может представлять отрицательные числа. Таким -1 образом, преобразуется в unsigned тип, он «оборачивается» и приводит к UINT_MAX, который равен UINT32_MAX, потому unsigned что имеет 32 бита. Этот результат представим в int64_t , поэтому преобразование не происходит, и c ему присваивается значение UINT32_MAX.

Для контраста возьмем, к примеру <uint16_t, int64_t> . 32-разрядный int может представлять все значения an uint16_t , поэтому он uint16_t повышается до int, поэтому результатом a - b является только an (int)-1 . Преобразование из (int)-1 в беззнаковое число не выполняется. Затем int64_t может представлять -1 , поэтому значение -1 просто присваивается переменной с типом int64_t .

* На языке Си это называется целочисленными акциями

Комментарии:

1. и что нужно сделать программистам в отношении лучших практик? например, «не беспокойтесь о назначении неподписанного a-b подписанному c, если c не шире int и не отличается шириной (a-b)»?

2. Ну, я не понимаю этой части. Лучшие практики для чего? Каким должен быть результат? Если вы хотите иметь -1 , просто напишите -1 вместо вычитания двух чисел. Определенно, лучшая практика — выучить язык и знать, как он работает. Если вам не нравятся неявные рекламные акции, конверсии и слабая типизация, перейдите на другой язык программирования.

3. don't worry about assigning unsigned a-b to signed c unless c is wider than int and different width than (a-b) Какой результат вы хотите получить? Код работает так, как задумано. Я полагаю, что вы хотите быть явным — N c = (M)(a - b); — в этом случае он будет переполнен в соответствии с типом без знака (я не знаю, является ли это предполагаемым результатом ). Все зависит от того, чего вы хотите . В любом случае лучшей практикой может быть соблюдение некоторых правил MISRA и других стандартов безопасности, которые специально регулируют запутанное поведение неявных рекламных акций в коде.

4. Я вижу пример правила 5-0-3 в MISRA C 2008 tlemp.com/download/rule/MISRA-CPP-2008-STANDARD.pdf . Исходя из этого, другой наилучшей практикой было бы выполнить N c = (N)a - (N)b приведение значений перед вычислением. Но обратите внимание, что переполнение со знаком также является угловым случаем — лучше всего было бы обрабатывать его отдельно.