Skip to content

Latest commit

 

History

History
59 lines (42 loc) · 10.3 KB

what_is_ub.md

File metadata and controls

59 lines (42 loc) · 10.3 KB

Что такое неопределенное поведение и как оно проявляется

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

Состоит эта особенность в том, что в спецификации языка программирования сознательно не определяют поведение программы в каких-то особых условиях. Делается это из соображений производительности: не надо генерировать дополнительные инструкции с проверками. Или из соображений обеспечения гибкости при реализации каких-то фич. В спецификации пишут просто: «Если код делает что-то нехорошее, то поведение не определено». Например:

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

Важно, что это «поведение не определено» означает, что произойти может что угодно: форматирование диска, ошибка компиляции, исключение, а может и все будет хорошо. Никаких гарантий не дается. Отсюда и происходят веселые, неожиданные и очень печальные в production-коде последствия.

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


И так, по каким же признакам можно заподозрить UB в программе и насколько неопределенное поведение действительно неопределенное?

Когда-то давно UB в коде могло повлечь действительно что угодно. Например, gcc 1.17 начинал запускать игрушечки.

Сегодня, если вы поделите что-то на ноль, подобного почти наверное не произойдет. Однако неприятности все же бывают разные:

  1. Для данной конкретной платформы и компилятора в документации сказано что именно произойдет, несмотря на страшные слова «undefined behavior» в стандарте. И все будет хорошо. Вы знаете что делаете. Никакой неопределенности. Все классно.
  2. UB при работе с памятью чаще всего заканчиваются ошибкой сегментации и получением прекрасного сигнала SIGSEGV от операционной системы. Программа падает.
  3. Программа работает и штатно завершается. Но дает разные или неадекватные результаты от запуска к запуску. Также результаты меняются от сборки к сборке при изменении опций компилятора или самого компилятора. Никаких генераторов случайных чисел вы не использовали.
  4. Программа ведет себя неправильно, несмотря на то, что в коде наставлено огромное множество проверок, assert'ов, try-catch блоков, каждый из которых «подтверждает» что все корректно. В отладчике видно, что вычисления идут корректно, но совершенно внезапно все ломается.
  5. Программа выполняет код, который в ней есть, но не вызывался. Отрабатывают ни разу не вызываемые функции.
  6. Компилятор «без причины» и без падения отказывается собирать код. Линковщик выдает «невозможные и бессмысленные» ошибки.
  7. Проверки в коде перестают исполняться. Под отладчиком видно, что исполнение не заходит внутрь веток if или catch, хотя по значениям переменных заход должен быть выполнен.
  8. Внезапный необоснованный вызов std::terminate.
  9. Бесконечные циклы становятся конечными и наоборот.

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

  1. Еще одна страшная аббревиатура UB — неуточненное (unspecified) поведение. Стандарт не уточняет, что именно может произойти, но описывает варианты. Так, например, порядок вычисления аргументов функции — поведение неуточненное.
  2. Поведение, определяемое реализацией (implementation-defined) — надо смотреть документацию для вашей платформы и вашего компилятора.
  3. Ошибочное поведения (erroneous) — новинка C++26. Часть неопределенного поведения будет, возможно, переквалифицированна в эту категорию. Например, так поступили с чтением неинициализированных переменных. Разница с неопределенным — компилятору очень рекомендуется выдавать диагностики и запрещается выполнять умные оптимизации с неожиданными побочными эффектами.

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

Также выделяют два класса неопределенного поведения:

  • Неопределенное поведение на уровне библиотеки (Library Undefined Behavior): Вы сделали что-то что не предусматривается конкретной библиотекой (в том числе и стандартной, но не всегда). Например, библиотека gmock под страхом неопределенного поведения не допускает донастраивать mock-объект после начала его использования.
  • Неопределенное поведение на уровне языка (Language Undefined Behavior): Вы сделали что-то что фундаментально не определено спецификацией языка программирования. Например, разыменовани нулевой указатель.

Если вы столкнулись с первым — у вас проблемы, но если работает, то с очень большим шансом и продолжит работать пока вы не обновите библиотеку/не смените платформу. А побочные эффекты часто могут быть лишь локальными. Очень похоже на implementation defined поведение.

Если вы столкнулись со вторым — у вас большие проблемы. Код может перестать работать корректно совершенно внезапно при малейших изменениях. А также могут быть серьезные угрозы безопасности для пользователей вашего приложения.

Полезные ссылки

  1. https://stackoverflow.com/questions/2397984/undefined-unspecified-and-implementation-defined-behavior