Рассмотрим следующее утверждение:
*((char*)NULL) = 0; //undefined behavior
Это явно вызывает неопределенное поведение. Означает ли существование такого утверждения в данной программе, что вся программа не определена или что поведение становится неопределенным только после того, как поток управления достигнет этого утверждения?
Будет ли следующая программа четко определена, если пользователь никогда не введет номер 3
?
while (true) {
int num = ReadNumberFromConsole();
if (num == 3)
*((char*)NULL) = 0; //undefined behavior
}
Или это совершенно неопределенное поведение, независимо от того, что вводит пользователь?
Кроме того, может ли компилятор предположить, что неопределенное поведение никогда не будет выполнено во время выполнения? Это позволило бы рассуждать задом наперед:
int num = ReadNumberFromConsole();
if (num == 3) {
PrintToConsole(num);
*((char*)NULL) = 0; //undefined behavior
}
Здесь, компилятор может объяснить, что в случае num == 3
мы всегда будем вызывать неопределенное поведение. Следовательно, этот случай должен быть невозможным, и номер не нужно печатать. Целиком if
заявление может быть оптимизировано. Разрешено ли такое обратное рассуждение в соответствии со стандартом?
Означает ли существование такого утверждения в данной программе, что
вся программа не определена или это поведение становится неопределенным
когда поток управления попадает в это утверждение?
Ни. Первое условие слишком сильное, а второе слишком слабое.
Доступ к объектам иногда упорядочен, но стандарт описывает поведение программы вне времени. Данвиль уже цитировал:
если любое такое выполнение содержит неопределенную операцию, это
Международный стандарт не предъявляет никаких требований к реализации
выполнение этой программы с этим входом (даже в отношении
операции, предшествующие первой неопределенной операции)
Это можно интерпретировать:
Если выполнение программы приводит к неопределенному поведению, тогда вся программа имеет
неопределенное поведение.
Таким образом, недостижимое утверждение с UB не дает программе UB. Достижимое утверждение, что (из-за значений входных данных) никогда не достигается, не дает программе UB. Вот почему ваше первое состояние слишком сильное.
Теперь компилятор не может вообще сказать, что имеет UB. Таким образом, чтобы позволить оптимизатору переупорядочить операторы с потенциальным UB, которые можно было бы переупорядочить, если бы их поведение было определено, необходимо разрешить UB «возвращаться назад во времени» и идти неправильно до предшествующей точки последовательности (или в C ++ 11 терминология, для UB, чтобы повлиять на вещи, которые упорядочены перед UB). Поэтому ваше второе состояние слишком слабое.
Главный пример этого — когда оптимизатор полагается на строгий псевдоним. Весь смысл строгих правил псевдонимов состоит в том, чтобы позволить компилятору переупорядочивать операции, которые нельзя было переупорядочить, если бы было возможно, что рассматриваемые указатели псевдонима совпадают с памятью. Таким образом, если вы используете незаконные указатели псевдонимов, и UB действительно возникает, тогда это может легко повлиять на оператор «перед» оператором UB. Что касается абстрактной машины, то оператор UB еще не был выполнен. Что касается фактического объектного кода, он был частично или полностью выполнен. Но стандарт не пытается детализировать, что означает для оптимизатора переупорядочение операторов, или как это влияет на UB. Он просто дает лицензию на реализацию, чтобы пойти не так, как только пожелает.
Вы можете думать об этом как: «У UB есть машина времени».
Конкретно ответить на ваши примеры:
PrintToConsole(3)
как-то известно, что обязательно вернется. Это может вызвать исключение или что-то еще.Примером, похожим на ваш второй, является опция gcc -fdelete-null-pointer-checks
, который может взять такой код (я не проверял этот конкретный пример, считаю его иллюстрацией общей идеи):
void foo(int *p) {
if (p) *p = 3;
std::cout << *p << '\n';
}
и измените его на:
*p = 3;
std::cout << "3\n";
Зачем? Потому что, если p
равно нулю, тогда код имеет UB, так что компилятор может предположить, что он не равен нулю и оптимизировать соответственно. Ядро Linux споткнулось об этом (https://web.nvd.nist.gov/view/vuln/detail?vulnId=CVE-2009-1897) в основном потому, что работает в режиме разыменования нулевого указателя не предполагается, что это UB, это должно привести к определенному аппаратному исключению, которое может обработать ядро. Когда оптимизация включена, gcc требует использования -fno-delete-null-pointer-checks
чтобы обеспечить эту нестандартную гарантию.
Постскриптум Практический ответ на вопрос «когда проявляется неопределенное поведение?» это «10 минут до того, как вы планировали уйти на день».
Стандарт гласит на 1,9 / 4
[Примечание: этот международный стандарт не предъявляет никаких требований к
поведение программ, содержащих неопределенное поведение. — конец примечания]
Интересным моментом, вероятно, является то, что означает «содержать». Чуть позже в 1.9 / 5 говорится:
Однако, если любое такое выполнение содержит неопределенную операцию, это
Международный стандарт не предъявляет никаких требований к реализации
выполнение этой программы с этим входом (даже в отношении
операции, предшествующие первой неопределенной операции)
Здесь конкретно упоминается «исполнение … с этим вводом». Я бы сказал, что неопределенное поведение в одной из возможных ветвей, которая не выполняется прямо сейчас, не влияет на текущую ветвь выполнения.
Другой проблемой, однако, являются предположения, основанные на неопределенном поведении во время генерации кода. Смотрите ответ Стива Джессопа для более подробной информации об этом.
Поучительный пример
int foo(int x)
{
int a;
if (x)
return a;
return 0;
}
И текущий GCC, и текущий Clang оптимизируют это (на x86) для
xorl %eax,%eax
ret
потому что они сделать вывод, что x
всегда ноль от UB в if (x)
контрольный путь. GCC даже не выдаст вам предупреждение об использовании неинициализированного значения! (потому что проход, который применяет вышеупомянутую логику, выполняется перед проходом, который генерирует предупреждения неинициализированного значения)
Текущий рабочий проект C ++ говорит в 1.9.4, что
Настоящий международный стандарт не предъявляет никаких требований к поведению программ, которые содержат неопределенное поведение.
Исходя из этого, я бы сказал, что программа, содержащая неопределенное поведение на любом пути выполнения, может делать что угодно в любое время своего выполнения.
Есть две действительно хорошие статьи о неопределенном поведении и о том, что обычно делают компиляторы:
Слово «поведение» означает, что что-то сделанный. Statemenr, который никогда не выполняется, не является «поведением».
Иллюстрация:
*ptr = 0;
Это неопределенное поведение? Предположим, мы уверены на 100% ptr == nullptr
хотя бы один раз во время выполнения программы. Ответ должен быть да.
Как насчет этого?
if (ptr) *ptr = 0;
Это не определено? (Помните ptr == nullptr
хотя бы один раз?) Я надеюсь, что нет, иначе вы не сможете написать какую-либо полезную программу.
Ни один срандардез не пострадал при принятии этого ответа.
Неопределенное поведение проявляется, когда программа вызывает неопределенное поведение независимо от того, что происходит дальше. Однако вы привели следующий пример.
int num = ReadNumberFromConsole();
if (num == 3) {
PrintToConsole(num);
*((char*)NULL) = 0; //undefined behavior
}
Если компилятор не знает определения PrintToConsole
не может удалить if (num == 3)
условна. Давайте предположим, что у вас есть LongAndCamelCaseStdio.h
системный заголовок со следующим объявлением PrintToConsole
,
void PrintToConsole(int);
Ничего особо полезного, все в порядке. Теперь давайте посмотрим, насколько плохим (или, возможно, не таким злым, неопределенное поведение могло бы быть хуже) продавца, проверив фактическое определение этой функции.
int printf(const char *, ...);
void exit(int);
void PrintToConsole(int num) {
printf("%d\n", num);
exit(0);
}
На самом деле компилятор должен предположить, что любая произвольная функция, которую компилятор не знает, что он делает, может выйти или вызвать исключение (в случае C ++). Вы можете заметить, что *((char*)NULL) = 0;
не будет выполнен, так как выполнение не будет продолжено после PrintToConsole
вызов.
Неопределенное поведение поражает, когда PrintToConsole
на самом деле возвращается. Компилятор ожидает, что этого не произойдет (поскольку это приведет к тому, что программа выполнит неопределенное поведение, несмотря ни на что), поэтому может произойти все что угодно.
Однако давайте рассмотрим что-то еще. Допустим, мы делаем нулевую проверку и используем переменную после нулевой проверки.
int putchar(int);
const char *warning;
void lol_null_check(const char *pointer) {
if (!pointer) {
warning = "pointer is null";
}
putchar(*pointer);
}
В этом случае легко заметить, что lol_null_check
требует ненулевой указатель. Присвоение глобальной энергонезависимой warning
Переменная — это не то, что может выйти из программы или вызвать какое-либо исключение. pointer
также является энергонезависимым, поэтому он не может волшебным образом изменять свое значение в середине функции (если это так, это неопределенное поведение). призвание lol_null_check(NULL)
приведет к неопределенному поведению, которое может привести к тому, что переменная не будет назначена (потому что в этот момент известен тот факт, что программа выполняет неопределенное поведение).
Однако неопределенное поведение означает, что программа может делать все что угодно. Таким образом, ничто не мешает неопределенному поведению вернуться назад во времени и вывести из строя вашу программу перед первой строкой int main()
выполняет. Это неопределенное поведение, оно не должно иметь смысла. Это может также привести к сбою после ввода 3, но неопределенное поведение вернется во времени и завершится раньше, чем вы даже наберете 3. И, кто знает, возможно, неопределенное поведение перезапишет вашу системную оперативную память и вызовет сбой вашей системы через 2 недели, пока ваша неопределенная программа не запущена.
Если программа достигает оператора, который вызывает неопределенное поведение, никакие требования не предъявляются ни к какому результату / поведению программы; не имеет значения, произойдут ли они «до» или «после» неопределенного поведения.
Ваши рассуждения о всех трех фрагментах кода верны. В частности, компилятор может обрабатывать любое утверждение, которое безоговорочно вызывает неопределенное поведение, как GCC обрабатывает __builtin_unreachable()
: как намек на оптимизацию, что утверждение недостижимо (и, следовательно, все пути кода, безоговорочно ведущие к нему, также недоступны). Другие подобные оптимизации, конечно, возможны.
Многие стандарты для многих видов вещей тратят много усилий на описание вещей, которые реализация ДОЛЖНА или НЕ ДОЛЖНА делать, используя номенклатуру, аналогичную определенной в IETF RFC 2119 (хотя не обязательно, ссылаясь на определения в этом документе). Во многих случаях описания того, что должны делать реализации за исключением случаев, когда они будут бесполезны или нецелесообразны важнее, чем требования к которым все соответствующие реализации должны соответствовать.
К сожалению, стандарты C и C ++ стремятся избегать описаний вещей, которые, хотя и не требуются на 100%, тем не менее следует ожидать от качественных реализаций, которые не документируют противоположное поведение. Предложение о том, что реализации должны что-то делать, может рассматриваться как подразумевающее, что те, которые не уступают, и в тех случаях, когда, как правило, очевидно, какое поведение будет полезным или практичным, а не непрактичным и бесполезным, в данной реализации, были мало осознанная необходимость того, чтобы Стандарт вмешивался в такие суждения.
Умный компилятор может соответствовать Стандарту, исключая при этом любой код, который не будет иметь никакого эффекта, за исключением случаев, когда код получает входные данные, которые неизбежно приводят к неопределенному поведению, но «умный» и «немой» не являются антонимами. Тот факт, что авторы Стандарта решили, что могут существовать некоторые виды реализаций, в которых поведение с пользой в данной ситуации было бы бесполезным и непрактичным, не предполагает какого-либо суждения о том, следует ли считать такое поведение практичным и полезным для других. Если бы реализация могла поддерживать поведенческую гарантию без каких-либо затрат, кроме потери возможности сокращения «мертвой ветви», почти любое значение, которое пользовательский код мог бы получить из этой гарантии, превысило бы стоимость ее предоставления. Устранение мертвой ветви может быть хорошо в тех случаях, когда это не потребует отказа что-нибудь, но если бы в данной ситуации пользовательский код мог обработать практически любое возможное поведение Другой По сравнению с устранением мертвых ветвей, любой пользовательский код, который нужно приложить, должен будет потратить, чтобы избежать UB, вероятно, превысит значение, полученное от DBE.