Неопределённое поведение (UB) – непростая концепция в языках программирования и компиляторах. Я слышал много заблуждений о том, что гарантирует компилятор при наличии UB. Это печально, но неудивительно!
Мы опубликовали и перевели эту статью с разрешения правообладателя. Автор статьи — Predrag Gruevski. Оригинал опубликован на Predrag's Blog.
Для ознакомления с неопределённым поведением и с тем, почему мы не можем просто "определить все виды поведения", я настоятельно рекомендую доклад Чендлера Каррута "Garbage In, Garbage Out: Arguing about Undefined Behavior with Nasal Demons".
Возможно, вы также знакомы с моей серией блогов Compiler Adventures, где рассказывается о том, как работают оптимизации компилятора. Предстоящий эпизод будет посвящён внедрению оптимизаций, использующих преимущества неопределённого поведения, такого как деление на ноль. В нём мы рассмотрим UB "с другой стороны".
Неопределённое поведение также не является тем же самым, что и неуточнённое поведение (unspecified behavior), которое похоже на поведение, определяемое реализацией (implementation-defined behavior), за исключением требования о том, что реализация должна документировать своё поведение и следовать ему. В статье мы уделяем внимание неопределённому поведению, а не неуточнённому, поэтому мы объединим неуточнённое поведение и поведение, определяемое реализацией.
Поведение программы делится на три группы, а не на две:
Вот список гарантий, которые компиляторы дают в отношении результатов неопределённого поведения:
Это весь список. Нет, я ничего не забыл. Да, серьёзно.
Можно проанализировать, как UB влияет на конкретную программу при компиляции конкретным компилятором или при выполнении на конкретной целевой платформе. Например, существуют "экзотические" компиляторы, операционные системы и аппаратное обеспечение (например, CHERI, с потрясающими возможностями относительно безопасности использования указателей). Они предоставляют дополнительные гарантии по сравнению с большинством распространённых платформ, гарантирующих изоляцию процессов только на уровне ОС. Мы не будем говорить о них в этой статье.
Посыл этой статьи следующий: "Если моя программа содержит UB, а компилятор создал двоичный файл, который делает X, является ли это ошибкой компилятора?"
Это не ошибка компилятора.
1. Неопределённое поведение "происходит" только на высоких уровнях оптимизации, таких как -O2 или -O3.
2. Если я отключу оптимизацию флагом -O0, то UB не будет.
3. Если я включу отладочные символы в сборку, UB не будет.
4. Если я запущу программу под отладчиком, UB не случится.
5. Ладно, всё ещё присутствует вероятность UB, но моя программа "отработает правильно", несмотря ни на что.
6. Программа либо "отработает правильно", либо завершится с ошибкой сегментации (сигнал SIGSEGV).
7. Программа либо "отработает правильно", либо упадёт тем или иным способом.
8. Программа либо "отработает правильно", либо упадёт, либо зависнет.
9. По крайней мере UB не запустит случайный код из другой части программы.
10. По крайней мере UB не запустит какой-либо недостижимый код, который может содержать программа.
11. Если строка с UB ранее "отрабатывала правильно", то она продолжит это делать при следующем запуске программы.
12. Строка с UB по крайней мере будет продолжать "отрабатывать правильно", пока программа еще работает.
13. Можно определить, содержалось ли неопределённое поведение в предыдущей строке, и предотвратить возникновение проблем.
14. По крайней мере влияние UB ограничено кодом, который использует значения, полученные в результате UB.
15. По крайней мере влияние UB ограничено кодом, который находится в той же единице компиляции, что и строка с UB.
16. Хорошо, но по крайней мере влияние UB ограничено кодом, который выполняется после строки с UB. UB явно может менять поведение другого кода, включая даже операции, предшествующие ему! "Менять" здесь включает повреждение, откат или вообще предотвращение работы (как будто этого никогда не происходило) другого кода. Более подробную информацию и примеры неопределённого поведения, которые могут привести к "путешествиям во времени", можно найти в посте этого блога. Спасибо этим двум постам на Reddit (1, 2) за то, что предложили более удачные формулировки для пунктов выше.
17. По крайней мере оно не приведёт к повреждению памяти программы.
18. По крайней мере оно не приведёт к повреждению памяти программы, кроме той, где находились данные, затронутые UB.
19. По крайней мере оно не повредит кучу.
20. По крайней мере оно не повредит стек.
21. По крайней мере оно не повредит текущий фрейм стека (я называю это ошибкой под названием "локальные переменные надежно хранятся в регистрах").
22. По крайней мере оно не приведёт к повреждению указателя стека.
23. По крайней мере оно не повредит регистр флагов процессора или любое другое состояние процессора.
24. По крайней мере неопределённое поведение не приведёт к повреждению исполняемой памяти программы.
С функциями безопасности ОС и оборудования, такими как W ^ X, такой исход маловероятен, однако самомодифицирующиеся программы могут быть построены так, что UB в принципе может привести к повреждению исполняемой памяти. Конечно, нет никакой гарантии, что UB не сделает этого!
25. По крайней мере это не повредит таким потокам, как stdout или stderr.
26. По крайней мере UB не приведёт к перезаписи файлов, которые уже были открыты программой.
27. По крайней мере оно не будет открывать новые файлы и перезаписывать их.
28. По крайней мере оно не приведёт к полному удалению данных с диска.
29. По крайней мере UB не повредит и не уничтожит какие-либо компоненты оборудования. Не все устройства имеют одинаковый уровень защиты от неправильных вводных данных, записанных в регистры управления. Это тот урок, который обычно усваивается на горьком опыте.
30. По крайней мере UB не начнёт воспроизводить Doom, если в программе еще не было исходного кода Doom. Я был бы весьма впечатлён, если бы вы создали компилятор, который заставляет программы запускать Doom, когда они сталкиваются с UB. Считайте это вызовом!
31. Если программа, содержащая UB, ранее "работала прекрасно", перекомпиляция программы без каких-либо изменений кода всё равно приведёт к созданию двоичного файла, который "прекрасно работает".
32. Перекомпиляция без изменений кода тем же компилятором и с теми же флагами приведёт к созданию двоичного файла, который по-прежнему "прекрасно работает".
33. Перекомпиляция в условиях, как указано выше, + на том же компьютере приведёт к созданию двоичного файла, который по-прежнему "прекрасно работает".
34. Если вы не перезагружали компьютер с момента последней компиляции, то перекомпиляция в условиях, указанных выше, создаст двоичный файл, который по-прежнему "прекрасно работает".
35. Перекомпиляция в условиях, указанных выше, + с теми же переменными окружения приведёт к созданию двоичного файла, который по-прежнему "прекрасно работает".
36. Перекомпиляция в условиях, указанных выше, + в то же время суток и в тот же день недели, что и раньше, во время лунного затмения, после принесения в жертву новой порции оперативной памяти богам двоичных файлов, создаст двоичный файл, который всё ещё "прекрасно работает".
37. Многократные запуски программы, скомпилированной в условиях, указанных выше, с одинаковыми входными данными будут приводить к одному и тому же поведению при каждом запуске.
38. Эти многократные запуски приведут к одному и тому же поведению, если программа, не учитывая UB, является детерминированной.
39. Эти же запуски приведут к одному и тому же поведению и в случае, если программа является однопоточной.
40. Результат будет тот же, если программа не считывает никаких внешних данных (файлы, сеть, переменные окружения и т. д.).
41. Использование отладчика в программе, содержащей UB, покажет состояние программы, соответствующее исходному коду. Это следствие заблуждения № 16, оно более подробно объясняется в этом посте. Неопределённое поведение может изменить поведение программы как до, так и после UB. Поэтому исходный код, который вы видите в своём редакторе, уже не соответствует фактически выполняемой программе. Конечно, всё ещё можно использовать отладчик для пошагового выполнения ассемблерных инструкций и просмотра состояния регистра. Но начнем с того, что хорошо оптимизированный ассемблерный код нелегко понять и странности, вызванные UB, только усложнят задачу. В целом это ситуация, которой лучше избегать. Почитать можно тут.
42. Неопределённое поведение — это исключительно явление времени выполнения. В Rust контрпримером является неправильное использование #[no_mangle] для перезаписи символа с неправильным типом. Контрпримером в C++ являются нарушения правила одного определения (ODR), о некоторых из которых компилятор не обязан сообщать, прежде чем вызвать хаос.
43. Это любое разумное или неразумное поведение, которое происходит с какой-либо последовательностью или какой-либо гарантией.
В тот момент, когда ваша программа содержит UB, все ставки отменяются. Даже если это всего лишь одно маленькое неопределённое поведение. Даже если оно никогда не будет выполнено. Даже если вы вообще не знаете, что оно там есть. Возможно, даже если вы сами написали спецификацию языка и компилятор (говорю по собственному опыту. Надеюсь, вам не придётся его пережить, чтобы поверить).
Это не означает, что все исходы в приведённом выше списке одинаково вероятны или даже правдоподобны (особенно вариант с запуском Doom). Но все они являются разрешёнными, допустимыми, соответствующими спецификации поведениями.
Вполне возможно, что у вашей программы есть UB, и она работает нормально в течение многих лет без проблем. Это здорово! Я счастлив это слышать! Я даже не говорю, что вам нужно взять и переписать её, чтобы исключить неопределённое поведение. Но, принимая решения, полезно знать полную картину того, что компилятор будет или не будет гарантировать для вашей программы.
"Если программа компилируется без ошибок, значит, у неё нет неопределённого поведения".
В языках C и C++ это на 100 % неверно.
Это также ложное утверждения для Rust, хотя оно может оказаться правдой с одним условием. Если в вашей программе на Rust никогда не используется unsafe, то в ней не должно быть неопределённого поведения. Другими словами: вызов UB без unsafe считается ошибкой в компиляторе Rust. Эти случаи редки, и вы вряд ли столкнётесь с ними.
Когда в Rust используется unsafe, то все ставки отменяются точно так же, как в C или C++. Но предположение о том, что "программы на Rust, которые компилируются без ошибок, не имеют UB", в основном верно.
Мы в долгу перед теми людьми, которые в совокупности вложили столетия инженерной мысли в то, чтобы сделать Rust таким. Это нелёгкий труд. Спасибо, что прочитали статью!
Спасибо arriven, Конраду Ладгейту, sharnoff, Брайану Грэму и нескольким людям, пожелавшим остаться неназванными, за отзывы о черновиках этого поста. Любые ошибки, допущенные в статье, принадлежат только мне.