Можно ли считать ветви с неопределенным поведением недостижимыми и оптимизированными как мертвый код?


рассмотрим следующее утверждение:

*((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 оператор может быть оптимизирован. Допускается ли такое обратное рассуждение в соответствии с стандарт?

8 81

8 ответов:

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

ни. Первое условие слишком сильно, а второе слишком слабо.

доступ к объектам иногда упорядочивается, но стандарт описывает поведение программы Вне времени. Данвил уже цитировал:

если такие выполнение содержит неопределенную операцию, это Международный стандарт не устанавливает никаких требований к внедрению выполнение этой программы с этим вводом (даже не в отношении операции, предшествующие первой неопределенной операции)

это можно трактовать:

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

Итак, недостижимый оператор с UB не делает дайте программу UB. Достижимый оператор, который (из-за значений входных данных) никогда не достигается, не дает программе UB. Вот почему ваше первое условие слишком сильно.

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

основной пример этого - когда оптимизатор полагается на строгое сглаживание. Весь смысл строгих правил псевдонимов заключается в том, чтобы позволить компилятору переупорядочивать операции, которые не могут быть действительно переупорядочены, если возможно, что указатели, о которых идет речь, псевдонимы одной и той же памяти. Поэтому, если вы используете незаконно сглаживающие указатели, и UB действительно происходит, то он может легко влияет на заявление "перед" заявлением UB. Что касается абстрактной машины, то оператор UB еще не выполнен. Что касается фактического объектного кода, то он был частично или полностью выполнен. Но стандарт не пытается подробно рассказать о том, что означает для оптимизатора переупорядочивать операторы или какие последствия это имеет для UB. Он просто дает лицензию на реализацию, чтобы пойти не так, как только ему заблагорассудится.

вы можете подумать об этом как, "у UB есть машина времени".

в частности, чтобы ответить на ваши примеры:

  • поведение не определено только при чтении 3.
  • компиляторы могут и устраняют код как мертвый, если базовый блок содержит операцию, которая наверняка не определена. Они разрешены (и я предполагаю, что это так) в случаях, которые не являются базовым блоком, но где все ветви ведут к 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 равно null, тогда код имеет UB в любом случае, поэтому компилятор может предположить, что он не равен null и соответственно оптимизировать. Ядро 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%, тем не менее следует ожидать от качественных реализаций, которые не документируют противоположное поведение. Предположение о том, что реализации должны что-то делать, может рассматриваться как подразумевающее, что те, которые не являются низшими, и в тех случаях, когда обычно было бы очевидно, какое поведение было бы полезным или практичным, а не непрактичным и бесполезно, по данной реализации, было мало осознанной необходимости для стандарта вмешиваться в такие суждения.

умный компилятор может соответствовать стандарту, устраняя любой код, который не будет иметь никакого эффекта, кроме тех случаев, когда код получает входные данные, которые неизбежно вызовут неопределенное поведение, но "умный" и "тупой" не являются антонимами. Дело в том, что авторы стандарта решили, что могут быть некоторые виды реализаций, где поведение полезно в данной ситуации ситуация была бы бесполезной и непрактичной не подразумевает никакого суждения о том, следует ли считать такое поведение практичным и полезным для других. Если реализация может поддерживать поведенческую гарантию без каких-либо затрат, кроме потери возможности обрезки "мертвой ветви", почти любой код пользователя может получить от этой гарантии стоимость, превышающую стоимость ее предоставления. Устранение мертвой ветви может быть прекрасным в тех случаях, когда это не потребует отказа что-нибудь, но если в a в данной ситуации пользовательский код мог бы обрабатывать практически любое возможное поведение другое чем устранение мертвой ветви, любые усилия пользовательского кода придется потратить, чтобы избежать UB, скорее всего, превысит значение, полученное от DBE.