Это пример для иллюстрации моего вопроса, который включает в себя гораздо более сложный код, который я не могу здесь опубликовать.
#include <stdio.h>
int main()
{
int a = 0;
for (int i = 0; i < 3; i++)
{
printf("Hello\n");
a = a + 1000000000;
}
}
Эта программа содержит неопределенное поведение на моей платформе, потому a
что в третьем цикле произойдет переполнение.
Делает ли это поведение всей программы неопределенным или только после того, как действительно происходит переполнение ? Может ли компилятор потенциально работать, что a
будет переполняться , поэтому он может объявить весь цикл неопределенного и не беспокоить , чтобы запустить printfs , даже если они все произойдут до перелива?
(Помечены C и C ++, хотя они разные, потому что меня бы интересовали ответы для обоих языков, если они разные.)
a
не используется (кроме вычислений), и просто удалитьa
Ответы:
Если вас интересует чисто теоретический ответ, стандарт C ++ допускает неопределенное поведение "путешествия во времени":
Таким образом, если ваша программа содержит неопределенное поведение, то поведение всей вашей программы не определено.
источник
sneeze()
функция не определена ни для чего из этого классаDemon
(подклассом которого является носовая разновидность), что в любом случае делает все это круговым.printf
не возвращается, но еслиprintf
он собирается вернуться, то неопределенное поведение может вызвать проблемы доprintf
вызова. Следовательно, путешествие во времени.printf("Hello\n");
а затем следующая строка компилируется какundoPrintf(); launchNuclearMissiles();
Во-первых, позвольте мне исправить заголовок этого вопроса:
Неопределенное поведение (конкретно) не из области исполнения.
Неопределенное поведение влияет на все шаги: компиляцию, связывание, загрузку и выполнение.
Несколько примеров, подтверждающих это, помните, что ни один раздел не является исчерпывающим:
LD_PRELOAD
уловок в UnixВот что так пугает в Undefined Behavior: предсказать заранее, какое именно поведение будет происходить, практически невозможно, и этот прогноз необходимо пересматривать при каждом обновлении цепочки инструментов, базовой ОС, ...
Я рекомендую посмотреть это видео Майкла Спенсера (разработчик LLVM): CppCon 2016: My Little Optimizer: Undefined Behavior is Magic .
источник
argc
в качестве счетчика циклов, регистрargc=1
не выдает UB, и компилятор был бы вынужден обработать это.i
нельзя увеличивать более чемN
раз и, следовательно, его значение ограничено.f(good);
что-то делает X иf(bad);
вызывает неопределенное поведение, тогда программа, которая просто вызываетf(good);
, гарантированно выполнит X, ноf(good); f(bad);
не гарантированно сделает X.if(foo) f(good); else f(bad);
, умный компилятор отбросит сравнение и создаст и безусловныйfoo(good)
.Агрессивно оптимизирующий компилятор C или C ++, ориентированный на 16 бит,
int
будет знать, что поведение при добавлении1000000000
кint
типу не определено .Допускается любой стандарт , чтобы сделать все , что он хочет , который может включать в себя удаление всей программы, в результате чего
int main(){}
.А как насчет большего
int
s? Я не знаю компилятора, который бы это делал (и я ни в коем случае не являюсь экспертом в проектировании компиляторов C и C ++), но я полагаю, что когда-нибудь компилятор, ориентированный на 32-битныйint
или выше, обнаружит, что цикл бесконечно (i
не меняется) и поэтому вa
конечном итоге переполнится. Итак, еще раз, он может оптимизировать вывод доint main(){}
. Я пытаюсь здесь подчеркнуть, что по мере того, как оптимизация компилятора становится все более агрессивной, все больше и больше неопределенных поведенческих конструкций проявляют себя неожиданным образом.Тот факт, что ваш цикл бесконечен, сам по себе не является неопределенным, поскольку вы пишете в стандартный вывод в теле цикла.
источник
int
это 16 бит, добавление будет происходить вlong
(потому что у литерального операнда есть типlong
), где он четко определен, а затем будет преобразован с помощью преобразования, определенного реализацией, обратно вint
.printf
определяется стандартом, чтобы всегда возвращатьТехнически, согласно стандарту C ++, если программа содержит неопределенное поведение, поведение всей программы, даже во время компиляции (до того, как программа даже будет выполнена), не определено.
На практике, поскольку компилятор может предположить (как часть оптимизации), что переполнение не произойдет, по крайней мере, поведение программы на третьей итерации цикла (при условии 32-разрядной машины) будет неопределенным, хотя это вероятно, что вы получите правильные результаты до третьей итерации. Однако, поскольку поведение всей программы технически не определено, ничто не мешает программе генерировать полностью неправильный вывод (включая отсутствие вывода), сбой во время выполнения в любой момент во время выполнения или даже неспособность полностью скомпилировать (поскольку неопределенное поведение распространяется на время компиляции).
Неопределенное поведение предоставляет компилятору больше возможностей для оптимизации, поскольку оно исключает определенные предположения о том, что должен делать код. При этом не гарантируется, что программы, основанные на предположениях, предполагающих неопределенное поведение, будут работать должным образом. Таким образом, вам не следует полагаться на какое-либо конкретное поведение, которое считается неопределенным в соответствии со стандартом C ++.
источник
if(false) {}
области действия? Отравляет ли это всю программу из-за того, что компилятор предполагает, что все ветви содержат ~ четко определенные части логики, и, таким образом, работает с ошибочными предположениями?Чтобы понять, почему неопределенное поведение может «путешествовать во времени», как правильно выразился @TartanLlama , давайте взглянем на правило «как если бы»:
Благодаря этому мы могли рассматривать программу как «черный ящик» с входом и выходом. Входными данными могут быть пользовательский ввод, файлы и многое другое. Результатом является «наблюдаемое поведение», упомянутое в стандарте.
Стандарт определяет только отображение между входом и выходом, ничего больше. Он делает это, описывая «пример черного ящика», но прямо заявляет, что любой другой черный ящик с таким же отображением является одинаково допустимым. Это означает, что содержимое черного ящика не имеет значения.
Имея это в виду, не имеет смысла говорить, что неопределенное поведение происходит в определенный момент. В примере реализации черного ящика мы могли бы сказать, где и когда это произойдет, но на самом деле черный ящик может быть чем-то совершенно другим, поэтому мы больше не можем сказать, где и когда это произойдет. Теоретически компилятор может, например, решить перечислить все возможные входные данные и предварительно вычислить результирующие выходные данные. Тогда во время компиляции произошло бы неопределенное поведение.
Неопределенное поведение - это отсутствие соответствия между вводом и выводом. Программа может иметь неопределенное поведение для одного ввода, но определенное поведение для другого. Тогда сопоставление между вводом и выводом просто неполное; есть вход, для которого не существует сопоставления с выходом.
Программа в вопросе имеет неопределенное поведение для любого ввода, поэтому отображение пусто.
источник
Предполагая, что
int
это 32-битная версия, неопределенное поведение происходит на третьей итерации. Таким образом, если, например, цикл был доступен только условно или мог быть условно завершен до третьей итерации, не было бы неопределенного поведения, если только третья итерация не была фактически достигнута. Однако в случае неопределенного поведения весь вывод программы не определен, включая вывод, который находится «в прошлом» относительно вызова неопределенного поведения. Например, в вашем случае это означает, что нет гарантии увидеть 3 сообщения «Hello» в выводе.источник
Ответ TartanLlama правильный. Неопределенное поведение может произойти в любое время, даже во время компиляции. Это может показаться абсурдным, но это ключевая функция, позволяющая компиляторам делать то, что им нужно. Не всегда легко быть компилятором. Вы должны каждый раз делать именно то, что указано в спецификации. Однако иногда бывает чудовищно сложно доказать, что происходит определенное поведение. Если вы помните проблему остановки, довольно тривиально разработать программное обеспечение, для которого вы не можете доказать, завершается оно или входит в бесконечный цикл при подаче определенного ввода.
Мы могли бы сделать компиляторы пессимистичными и постоянно компилировать, опасаясь, что следующая инструкция может оказаться одной из таких проблем с остановкой, например, проблем, но это неразумно. Вместо этого мы позволяем компилятору пройти: по этим темам «неопределенного поведения» они освобождаются от какой-либо ответственности. Неопределенное поведение состоит из всех видов поведения, которые настолько тонко гнусны, что нам трудно отделить их от действительно отвратительных, гнусных проблем с остановкой и прочего.
Есть пример, который я люблю публиковать, хотя признаю, что потерял источник, поэтому мне приходится перефразировать. Это было из конкретной версии MySQL. В MySQL у них был кольцевой буфер, который был заполнен данными, предоставленными пользователем. Они, конечно, хотели убедиться, что данные не переполняют буфер, поэтому у них была проверка:
if (currentPtr + numberOfNewChars > endOfBufferPtr) { doOverflowLogic(); }
Выглядит достаточно разумно. Однако что, если numberOfNewChars действительно велико и выходит за пределы? Затем он оборачивается и становится указателем меньше чем
endOfBufferPtr
, поэтому логика переполнения никогда не вызывается. Поэтому они добавили вторую проверку перед этой:if (currentPtr + numberOfNewChars < currentPtr) { detectWrapAround(); }
Похоже, вы позаботились об ошибке переполнения буфера? Однако была отправлена ошибка, в которой говорилось, что этот буфер переполнен в определенной версии Debian! Тщательное расследование показало, что эта версия Debian была первой, в которой использовалась передовая версия gcc. В этой версии gcc компилятор обнаружил, что currentPtr + numberOfNewChars никогда не может быть меньшим указателем, чем currentPtr, потому что переполнение для указателей является неопределенным поведением! Этого было достаточно для gcc, чтобы оптимизировать всю проверку, и внезапно вы оказались не защищены от переполнения буфера, даже если написали код для проверки!
Это было особое поведение. Все было законно (хотя, насколько я слышал, gcc откатил это изменение в следующей версии). Это не то, что я считаю интуитивным поведением, но если вы немного расширите свое воображение, легко увидеть, как небольшой вариант этой ситуации может стать проблемой остановки для компилятора. Из-за этого авторы спецификации сделали его «неопределенным поведением» и заявили, что компилятор может делать абсолютно все, что угодно.
источник
if(numberOfNewChars > endOfBufferPtr - currentPtr)
при условии, что numberOfNewChars никогда не может быть отрицательным, а currentPtr всегда указывает на то место в буфере, где вам даже не нужна нелепая «циклическая» проверка. (Я не думаю, что предоставленный вами код имеет хоть какую-то надежду на работу в кольцевом буфере - вы пропустили все необходимое для этого в перефразировке, поэтому я также игнорирую этот случай)Помимо теоретических ответов, практическое наблюдение может заключаться в том, что в течение долгого времени компиляторы применяли различные преобразования к циклам, чтобы уменьшить объем работы, выполняемой в них. Например, учитывая:
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. Он также может превратиться в бесконечный цикл, если масштаб считывался из памяти более одного раза и что-то еще. неожиданно изменил свое значение (в любом случае, когда «масштаб» может изменить середину цикла без вызова UB, компилятор не сможет выполнить оптимизацию).
Хотя большинство таких оптимизаций не вызовут никаких проблем в случаях, когда два коротких беззнаковых типа умножаются для получения значения, которое находится между INT_MAX + 1 и UINT_MAX, gcc имеет некоторые случаи, когда такое умножение внутри цикла может вызвать ранний выход из цикла. . Я не заметил такого поведения, проистекающего из инструкций сравнения в сгенерированном коде, но оно наблюдается в тех случаях, когда компилятор использует переполнение, чтобы сделать вывод, что цикл может выполняться не более 4 раз; по умолчанию он не генерирует предупреждения в тех случаях, когда некоторые входные данные вызывают UB, а другие нет, даже если его выводы приводят к игнорированию верхней границы цикла.
источник
Неопределенное поведение по определению является серой зоной. Вы просто не можете предсказать, что он будет делать, а что нет - вот что означает «неопределенное поведение». .
С незапамятных времен программисты всегда пытались спасти остатки определенности из неопределенной ситуации. У них есть код, который они действительно хотят использовать, но который оказывается неопределенным, поэтому они пытаются возразить: «Я знаю, что он не определен, но, конечно, он, в худшем случае, сделает то или это; он никогда не сделает этого. . " И иногда эти аргументы более или менее верны, но часто они ошибочны. И по мере того, как компиляторы становятся все умнее и умнее (или, как некоторые могут сказать, изворотливее и хитрее), границы вопроса продолжают меняться.
Так что на самом деле, если вы хотите написать код, который гарантированно будет работать и будет работать долгое время, есть только один выбор: любой ценой избегать неопределенного поведения. Поистине, если вы попробуете это сделать, он вернется, чтобы преследовать вас.
источник
Одна вещь, которую ваш пример не учитывает, - это оптимизация.
a
устанавливается в цикле, но никогда не используется, и оптимизатор может с этим справиться. Таким образом, оптимизатор можетa
полностью отказаться от него , и в этом случае все неопределенное поведение исчезнет, как жертва буджума.Однако, конечно, это само по себе не определено, потому что оптимизация не определена. :)
источник
Поскольку этот вопрос связан с двумя тегами C и C ++, я постараюсь ответить на оба вопроса. Здесь C и C ++ используют разные подходы.
В C реализация должна иметь возможность доказать, что будет вызвано неопределенное поведение, чтобы обрабатывать всю программу так, как если бы она имела неопределенное поведение. В примере с OPs компилятору казалось бы тривиальным доказать это, и, следовательно, это как если бы вся программа была неопределенной.
Мы можем видеть это из Отчета о дефектах 109, который в своей сути спрашивает:
и ответ был:
В C ++ подход кажется более расслабленным и предполагает, что программа имеет неопределенное поведение независимо от того, может ли реализация доказать это статически или нет.
У нас есть [intro.abstrac] p5, в котором говорится:
источник
Главный ответ - неправильное (но распространенное) заблуждение:
Неопределенное поведение - это свойство времени выполнения *. Это НЕ МОЖЕТ "путешествовать во времени"!
Некоторые операции определены (по стандарту) как имеющие побочные эффекты и не могут быть оптимизированы. В
volatile
эту категорию попадают операции, выполняющие ввод-вывод или переменные доступа .Однако есть предостережение: UB может иметь любое поведение, включая поведение, отменяющее предыдущие операции. В некоторых случаях это может иметь аналогичные последствия для оптимизации более раннего кода.
Фактически, это согласуется с цитатой в верхнем ответе (выделено мной):
Да, эта цитата делает говорит «даже не в отношении операций , предшествующих первое неопределенной операции» , но обратите внимание , что это именно о том , что код в настоящее время выполняется , а не просто компиляции.
В конце концов, неопределенное поведение, которое фактически не достигнуто, ничего не делает, и для того, чтобы действительно достичь строки, содержащей UB, сначала должен выполняться код, предшествующий ей!
Итак, да, после выполнения UB любые эффекты предыдущих операций становятся неопределенными. Но пока этого не произойдет, выполнение программы четко определено.
Обратите внимание, однако, что все выполнения программы, которые приводят к этому, могут быть оптимизированы для эквивалентных программ, включая те, которые выполняют предыдущие операции, но затем отменяют их эффекты. Следовательно, предыдущий код можно оптимизировать, если это было бы эквивалентно отмене их эффектов ; иначе не может. См. Пример ниже.
* Примечание: это не противоречит UB, возникающему во время компиляции . Если компилятор действительно может доказать , что UB код будет всегда выполняться для всех входов, то UB может распространяться на время компиляции. Однако для этого необходимо знать, что весь предыдущий код в конечном итоге вернется , что является серьезным требованием. Опять же, см. Ниже пример / объяснение.
Чтобы сделать это конкретным, обратите внимание, что следующий код должен печатать
foo
и ждать вашего ввода независимо от любого неопределенного поведения, которое следует за ним:printf("foo"); getchar(); *(char*)1 = 1;
Однако также обратите внимание, что нет гарантии, что
foo
он останется на экране после появления UB или что набранный вами символ больше не будет во входном буфере; обе эти операции можно «отменить», что имеет эффект, аналогичный UB «путешествия во времени».Если бы
getchar()
строки не было, то оптимизация строк была бы законной тогда и только тогда , когда это было бы неотличимо от вывода,foo
а затем «отмены».Будут ли они неразличимы или нет, будет полностью зависеть от реализации (то есть от вашего компилятора и стандартной библиотеки). Например, можете ли вы
printf
заблокировать здесь свой поток, ожидая, пока другая программа прочитает вывод? Или сразу вернется?Если он может заблокироваться здесь, тогда другая программа может отказаться читать свой полный вывод, и он может никогда не вернуться, и, следовательно, UB может никогда не произойти.
Если он может немедленно вернуться сюда, тогда мы знаем, что он должен вернуться, и поэтому его оптимизация полностью неотличима от его выполнения и последующего отмены его эффектов.
Конечно, поскольку компилятор знает, какое поведение допустимо для его конкретной версии
printf
, он может оптимизировать его соответствующим образом и, следовательно,printf
может быть оптимизирован в некоторых случаях, а в других - нет. Но, опять же, оправдание состоит в том, что это было бы неотличимо от невыполнения UB предыдущих операций, а не потому , что предыдущий код "отравлен" из-за UB.источник