В какой точке цикла цикл переполнения целых чисел становится undefined?

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

#include <stdio.h>
int main()
{
    int a = 0;
    for (int i = 0; i < 3; i++)
    {
        printf("Hello\n");
        a = a + 1000000000;
    }
}

Эта программа содержит поведение undefined на моей платформе, потому что a будет переполняться в третьем цикле.

Имеет ли значение целая программа поведение undefined или только после фактического переполнения ? Может ли компилятор скомпрометировать, что переполнение a будет, чтобы оно могло объявить весь цикл undefined и не пытаться запускать printfs, даже если все они происходят до переполнения?

(Tagged C и С++, хотя и разные, потому что меня будут интересовать ответы для обоих языков, если они разные.)

Ответ 1

Если вы интересуетесь чисто теоретическим ответом, стандарт С++ допускает поведение undefined к "путешествию во времени":

[intro.execution]/5:Соответствующая реализация, выполняющая хорошо сформированную программу, должна обеспечивать такое же наблюдаемое поведение как одно из возможных исполнений соответствующего экземпляра абстрактной машины с той же программой и тот же ввод. Однако , если какое-либо такое исполнение содержит операцию undefined, этот Международный Стандарт не требует от реализации выполнения этой программы с этим вводом (даже в отношении операций, предшествующих первой операции undefined)

Таким образом, если ваша программа содержит поведение undefined, то поведение всей вашей программы будет undefined.

Ответ 2

Во-первых, позвольте мне исправить название этого вопроса:

Undefined Поведение не является (конкретно) сферы выполнения.

Undefined Поведение влияет на все этапы: компиляция, связывание, загрузка и выполнение.

Некоторые примеры для цементирования этого, имейте в виду, что ни один из разделов не является исчерпывающим:

  • компилятор может предположить, что части кода, содержащие Undefined Behavior, никогда не выполняются и, следовательно, предполагают, что пути выполнения, которые приведут к ним, являются мертвым кодом. См. Что каждый программист C должен знать о поведении Undefined не кем иным, как Крисом Латтерном.
  • компоновщик может предположить, что при наличии нескольких определений слабого символа (признанного по имени) все определения идентичны с помощью правила одного определения
  • загрузчик (в случае использования динамических библиотек) может принимать одно и то же значение, таким образом выбирая первый найденный им символ; это обычно (ab) используется для перехвата вызовов с использованием LD_PRELOAD трюков в Unixes
  • выполнение может завершиться неудачно (SIGSEV), если вы используете оборванные указатели.

Вот что так страшно в отношении Undefined Поведение: почти невозможно заранее предсказать, какое точное поведение произойдет, и это предсказание нужно пересмотреть при каждом обновлении инструментальной цепочки, лежащей в основе ОС...


Я рекомендую посмотреть это видео Michael Spencer (LLVM Developer): CppCon 2016: Мой маленький оптимизатор: Undefined Поведение - это волшебство.

Ответ 3

Настойчиво оптимизирующий компилятор C или С++, ориентированный на 16-разрядный int, будет знать, что поведение при добавлении 1000000000 к типу int undefined.

Разрешено либо стандартом делать что угодно, что может включать удаление всей программы, оставляя int main(){}.

Но как насчет больших int s? Я не знаю о компиляторе, который делает это еще (и я не являюсь экспертом в разработке компилятора C и С++ любыми способами), но я полагаю, что когда-нибудь компилятор, ориентированный на 32-разрядный int или выше, выяснит что цикл бесконечен (i не изменяется), и поэтому a будет в конечном итоге переполняться. Поэтому еще раз, он может оптимизировать вывод на int main(){}. То, что я пытаюсь сделать здесь, состоит в том, что, поскольку оптимизация компилятора становится все более агрессивной, все больше и больше конструкций поведения undefined проявляются неожиданными способами.

Тот факт, что ваш цикл бесконечен, сам по себе не является undefined, поскольку вы пишете стандартный вывод в теле цикла.

Ответ 4

Технически, в соответствии со стандартом С++, если программа содержит поведение undefined, поведение всей программы даже во время компиляции (до того, как программа ровная Выполнено), undefined.

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

Undefined поведение предоставляет компилятору больше возможностей для оптимизации, поскольку устраняет некоторые предположения о том, что должен делать код. При этом программы, которые полагаются на предположения с участием поведения undefined, не гарантируются, как ожидается. Таким образом, вы не должны полагаться на какое-либо конкретное поведение, которое считается undefined на стандарт С++.

Ответ 5

Чтобы понять, почему поведение undefined может путешествовать во времени ", как @TartanLlama адекватно поместить его, давайте взглянем на правило" как-если":

1.9 Выполнение программы

1 Семантические описания в этом Международном стандарте определяют параметризованной недетерминированной абстрактной машиной. Этот международный Стандарт не требует от структуры соответствия Реализации. В частности, им не нужно копировать или эмулировать структура абстрактной машины. Скорее, соответствующие реализации требуются для подражания (только) наблюдаемого поведения абстрактного как описано ниже.

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

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

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

Undefined поведение - это отсутствие отображения между входом и выходом. Программа может иметь поведение undefined для некоторого ввода, но определенное поведение для другого. Тогда отображение между входом и выходом просто неполно; есть вход, для которого нет отображения для вывода.
Программа в вопросе имеет поведение undefined для любого ввода, поэтому отображение пуст.

Ответ 6

Предполагая, что int является 32-разрядным, поведение undefined происходит на третьей итерации. Так, если, например, цикл был только условно достижим или мог условно быть прерван до третьей итерации, не было бы поведения undefined, если бы не была достигнута третья итерация. Однако в случае поведения undefined все выходные данные программы undefined, включая вывод, который является "в прошлом" относительно вызова undefined. Например, в вашем случае это означает, что нет гарантии видеть 3 сообщения "Hello" на выходе.

Ответ 7

Ответ ТартанЛлама правильный. Поведение undefined может происходить в любое время, даже во время компиляции. Это может показаться абсурдным, но это ключевая функция, позволяющая компиляторам делать то, что им нужно делать. Не всегда легко быть компилятором. Вы должны делать то, о чем говорит спецификатор, каждый раз. Однако иногда может показаться чудовищно трудно доказать, что происходит определенное поведение. Если вы помните проблему остановки, ее довольно тривиально разрабатывать программное обеспечение, для которого вы не можете доказать, завершает ли он или вводит бесконечный цикл при подаче определенного ввода.

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

Есть пример, который я люблю публиковать, хотя я признаю, что потерял источник, поэтому я должен перефразировать. Это было из определенной версии MySQL. В MySQL у них был циклический буфер, который был заполнен данными, предоставленными пользователем. Они, конечно, хотели убедиться, что данные не переполняют буфер, поэтому у них была проверка:

if (currentPtr + numberOfNewChars > endOfBufferPtr) { doOverflowLogic(); }

Это выглядит достаточно здраво. Однако, что, если numberOfNewChars действительно большой и переполняется? Затем он обертывается и становится указателем меньше endOfBufferPtr, поэтому логика переполнения никогда не будет вызвана. Поэтому они добавили вторую проверку, прежде чем она:

if (currentPtr + numberOfNewChars < currentPtr) { detectWrapAround(); }

Похоже, вы позаботились о ошибке переполнения буфера, верно? Однако была представлена ​​ошибка, указывающая, что этот буфер переполнен в определенной версии Debian! Тщательное расследование показало, что эта версия Debian была первой, использующей особенно кровоточащую версию gcc. В этой версии gcc компилятор признал, что currentPtr + numberOfNewChars может никогда быть меньшим указателем, чем currentPtr, потому что переполнение для указателей - это поведение undefined! Этого было достаточно для gcc для оптимизации всей проверки, и внезапно вы не были защищены от переполнения буфера, даже несмотря на то, что вы написали код для проверки!

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

Ответ 8

Поведение

Undefined - это, по определению, серая область. Вы просто не можете предсказать, что он будет или не будет делать, - что означает "undefined поведение".

С незапамятных времен программисты всегда пытались спасти остатки определенной определенности из ситуации undefined. У них есть код, который они действительно хотят использовать, но который оказывается undefined, поэтому они пытаются утверждать: "Я знаю его undefined, но, конечно же, он в худшем случае сделает то или это; никогда этого не сделает". И иногда эти аргументы более или менее правильные, но часто они ошибаются. И поскольку компиляторы становятся умнее и умнее (или, может быть, некоторые люди говорят, подхалимнее и подхалимнее), границы вопроса продолжают меняться.

Итак, если вы хотите написать код, который гарантированно работает, и это будет работать долгое время, есть только один выбор: избегайте поведения undefined любой ценой. Поистине, если ты будешь в нем, он вернется, чтобы преследовать тебя.

Ответ 9

Помимо теоретических ответов, практическое наблюдение состояло бы в том, что в течение длительного времени компиляторы применяли различные преобразования на циклах, чтобы уменьшить объем выполненной в них работы. Например, данный:

for (int i=0; i<n; i++)
  foo[i] = i*scale;

компилятор может преобразовать это в:

int temp = 0;
for (int i=0; i<n; i++)
{
  foo[i] = temp;
  temp+=scale;
}

Таким образом, сохранение умножения с каждой итерацией цикла. Дополнительная форма оптимизации, которую компиляторы адаптировали с разной степенью агрессивности, превратит это в:

if (n > 0)
{
  int temp1 = n*scale;
  int *temp2 = foo;
  do
  {
    temp1 -= scale;
    *temp2++ = temp1;
  } while(temp1);
}

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

В то время как большинство таких оптимизаций не будут иметь проблем в случаях, когда два короткие неподписанные типы умножаются, чтобы получить значение, которое находится между INT_MAX + 1 и UINT_MAX, gcc имеет некоторые случаи, когда такое умножение в цикле может привести к началу цикла. Я не заметил, что такое поведение происходит из инструкций сравнения в сгенерированном коде, но это наблюдается в случаях где компилятор использует переполнение, чтобы заключить, что цикл может выполнять не более 4 или менее раз; он по умолчанию не генерирует предупреждения в тех случаях, когда некоторые входы приведут к тому, что UB и другие не будут, даже если его выводы верхняя граница цикла игнорируется.

Ответ 10

Одна вещь, которую ваш пример не рассматривает, - это оптимизация. a устанавливается в цикле, но никогда не используется, и оптимизатор может это решить. Таким образом, для оптимизатора вполне законно полностью отказаться от a, и в этом случае все поведение undefined исчезает как жертва boojum.

Однако, конечно, это undefined, потому что оптимизация undefined.:)

Ответ 11

Поскольку этот вопрос имеет двойную метку C и C++, я попытаюсь обратиться к обоим. C и C++ здесь используются разные подходы.

В C реализация должна быть способна доказать, что неопределенное поведение будет вызываться для того, чтобы обрабатывать всю программу, как если бы у нее было неопределенное поведение. В примере OPs для компилятора показалось бы тривиальным, чтобы доказать это и, следовательно, так: если вся программа не определена.

Мы можем видеть это из отчета о дефектах 109, который, по его словам, спрашивает:

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

int array1[5];
int array2[5];
int *p1 = &array1[0];
int *p2 = &array2[0];

int foo()
{
int i;
i = (p1 > p2); /* Must this be "successfully translated"? */
1/0; /* Must this be "successfully translated"? */
return 0;
}

Поэтому основной вопрос заключается в следующем: должен ли этот код быть "успешно переведен" (что бы это ни значило)? (См. Сноску, прилагаемую к подпункту 5.1.1.3.)

и ответ был:

Стандарт C использует термин "неопределенно оцененный", а не "неопределенное значение". Использование неопределенного оцениваемого объекта приводит к неопределенному поведению. В сноске к подпункту 5.1.1.3 указывается, что реализация может произвольно производить любое количество диагностических операций, пока действительная программа все еще правильно переводится. Если выражение, чья эвауляция приведет к неопределенному поведению, появляется в контексте, где требуется постоянное выражение, содержащая программа не является строго соответствующей. Кроме того, если любое возможное выполнение данной программы приведет к неопределенному поведению, данная программа не будет строго соответствовать. Соответствующая реализация не должна переводить строго соответствующую программу просто потому, что некоторое возможное выполнение этой программы приведет к неопределенному поведению. Поскольку foo никогда не может быть вызван, приведенный пример должен быть успешно переведен с помощью соответствующей реализации.

В C++ подход кажется более расслабленным и предполагает, что программа имеет неопределенное поведение независимо от того, может ли реализация доказать это статически или нет.

У нас есть [intro.abstrac] p5, который гласит:

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

Ответ 12

Верхний ответ - неправильное (но общее) заблуждение:

Undefined поведение - это свойство времени выполнения *. Это НЕ МОЖЕТ "время-путешествие"!

Определенные операции определяются (по стандарту) наличием побочных эффектов и не могут быть оптимизированы. Операции, выполняющие ввод-вывод или доступ к переменным volatile, попадают в эту категорию.

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

На самом деле это согласуется с цитатой в верхнем ответе (акцент мой):

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

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

Итак, да, после выполнения UB любые эффекты предыдущих операций становятся undefined. Но до тех пор, пока это не произойдет, выполнение программы четко определено.

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

* Примечание: Это не противоречит UB во время компиляции. Если компилятор действительно может доказать, что UB-код будет всегда выполнен для всех входов, UB может распространяться на время компиляции. Однако это требует знания того, что все предыдущие коды в конечном итоге возвращаются, что является сильным требованием. Опять же, см. Ниже пример/объяснение.


Чтобы сделать это конкретным, обратите внимание, что следующий код должен печатать foo и ждать ввода, независимо от поведения undefined, которое следует за ним:

printf("foo");
getchar();
*(char*)1 = 1;

Однако также обратите внимание, что нет гарантии, что foo останется на экране после появления UB или что введенный вами символ больше не будет во входном буфере; обе эти операции могут быть "отменены", что имеет аналогичный эффект для UB "время-путешествие".

Если строки getchar() не было, было бы законным, чтобы линии были оптимизированы прочь , если и только если, которые будут неразличимы от вывода foo, а затем "un-doing".

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

  • Если он может блокироваться здесь, то другая программа может отказаться читать свой полный вывод, и он никогда не сможет вернуться, и, следовательно, UB может никогда не произойти.

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

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