Переполнение целочисленного значения со знаком в С++?

У меня есть устаревшая кодовая база, которую мы пытаемся перенести с devtoolset-4 на devtoolset-7. Я заметил интересное поведение в отношении переполнения целых чисел со знаком (точнее, int64_t).

Существует фрагмент кода, который используется для обнаружения целочисленного переполнения при умножении большого набора целых чисел:

// a and b are int64_t
int64_t product = a * b; 
if (b != 0 && product / b != a) {
    // Overflow
}

Этот код отлично работал с devtoolset-4. Однако с devtoolset-7 переполнение никогда не обнаруживается.

Например: когда a = 83802282034166 и b = 98765432, product становится -5819501405344925872 (очевидно, что значение переполнено).

Но product / b приводит к значению, равному a (83802282034166). Следовательно, условие if никогда не становится истинным. Его значение должно было быть вычислено на основе переполненного (отрицательного) значения product: -5819501405344925872 / 98765432 = -58922451788

Как ни странно, математика верна, но вызывает аномальное поведение в отношении devtoolset-4.

  • Может ли компилятор кэшировать значение (а не переоценивать его), что приводит к такому поведению?
  • Или оптимизация компилятора преобразует оператор product / b != a в product != a * b и достигает того же значения переполнения (или, может быть, просто пропускает вычисления на основе приведенного выше оператора, где product = a * b)?

Я понимаю, что переполнение целого числа со знаком является «неопределенным поведением» в C++, поэтому поведение компилятора может меняться в разных реализациях. Но может ли кто-нибудь помочь мне понять вышеописанное поведение?

Примечание: версии g++ в devtoolset-4 и devtoolset-7 — g++ (GCC) 5.2 и g++ (GCC) 7.2.1 соответственно.


person Still-InBeta    schedule 28.03.2018    source источник
comment
signed целочисленное переполнение не определено в С++. Вы не можете надежно обнаружить это постфактум, так как вы неявно находитесь в неопределенном поведении.   -  person François Andrieux    schedule 28.03.2018
comment
Правильный тест проверяет перед умножением, чтобы избежать неопределенного поведения. Что-то вроде if (std::numeric_limits<std::int64_t>::max() / b < a) { /* error */ }.   -  person Pete Becker    schedule 28.03.2018
comment
@PeteBecker Да, Пит, это то, на что я изменил код. Я просто пытался понять вышеописанное поведение.   -  person Still-InBeta    schedule 28.03.2018
comment
Чтобы понять поведение, вам нужно взглянуть на сгенерированный ассемблерный код, чтобы увидеть, что с ним сделал компилятор.   -  person 1201ProgramAlarm    schedule 28.03.2018
comment
product == a * b, поэтому product / b != a можно оптимизировать до -> a * b / b != a -> a * 1 != a -> a != a -> false.   -  person eerorika    schedule 28.03.2018
comment
ОП заявляет, что он знает, что переполнение целого числа со знаком - это UB. Ссылка на вопрос, в ответах на который указано, что это UB, бесполезна.   -  person Yakk - Adam Nevraumont    schedule 28.03.2018
comment
Если вам интересно понять, что компиляторы выводят для данного фрагмента кода, вы можете использовать godbolt.org. Например, я попробовал ваш фрагмент, и кажется, что условие полностью оптимизировано gcc 7.3 с - О2. Для объяснения того, почему поведение undefined делает то, что оно делает, вам нужно будет точно указать, какой компилятор вы используете, какую версию вы используете, какие флаги используете, и ответ может зависеть от других факторов.   -  person François Andrieux    schedule 28.03.2018


Ответы (7)


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

Это непроверенный код, но вам, вероятно, понадобится что-то вроде следующего:

if(b != 0) {
    auto max_a = std::numeric_limits<int64_t>::max() / b;
    if(max_a < a) {
        throw std::runtime_error{"overflow"};
    }
}
return a * b;

Обратите внимание, что этот код не обрабатывает потерю значимости; если a * b может быть отрицательным, эта проверка не сработает.

Согласно Godbolt, вы можете видеть, что в вашей версии проверка полностью оптимизирована.

person Stephen Newell    schedule 28.03.2018
comment
Спасибо @stephen. Ссылка Godbolt отвечает на мой вопрос. Так что это действительно связано с оптимизацией компилятора. Также было бы здорово, если бы вы могли дать мне ссылку на обсуждение Cppcon. - person Still-InBeta; 28.03.2018
comment
Разве это не должно быть a > max_a? - person Aconcagua; 28.03.2018
comment
и отрицательный тоже не обрабатывается (и -big * -big, и big * -big) - person Jarod42; 28.03.2018
comment
@Aconcagua - Уверен, что у меня правильный чек. max_a должно быть максимально возможным значением, которое может хранить int64_t, поэтому a никогда не может быть больше. - person Stephen Newell; 28.03.2018
comment
@ Jarod42 - Хороший улов на негативах. Оставлю это в качестве упражнения для читателя :) - person Stephen Newell; 28.03.2018
comment
@StephenNewell max_a = max_i64 / b, поэтому max_a - это максимальное значение, которое можно умножить на b... Или я неправильно понял, что возврат 0 означает переполнение? В любом случае, возможно, лучше throw? - person Aconcagua; 28.03.2018
comment
@StephenNewell негативы -> упражнение - предположим, было бы неплохо добавить его к самому ответу, в противном случае его можно было бы контролировать в комментариях ... - person Aconcagua; 28.03.2018
comment
@Aconcagua: Ты прав, я идиот. Вот почему существуют обзоры кода :). Согласен с throw и как-то не подумал об этом, когда писал ответ. - person Stephen Newell; 28.03.2018
comment
@Still-InBeta - сейчас у меня нет пропускной способности, чтобы просмотреть все старые доклады Cppcon (хотя звучит весело). Если мне не изменяет память, это правильный разговор: youtube.com/watch?v=g7entxbQOCc (может быть и этот: youtube.com/watch?v=yG1OZ69H_ -о). Если кто-то хочет поработать и проверить/исправить ссылку, я с удовольствием отредактирую ответ. - person Stephen Newell; 28.03.2018
comment
Кто-нибудь наблюдал такое неопределенное поведение на любом компиляторе? - person TakeMeAsAGuest; 24.03.2019
comment
@TakeMeAsAGuest — Проверьте Godbolt: godbolt.org/z/4DdDlT. И gcc, и clang полностью оптимизируют проверку переполнения, но только для подписанных типов (определено незнаковое переполнение/недостаточное переполнение). - person Stephen Newell; 25.03.2019
comment
я не очень разбираюсь в ассемблере, так что же делает сам процессор, если он переполняется? сказать сложение или умножение? извините, у меня сейчас нет компилятора С++, и я также хочу, чтобы читатели изучали smt. - person TakeMeAsAGuest; 25.03.2019
comment
@TakeMeAsAGuest — подписанные версии функции имеют три инструкции: mov, imul, ret; нет разветвления. В неподписанных версиях есть много инструкций, в том числе test и je (переход, если они равны); если вы наведете курсор на инструкцию, вы получите краткое объяснение. Что касается того, что делает ЦП, это зависит от конкретного ЦП. Если вам нужны подробности об архитектуре, вам нужно будет задать новый вопрос, чтобы кто-то, кто знает, мог ответить вам. - person Stephen Newell; 25.03.2019
comment
мне было любопытно узнать о x86, но ладно - person TakeMeAsAGuest; 25.03.2019

Переполнение целого числа со знаком — это неопределенное поведение в C++.

Это означает, что оптимизатор может предположить, что этого никогда не произойдет. a*b/b это a, и точка.

Современные компиляторы выполняют статическую оптимизацию на основе одиночного присваивания.

// a and b are int64_t
int64_t product = a * b;
if (b != 0 && product / b != a) {
  // Overflow
}

становится:

const int64_t __X__ = a * b; 
const bool __Y__ = b != 0;
const int64_t __Z__ = __X__ / b;
const int64_t __Z__ = a*b / b;
const int64_t __Z__ = a;

if (__Y__ && __Z__ != a) {
  // Overflow
}

который оценивается как

if (__Y__ && false) {
  // Overflow
}

ясно, что __Z__ это a, а a!=a это false.

int128_t big_product = a * b; 

работать с big_product и обнаруживать там переполнение.

SSA позволяет компилятору реализовать такие вещи, как (a+1)>a всегда верно, что может упростить многие циклы и случаи оптимизации. Этот факт основан на том факте, что переполнение значений со знаком является неопределяемым поведением.

person Yakk - Adam Nevraumont    schedule 28.03.2018

Зная этоproduct == a * b, компилятор/оптимизатор может предпринять следующие шаги по оптимизации:

b != 0 && product / b != a
b != 0 && a * b / b != a
b != 0 && a * 1 != a
b != 0 && a != a
b != 0 && false
false

Оптимизатор может полностью удалить ветвь.


Я понимаю, что переполнение целого числа со знаком является «неопределенным поведением» в C++, поэтому поведение компилятора может меняться в разных реализациях. Но может ли кто-нибудь помочь мне понять вышеописанное поведение?

Возможно, вы знаете, что переполнение целого числа со знаком — это UB, но я полагаю, вы еще не поняли, что на самом деле означает UB. UB не нужен, и часто не имеет смысла. Хотя этот случай кажется прямолинейным.

person eerorika    schedule 28.03.2018

может ли кто-нибудь помочь мне понять вышеописанное поведение?

Переполнение целого числа со знаком имеет неопределенное поведение в C++. Это означает, что вы не можете надежно его обнаружить, и что код, содержащий переполнение целого числа со знаком, может делать что угодно.


Если вы хотите определить, приведет ли операция к переполнению целого числа со знаком или нет, вам нужно сделать это до того, как произойдет переполнение, предотвращая возникновение UB.

person Vittorio Romeo    schedule 28.03.2018
comment
Уже есть несколько дубликатов, полезен ли этот ответ? - person YSC; 28.03.2018
comment
Нет, давай закроем. - person Vittorio Romeo; 28.03.2018

Целочисленное переполнение со знаком является неопределенным поведением. Это отличается от unsigned int (все беззнаковые целые). Дополнительная информация об этом here

В качестве примечания люди заметили, что использование int вместо unsigned int увеличивает производительность (см. unsigned-int">здесь), так как компилятор не имеет дело с переполнением.

person Raxvan    schedule 28.03.2018
comment
Уже есть несколько дубликатов, полезен ли этот ответ? - person YSC; 28.03.2018

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

https://gmplib.org/

person Tomaz Canabrava    schedule 28.03.2018

вы можете прочитать эту документацию, она может быть вам полезна, как если бы у меня возникла какая-либо проблема с переменными и типами данных, я сразу же прочитаю ее: http://www.cplusplus.com/doc/tutorial/variables/

person Abdelrahman Nasr    schedule 28.03.2018