Неопределённое поведение и правда не определено
Термином «неопределённое поведение» в языке C и C++ обозначают ситуацию, в которой буквально «чего только не бывает». Исторически, к неопределённому поведению относили случаи, когда прежние компиляторы для C (и архитектуры на нём) вели себя несовместимым образом, и комитет по разработке стандарта, в своей безграничной мудрости, решил ничего не решать по этому поводу (т.е. не отдавать предпочтение какой-то одной из конкурирующих реализаций). Неопределённым поведением также называли возможные ситуации, в которых стандарт, обычно столь исчерпывающий, не предписывал никакого конкретного поведения. У этого термина есть и третье значение, которое в наше время становится всё более актуальным: неопределённое поведение — это возможности для оптимизации. А разработчики на C и C++ обожают оптимизации; они настойчиво требуют, чтобы компиляторы прикладывали все усилия для ускорения работы кода.
Данная статья была впервые опубликована на сайте Cryptography Services. Перевод публикуется с разрешения автора Томаса Порнина (Thomas Pornin).
Вот классический пример:
Каждая из первых двух инструкций movupd перемещает два значения типа double в 128-битный регистр SSE2 (double имеет размер 64 бита, поэтому регистр SSE2 может хранить два значения типа double). Другими словами, сначала считываются четыре исходных значения, а уже потом они приводятся к int (операции cvttpd2dq). Операция punpcklqdq перемещает четыре полученных 32-битных целых значения в один регистр SSE2 (%xmm0), содержимое которого затем записывается в оперативную память (movups). А теперь главное: наша C-программа формально требует, чтобы доступ к памяти происходил в следующем порядке:
Смысл неопределённого поведения, если кратко, заключается в следующем: компилятор может допустить, что неопределённого поведения не будет, и сгенерировать код, исходя из этого допущения. В случае правила строгого алиасинга — при условии, что алиасинг имеет место быть, — неопределённое поведение позволяет проводить важные оптимизации, реализовать которые иначе было бы сложно. Если говорить в целом, каждая инструкция в процедурах генерации кода, используемых компилятором, имеет зависимости, ограничивающие алгоритм планирования операций: инструкция не может быть выполнена раньше тех инструкций, от которых она зависит, или после тех инструкций, которые зависят от неё. В нашем примере неопределённое поведение устраняет зависимости между операциями записи в dst[] и «последующими» операциями чтения из src[]: такая зависимость может существовать только в тех случаях, когда при доступе к памяти возникает неопределённое поведение. Точно также понятие неопределённого поведения позволяет компилятору просто удалять код, который не может быть выполнен без вхождения в состояние неопределённого поведения.
Всё это, конечно, хорошо, но такое поведение иногда воспринимается как вероломное предательство со стороны компилятора. Можно часто услышать такую фразу: «Компилятор использует понятие неопределённого поведения как предлог сломать мне код». Допустим, некто пишет программу, которая складывает целые числа, и опасается переполнения — вспомним случай с Bitcoin. Он может размышлять так: для представления целых чисел процессор использует дополнительный код, а значит если переполнение случится, то случится оно потому, что результат будет усечён до размера типа, т.е. 32 бит. Значит, результат переполнения можно предсказать и проверить тестом.
Наш условный разработчик напишет так:
Теперь попробуем скомпилировать этот код с помощью GCC:
Хорошо, вроде работает. Теперь попробуем другой компилятор, например, Clang (у меня версия 6.0.0):
Выходит, что, когда операция со знаковыми целыми типами приводит к результату, который не может быть представлен целевым типом, мы вступаем на территорию неопределённого поведения. Но ведь компилятор может допустить, что оно не происходит. В частности, оптимизируя выражение x > 0 && y > 0 && r
Undefined behavior ближе, чем вы думаете
Многие считают, что неопределённое поведение программы возникает из-за грубых ошибок (например, запись за границы массива) или на неадекватных конструкциях (например, i = i++ + ++i). Поэтому для многих является неожиданностью, когда неопределенное поведение вдруг проявляет себя во вполне привычном и ничем не настораживающем коде. Рассмотрим один из таких примеров. Программируя на C/C++ никогда нельзя терять бдительность. Ад ближе чем кажется.
Описание ошибки
Я давненько не поднимал тему 64-битных ошибок. Тряхну стариной. В данном случае неопределённое проведение будет проявлять себя в 64-битной программе.
Рассмотрим некорректный синтетический пример кода.
Этот код корректно работает, если собрать 32-битную версию программы. А вот если собрать 64-битный вариант программы, всё намного интересней.
64-битная программа выделяет массив байт размеров в 5 гигабайт и заполняет его нулями. Затем в цикле массив заполняется какими-то случайными числами, неравными нулю. Чтобы числа не были равны 0, используется «| 1».
Попробуйте угадать, как поведёт себя эта программа, собранная в режиме x64 с помощью компилятора, входящего в состав Visual Studio 2015. Заготовили ответ? Если да, то продолжим.
Логичное объяснение? Ничего подобного! Это неопределённое поведение и произойти может всё что угодно.
Но на самом деле они этого не знают. Если бы знали, они бы не говорили всякие глупости. Обычно глупости выглядят как-то так (собирательный образ):
Вы несете теоретический бред. Ну да, формально переполнение ‘int’ приводит к неопределенному повреждению. Но это не более чем болтовня. На практике, всегда можно сказать что получится. Если к INT_MAX прибавить 1, мы получим INT_MIN. Быть может и есть какие-то экзотические архитектуры, где это не так, но мой компилятор Visual C++ / GCC выдают корректный результат.
Так вот, сейчас я без всякой магии на простом примере продемонстрирую неопределённое поведение и не на какой-то волшебной архитектуре, а в Win64-программе.
Достаточно собрать приведённый выше пример в режиме Release x64 и запустить его. Программа перестанет падать, а сообщение «the last array element contains 0» выдано не будет.
Неопределенное поведение здесь проявило себя следующим образом. Массив будет полностью заполнен, не смотря, на то, что тип ‘int’ недостаточен для индексации всех элементов массива. Для тех, кто не верит, предлагаю взглянуть на ассемблерный код:
Вот оно проявление неопределенного поведения! И никаких экзотических компиляторов. Это VS2015.
Если заменить ‘int’ на ‘unsigned’ неопределённое поведение исчезнет. Массив будет заполнен только частично и в конце будет выдано сообщение «the last array element contains 0».
Ассемблерный код, когда используется ‘unsigned’:
Примечание про PVS-Studio
Анализатор PVS-Studio напрямую не диагностирует переполнение знаковых переменных. Это неблагодарное занятие. Почти невозможно предсказать, какие значения будут иметь те или иные переменные и произойдет переполнение или нет. Однако, он может заметить в этом коде ошибочные паттерны, которые он связывает с «64-битными ошибками».
На самом деле никаких 64-битных ошибок нет. Есть просто ошибки, например, неопределённое поведение. Просто эти ошибки спят в 32-битном коде и проявляют себя в 64-битном. Но если говорить про неопределённое поведение, то это не интересно, и никто покупать анализатор не будет. Да ещё и не поверят, что могут быть какие-то проблемы. А вот если анализатор говорит, что переменная может переполниться в цикле, и что это ошибка «64-битная», то совсем другое дело. Profit.
Приведенный выше код PVS-Studio считает ошибочным и выдаёт предупреждения, относящиеся к группе 64-битных диагностик. Логика следующая: в Win32 переменные типа size_t являются 32-битными, массив на 5 гигабайт выделить нельзя и всё корректно работает. В Win64 стало много памяти, и мы захотели работать с большим массивом. Но код отказал и даёт сбой. Т.е. 32-битный код работает, а 64-битный нет. В рамках PVS-Studio это называется 64-битной ошибкой.
Корректный код
Чтобы всё работало хорошо, надо использовать подходящие типы данных. Если вы собираетесь обрабатывать большие массивы, то забудьте про int и unsigned. Для этого есть типы ptrdiff_t, intptr_t, size_t, DWORD_PTR, std::vector::size_type и так далее. В данном случае пусть будет size_t:
Вывод
Если конструкция языка С++ вызывает неопределённое поведение, то она его вызывает и не надо с этим спорить или предсказывать как оно проявит себя. Просто не пишите опасный код.
Есть масса упрямых программистов, которая не хочет видеть ничего опасного в сдвигах отрицательных чисел, переполнении знаковых чисел, сравнивании this c нулём и так далее.
Не будьте в их числе. То, что программа сейчас работает, ещё ничего не значит. Как проявит UB предсказать невозможно. Ожидаемое поведение программы — это всего лишь один из вариантов UB.
Неопределённое поведение в C++
Ситуация, когда код на языке C++ синтаксически валиден, однако его поведение не определено в Стандарте, в русскоязычной литературе часто называют просто неопределённым поведением. В самом же Стандарте для таких ситуаций существуют целых 3 термина: undefined behavior, unspecified behavior и implementation-defined behavior. В этой коротенькой заметке мы будем разбираться, чем они отличаются.
Implementation-defined behavior
Этот термин применяется для описания ситуаций, когда C++ код полностью валиден, но его поведение зависит от реализации (например, компилятора или среды исполнения), и это поведение документировано. Например, размер в байтах указателя или типа int зависит от конкретной реализации или настроек компилятора, но это описано в документации, и на эту документацию можно полагаться.
Unspecified behavior
Термин означает, что поведение валидного C++ кода не определено Стандартом и зависит от реализации, к тому же не документировано (по крайней мере, официально). Пример: порядок вычисления значений аргументов функции определяется компилятором, но нигде нет описания, как именно. Стандарт говорит нам: это особенности поведения, которые не зафиксированы нигде, следовательно, на них нельзя полагаться. Поэтому и поведение вашего кода не должно зависеть от этих особенностей.
Undefined behavior
Это самый опасный вариант неопределённости. В Стандарте он служит для описания поведения, которое может привести к полностью непредсказуемым последствиям. Самые яркие примеры: обращение за границы массива или разыменование указателя на освобождённый объект. Хуже всего то, что программа совершенно не обязательно сразу же завершится или вообще выдаст какую-либо ошибку, тем не менее, на её поведение уже нельзя полагаться.
В заключение ещё раз напомню, что все вышеописанные термины относятся к синтаксически валидному коду, который будет успешно скомпилирован (впрочем, компиляторы зачастую выдают предупреждения для наиболее очевидных случаев undefined behavior). Код, невалидный с точки зрения Стандарта, называется ill-formed program.
Что каждый программист на C должен знать об Undefined Behavior. Часть 2/3
В первой части нашего цикла мы обсудили, что такое неопределённое поведение, и как оно позволяет компиляторам C и C++ генерировать более высокопроизводительные приложения, чем «безопасные» языки. В этом посте мы обсудим, чем на самом деле является «небезопасный» C, объяснив некоторые совершенно неожиданные эффекты, вызываемые неопределённым поведением. В третьей части, мы обсудим, как «дружественные» компиляторы могут смягчить некоторые из таких эффектов, даже если они не обязаны это делать.
Мне нравится называть это «Почему неопределённое поведение часто пугает и ужасает программистов на C».

Взаимодействие оптимизаций компилятора приводит к неожиданным результатам
Современный оптимизирующий компилятор содержит множество оптимизаций, работающих в определённом порядке, иногда повторяющихся по несколько раз, и этот порядок может изменяться по мере развития компилятора (т.е. при выходе новых релизов).
Также различные компиляторы имеют существенно разные оптимизаторы. Так как оптимизации работают как различные стадии преобразования кода, могут возникать различные эффекты, когда предшествующие стадии изменили код.
Рассмотрим такой дурацкий пример (упрощенный из примера реального бага в ядре Linux), чтобы сделать это более конкретным:
В этом примере, код «в явном виде» производит проверку на нулевой указатель. Если компилятор запустит проход удаления недостижимого кода («Dead Code Elimination») перед проходом удаления избыточных проверок на null («Redundant Null Check Elimination»), то мы увидим, что будет выполнено два преобразования кода:
Однако, оптимизатор может быть структурирован иначе, и может запустить RNCE перед DCE. Тогда будут выполнены следующие два преобразования:
и затем удалён избыточный код:
Для многих программистов, удаление проверки на null из функции было бы очень неожиданным (и они обвинят в баге компилятор). Однако, оба варианта, и «contains_null_check_after_DCE_and_RNCE», и «contains_null_check_after_RNCE_and_DCE», являются совершенно верной оптимизированной формой «contains_null_check» в соответствии со стандартом, и обе оптимизации важны для повышения производительности различных приложений.
Хотя это довольно простой и надуманный пример, такие вещи всё время происходят при инлайне функций. Инлайн функции открывает множество возможностей для последующих оптимизаций. Это означает, что если оптимизатор решит инлайнить функцию, будут произведены и другие локальные оптимизации, изменяющие поведение кода. Это совершенно правильно, как с точки зрения стандарта, так и с практической точко зрения, для повышения производительности.
Неопределённое поведение и безопасность не должны смешиваться
Семейство C-подобных языков программирования используется для широкого диапазона критического безопасного кода, такого как ядра, демоны setuid, web-браузеры, и пр. Этот код работает с «враждебными» входными данными и баги в нём могут привести к любого сорта проблемам в безопасности. Одно из наиболее известных преимуществ С в том, что относительно легко понять, что происходит, просто читая код.
Однако, неопределённое поведение лишает язык такого свойства. Например, большинство программистов будут считать, что «contains_null_check» в примере выше производит проверку на null. Хотя этот пример не такой страшный (этот код может что-то разрушить, если ему будет передан null, что относительно легко обнаружить при отладке) есть большое количество вполне разумно выглядящих фрагментов кода на C, которые на самом деле совершенно неверны. Эта проблема касается многих проектов (включая Linux Kernel, OpenSSL, glibc, и т.п.) и даже вынудило CERT опубликовать уведомление об уязвимости GCC (хотя лично я считаю, что все широко используемые оптимизирующие компиляторы C уязвимы, не только GCC).
Рассмотрим пример. Представим тщательно написанный код на С:
Этот код вполняет проверку, чтобы убедиться, что будет аллоцировано достаточно памяти для чтения из файла (так как нужно добавить терминирующий нуль), и завершает работу, если происходит переполнение целого. Однако, в данном примере, компилятор может (в соответствии со стандартом) удалить проверку. Это означает, что компилятор может превратить код в такой:
Когда компиляция происходит на 64-битной платформе, есть вероятность возникновения бага, когда «size» равен INT_MAX (возможно, это размер файла на диске). Посмотрим, насколько это ужасно: при прверке кода не обнаруживается ничего, так как проверка переменной на переполнение выглядит разумно. При тестировании кода не обнаруживается проблем, если специально не тестировать этот путь выполнения. Кажется, код может считаться безопасным, пока кто-то не решит эксплуатировать уязвимость. Это очень неожиданный и довольно ужасный класс багов. К счастью, его просто починить: просто используйте «size == INT_MAX» или что-то аналогичное.
Отладка оптимизированного кода может стать бессмысленной
Некоторые люди (например, низкоуровневые embedded-программисты, которые любят смотреть генерированный машинный код) работают с постоянно включенной оптимизацией. Так как код часто имеет баги в начале разработки, эти люди наблюдают непропорционально много неожиданных оптимизаций, которые могут привести к трудноотлаживаемым проблемам при выполнении программы. Например, случайно пропустив «i = 0» в примере «zero_array» в примере из первой статьи, мы разрешаем компилятору полностью удалить цикл (превратив zero_array в «return;») потому что это будет использованием неинициализированной переменной.
Другой интересный случай может произойти, когда есть глобальный указатель на функцию. Упрощённый пример выглядит так:
который clang оптимизирует в:
Он может так сделать, потому что вызов нулевого указателя не определён, что разрешает предполагать, что set() должно быть вызвано перед call(). В этом случае, разработчик забыл вызвать set(), программа не падает на разыменовании null, и код сломается, если кто-то другой сделает дебажный билд.
«Работающий» код, использующий неопределённое поведение, может сломаться, если в компиляторе что-то изменится.
Мы рассмотрели множество случаев, когда код, который «вроде бы работает» внезапно ломается когда для компиляции используется более новая версия LLVM, или когда приложение перенесено с GCC на LLVM. Хотя LLVM и сам по себе может иметь один или два бага, чаще всего это случается из-за того, что скрытые баги в приложении проявились благодаря компилятору. Это может случится в множестве различных случаев, вот два примера:
1. неинициализированная переменная, которая принимала ранее нулевое значение по счастливой случайности, а теперь помещена в другой регистр, который не содержит ноль. Такое поведение часто проявляется при изменениях в аллокаторе регистров.
2. Переполнение массива на стеке затирает актуальные переменные вместо «мёртвых». Это происходит, когда компилятор переупорядочивает переменные на стеке, или более агрессивно упаковывает в пространство стека переменные с неперекрывающимся временем жизни.
Важная и пугающая вещь, обнаружить, что практически любая оптимизация, основанная на неопределённом поведении, может привести к багам в любой момент в будущем. Инлайн функций, разворачивание циклов, и другие оптимизации будут работать лучше, и существенная часть их делается за счёт вторичных оптимизаций, как показано выше.
Меня это очень огорчает, отчасти из-за того, что практически неизбежно начинают обвинять компилятор, а также из-за того, что огромное количество С-кода является миной замедленного действия, ждущей, чтобы взорваться. И это ещё хуже, потому что…
Не существует надёжного способа убедиться в том, что большая кодовая база не содержит UB
Это очень плохая ситуация, потому что фактически не существует надёжного способа определить, что в крупномасштабном приложении нет UB, и что оно не сломается в будущем. Есть много полезных инструментов, которые могут помочь найти некоторые баги, но ничто не даёт полной уверенности, что ваш код не сломается в будущем. Давайте рассмотрим некоторые варианты, их сильные и слабые стороны.
1. Valgrind — это фантастический инструмент для поиска всех видов неинициализированных переменных и других багов с памятью. Valgrind ограничен тем, что он достаточно медленный, и может искать только те баги, которые уже существуют в сгенерированном машинном коде (и не может найти то, что было удалено оптимизатором), и не знает о том, что исходник был написан на С (и поэтому не может найти баги типа сдвига на величину, превышающую размер переменной, или переполнения знакового целого числа).
3. Сообщения компилятора хороши для поиска некоторых классов багов, таких, как неинициализированные переменные и простые переполнения целых. Есть два главных ограничения: 1) нет динамической информации об исполнении кода и 2) анализ должен быть очень быстрым, потому что любой анализ увеличивает время компиляции.
4. Статический анализатор Clang производит гораздо более глубокий анализ, пытаясь найти баги, включая использование UB, такое, как разыменование null-указателя.
Вы можете думать о нём как об усиленном средстве анализа по сравнению с предупреждениями компилятора, так как он не имеет временных ограничений, как обычные предупреждения. Основной недостаток статического анализатора в том, что он: 1) не имеет динамической информации о процессе работы программы и 2) не интегрирован в обычный процесс разработки (хотя его интеграция с XCode 3.2. и более поздними — это фантастика).
5. Субпроект LLVM «Klee» использует символьный анализ, чтобы «попробовать каждый возможный путь» по коду для нахождения багов в коде и генерации теста. Это замечательный маленький проект который главным образом ограничен тем, что его непрактично запускать на больших приложениях.
6. Хотя я никогда его не пробовал, инструмент C-Semantics от Чаки Эллисона и Григори Росу очень интересен тем, что может найти некоторые классы багов (таких, как нарушения точек следования). Он по-прежнему находится в состоянии исследовательского прототипа, но может быть полезен для нахождения багов в (маленьких и ограниченных) программах. Я рекомендую к чтению пост Джона Реджера для того, чтобы получить больше информации.
Итак, мы имеем множество инструментов для поиска багов, но нет хорошего способа доказать, что в приложении нет UB. Представьте, что существуют тонны багов в реальных приложениях, и что С используется в широком спектре критических приложений, и это пугает. В нашей последней статье, я рассмотрю различные опции, которые имеет компилятор C, для того, чтобы обрабатывать UB, особенно уделив внимание Clang-у.
Неопределенное поведение в C++
Достаточно сложной темой для программистов на С++ является undefined behavior. Даже опытные разработчики зачастую не могут четко сформулировать причины его возникновения. Статья призвана внести чуть больше ясности в этот вопрос.
Статья является ПЕРЕВОДОМ нескольких статей и выдержек из Стандарта по данной теме.
Что такое «точки следования»?
Точки следования (sequence points)– такие точки в процессе выполнения программы, в которых все побочные эффекты уже выполненного кода закончили свое действие, а побочные эффекты кода, подлежащего исполнению, еще не начали действовать. (§1.9/7)
Побочные эффекты? А что такое «побочные эффекты»?
Побочный эффект (side effect) (согласно Стандарту) – результат доступа к volatile объекту, изменения объекта, вызова функции из библиотеки I/O или же вызова функции, включающей в себя какие-то из этих действий. Побочный эффект является изменением состояния среды выполнения.
Вычисление некоторого выражения дает на выходе какой-то результат. Если же в дополнение к результату вычисление выражения вызывает изменения в среде выполнения, то говорят, что данное выражение имеет побочные эффекты.
В дополнение к операции инициализации переменной «x» значение переменной «y» изменилось из-за побочного эффекта оператора ++.
Что ж, с этим понятно. Далее к точкам следования. Альтернативное определение понятия «точка следования» дано Стивом Саммитом (автор книг «Язык C в вопросах и ответах», блога «comp.lang.c»):
Точка следования – момент времени, когда «пыль улеглась», и все встреченные побочные эффекты гарантированно завершены и остались позади.
Какие точки следования описаны в Стандарте C++?
В стандарте описаны следующие точки следования:
1. a && b (§5.14)
2. a || b (§5.15)
3. a? b: c (§5.16)
4. a, b (§5.18)
Вычисление этих выражений идет слева направо. После вычисления левого подвыражения все побочные эффекты этого вычисления прекращают действие. Если после вычисления левого подвыражения значение полного выражения известно, то правая часть не вычисляется. В последнем случае имеется в виду оператор запятая. В функции func(a, a++) запятая – не оператор, а просто разделитель между аргументами.
Что такое «неопределенное поведение»?
Неопределенное поведение (undefined behavior)– поведение, которое может возникать в результате использования ошибочных программных конструкций или некорректных данных, на которые Международный Стандарт не налагает никаких требований. Неопределенное поведение также может возникать в ситуациях, не описанных в Стандарте явно.
Иными словами, неопределенное поведение означает что угодно, что может произойти, начиная от козявки, выпавшей из носа, заканчивая беременностью вашей девушки.
Какая связь между неопределенным поведением и точками следования?
Неуточняемое поведение (unspecified behavior) (согласно Стандарту) – поведение, для которого Стандарт предлагает два или более возможных вариантов и не налагает четких требований на то, какой из них должен быть выбран в определенной ситуации.
Поведение, определяемое реализацией (implementation-defined behavior) (согласно Стандарту) – поведение правильно сформированной программной конструкции с правильными данными, которое зависит от реализации (должно быть документировано для каждой реализации).
Пример такого поведения – размер указателя. В соответствии со Стандартом, размер указателя зависит от конкретной реализации компилятора. В рамках одной конкретной реализации размер указателей различных типов также может быть различным.
Также хочу отметить, что порядок вычисления операндов конкретного оператора, подвыражений конкретного выражения, и порядок возникновения побочных эффектов не уточнены.
В §5/4 Стандарт говорит:
Между двумя точками следования скалярный объект должен менять хранимое значение при вычислении выражения не более одного раза.
Говоря чуть проще, переменную между двумя точками следования нельзя менять больше одного раза. В выражении следующая точка следования обычно располагается на заключающей точке с запятой, а предыдущая – в конце предшествующего оператора. Выражение так же может содержать промежуточные точки следования.
Исходя из вышесказанного, следующие выражения создают неопределенное поведение:
Кроме того (по Стандарту) – старое значение выражения (до вычисления) должно быть доступно только для определения хранимого значения.
Это значит, что некорректными являются те выражения, в которых доступ к значению может предшествовать его модификации.
Я слышал, что в C++0x нет никаких Точек Следования, это правда?
Да, это правда.
Понятие «точка следования» было заменено комитетом ISO C++ на уточненное и дополненное понятие Отношения Следования [ДО\ПОСЛЕ].
Что такое Отношение Следования[ДО]?
Формально, это означает, что при двух данных выражениях А и B, если А [следует ДО] B, то выполнение А должно предшествовать выполнению В. Если же А не [следует ДО] В, тогда выполнение А и В является неупорядоченным (unsequenced) (выполнение неупорядоченных вычислений может пересекаться).
Вычисление A и В являются неопределенно упорядоченным (indeterminantly sequenced), когда либо А [следует ДО] В, либо В [следует ДО] А, но что именно – не уточнено. Неопределенно упорядоченные вычисления не могут пересекаться, но любое из них может выполнятся первым.
Стандарт говорит нам (§1.9/14):
Каждый подсчет значения и побочный эффект, связанные с полным выражением, [следуют ДО] подсчета значения и побочного эффекта, связанных со следующим полным выражением, которое будет вычислено.
В данном примере подсчет значения и побочный эффект, связанный с выражением (++x), [следует ПОСЛЕ] подсчета значения и побочного эффекта (x=10).
Ведь между вещами, описанными выше, и неопределенным поведением должна быть какая-то связь, да?
Конечно, связь есть.
В §1.9/15 упоминается, что:
Вычисление операндов конкретного оператора или подвыражений конкретного выражения неупорядоченно, помимо случаев, которые были описаны ранее.
Примечание: неупорядоченные и неопределенно упорядоченные подвыражения полного выражения, которое вычисляется более одного раза в процессе выполнения программы, не обязательно вычисляются каждый раз в одном и том же порядке.
1) Вычисление операндов оператора «+» неупорядоченно.
2) Вычисление операндов операторов « >» неупорядоченно.
§1.9/15 подсчет значения операндов конкретного оператора [следует ДО] подсчета значения результата работы оператора.
Это означает, что в выражении x + y подсчет значений «х» и «у» [следует ДО] подсчета x+y.
Теперь к более важному:
Поясним это выражение. На первый взгляд, неупорядоченность вычисления аргументов функции не повлечет за собой неоднозначности. Однако нет точной вероятности, что компилятор, оптимизируя подобное выражение, не создаст набор инструкций, аналогичный по действию (по его мнению), который даст сбой при операции над одной и той же памятью.
Предположим, что компилятор решил, что оптимальнее всего присвоить «-1» будет обнулив переменную и сделав ее декремент.
Инструкции могут сформироваться так (команды условны):
При вызове функции каждый подсчет значения и побочный эффект, связанный с выражением аргумента этой функции, или с выражением, вызывающим функцию, [следует ДО] выполнения любого выражения или оператора в теле вызываемой функции.
Подсчет значения и побочные эффекты, связанные с разными аргументами, неупорядоченны.
Поток выполнения программы
Оперируя терминами, расшифрованными ранее, поток выполнения программы можно представить графически. В следующих далее диаграммах обозначим вычисление выражения (или подвыражения) как E(x), точку следования — %, побочный эффект «k» для объекта «e» обозначим S(k,e). Если для вычисления необходимо считать значение из именованного объекта (пусть «x» — имя), вычисление будем обозначать V(x), в остальных случаях – так же, как договаривались ранее, E(x). Побочные эффекты запишем справа и слева от выражений. Граница между двумя выражениями обозначает, что верхнее выражение вычисляется до нижнего выражения (зачастую потому что нижнее выражение зависит от prvalue или lvalue верхнего выражения).
Для двух выражений i++; i++; диаграмма будет иметь вид:
Как видите, в данном случае мы имеем две точки следования, одна из которых разделяет два изменения «i».
Вызовы функций также представляют интерес, несмотря на то, что диаграмму для них мы опустим:
Этот код корректный, потому что к тому времени, как начнет выполняться тело функции f, все побочные эффекты, порожденные вычислением аргументов, гарантированно закончатся: «с» и «d» будут увеличены на 1.
Теперь рассмотрим выражение i++ * j++;
Откуда же появилось две ветки? Напомним, что точки следования завершают вычисления, проводившиеся ДО их наступления. Все подвыражения умножения вычисляются до самого умножения, больше в этом выражении нет точки следования, следовательно, нам нужно принять во внимание теоретическую «параллельность» вычисления операндов, чтобы предположить, где может произойти конкурентное изменение одного и того же объекта. Говоря более формально, эти две ветви неупорядочены. Точки следования – это отношение, которое упорядочивает некоторые вычисления и не упорядочивает другие. Т.о. точки следования, как и говорилось выше, являются частичным упорядочиванием (partial order).
Конфликтующие побочные эффекты.
Чтобы обеспечить компилятору свободу в генерации и оптимизации машинного кода, в случаях, подобных рассмотренному выше умножению, не устанавливается порядок вычисления подвыражений и не разделяются побочные эффекты, порожденные ими (за исключением описанных ранее случаев).
Это может вести к конфликтам, поэтому Стандарт называет неопределенным поведение программы, если она пытается модифицировать один и тот же объект без участия точек следования. Это относится к скалярным объектам, потому что остальные объекты являются либо неизменяемыми (array) или попросту не подпадают под это правило (class objects). Неопределенное поведение также возникает, если в выражении присутствуют обращение к предыдущему значению объекта и его модификация, как например в i * i++
В качестве исключения позволено считывать значение объекта, если оно необходимо для подсчета нового значения. Пример контекста: i = i+1
Здесь мы видим обращение к «i» в правой части; после вычисления обеих частей совершается присваивание. Т.о. побочный эффект и обращение к «i» происходят, не пересекая точку следования, но обращались к «i» мы только для определения хранимого значения, поэтому разногласий не будет.
Иногда, значение считывается после модификации. Для случая
a = (b = 0);
справедливо, что происходит запись в «b», а потом чтение из «b» без пересечения точки следования. Тем не менее, это нормально, потому что считывается уже новое значение «b», а обращения к старому не происходит. В этом случае побочные эффекты присвоения «b» закончат свое действие не только до следующей точки следования, но и перед чтением значения «b», требуемого для присвоения «а». Стандарт явно говорит: «результатом операции присваивания является значение, хранимое в левом операнде, после того, как присваивание выполнено (результат — lvalue)». Почему не используется понятие точки следования? Потому что это понятие содержит ненужное в данной ситуации требование, чтобы все побочные эффекты левого и правого операнда были завершены, вместо того чтобы рассматривать только побочные эффекты присвоения, возвращающего lvalue, с помощью которого происходит считывание.




