Pull to refresh

Ещё раз о неопределённом поведении или «почему не стоит забивать гвозди бензопилой»

Reading time8 min
Views37K
Про неопределённое поведение писали не раз. Приводились цитаты из стандартов, объяснения их интерпретации, разного рода поучительные примеры, но, похоже, все люди, пытавшиеся об этом писать пропускали важный пункт: по-моему никто внятно так и не удосужился объяснить — откуда это понятие в языке, собственно, появилось, и, главное, кому оно адресовано.

Хотя на самом-то деле, если вспомнить историю Си, всё достаточно очевидно и, главное, логично. А все жалобы людей, «обжёгшихся» на неопределённом поведении для людей не забывших что такое Си и зачем он вообще существует звучат примерно как: «я тут гвозди бензопилой забивал… забивал и забивал, всё было хорошо, а потом я дёрнул за ручку и у неё коготки как забегают, задёргаются, мне руку оттяпало и полноги… ну кто так строит?».

Люди, которые знают что такое бензопила пытаются, конечно, объяснить, что за если за эту рукоятку дёрнуть, то так, в общем-то, и должно быть, но люди, считающие, что у них у руках такой себе молоток говорят «мимо» них, и, в результате, все остаются при своих.

Так какой же важный секрет люди упускают из виду?

Ответ прост: они забывают зачем это язык вообще существует и какие задачи решает.

Давайте вспомним что вообще такое Си и для чего он, собственно, изобретён. Все знают что это низкоуровневый язык. Все знают что это не переносимый язык (это вам не Java, да). Но многие забывают (или просто не задумываются) над тем что это за язык и зачем он, собственно, был придуман. Вернее какие-то базовые факты знают все (ах, да — UNIX, K&R — ох, конечно, ANSI и ISO — где-то слышал), но простейшие следствия из этих фактов как-то совершенно упускаются из виду. Что ведёт к совершенно жутчайшему непониманию места Си (и стандартов ANSI C и ISO C) в этом мире.

Так вот. Язык Си был создан, в общем-то, для одной-единственной цели: для написания операционной системы UNIX. То есть низкоуровневого кода. Но, минуточку, если вы таки пойдёте по ссылке в Википедию, то обнаружите, что UNIX — она не только многозадачная и многопользовательская, но она ещё и переносимая.

И вот именно это-то сочетание и подводит нас к неопределённому поведению.

Действительно: как можно сделать какую-то программу переносимой? Очевидный вариант: написать её на переносимом языке, в описании которого подробно описать всё, что делают разные операторы, как обрабатывается переполнение, обращение к неинициализированному указателю и прочее, прочее. Ну так, как делает Java или ECMAScript. Но это, как вы понимаете, неэффективно. Вам придётся в куче мест в коде проверки вставлять, какие-то места станут жутко неэффективными «на ровном месте» (сдвиг 1 на 129 на x86 даёт 2, а на ARM'е даёт 0, так что как бы вы ни определили значение выражения «1 << 129» вам на одном из процессоров придётся генерировать неэффективный код) и т.д. и т.п. Не самый лучший вариант для ядра OS. Да и вообще — что это за «низкоуровневый язык» где простейшие вещи транслируются в 10 команд ассемблера (и хорошо если в 10, а не в 100)?

Разработчики UNIX (и Си) пошли другим путём. Язык они сделали непереносимым, а вот написанный на нём UNIX — очень даже переносимым. Как этого добились? Запретами. Сдвиг на сильно большие величины на разных процессорах даёт разные результаты? Значит использовать такие сдвиги в программе нельзя. Сравнение указателей, ведущих в разные пересекающиеся сегменты в памяти дорого и сложно (вспомните 8086)? Значит использовать такие сравнения в программах нельзя. Один процессор при переполнении выбрасывает исключения, а другой «тихо» порождает отрицательное число? Значит использовать такую конструкцию в программе нельзя. И т.д. и т.п. Таких правил, призванных обеспечить переносимость кода накопились десятки и сотни.

Заметьте: эти правила — ориентированы на программиста, ни в коем случае не на разработчика компилятора. Это программисту нужен переносимый код, не компилятору. А для компилятора — эти правила, наоборот, дают простор для реализации. Если у нас сложение порождает отрицательные числа, а вычитание — бросает исключение, то что нам делать? Да неважно: программист же должен всё равно позаботится о том, чтобы такого не происходило! Именно это приводит к тому, что UNIX (и его «идейный наследник» Linux) поддерживают совершенно невероятное число платформ. И многие программы, написанные на этом непереносимом языке тоже работают в куда большем числе мест, чем программы написанные «на истинно переносимых языках» типа С# или Java. Дело в том, что переносимость в случае использования C# или Java возлагается в первую очередь на компилятор, а вот в Си — совсем не на него, а вовсе даже на программиста. И если программист всё делает аккуратно, то программа на Си заработает даже на таких платформах куда Java в принципе «не пролезет».

После того, как Си начал использоваться в куче разных мест помимо ядра UNIX люди собрались и написали стандарт (на самом деле и не один: их несколько, но они основываются друг на друге). И важной частью работы над этим стандартом был сбор и классификация всех этих запретов, налагаемых на программиста. Иначе как бы переносимости с одного компилятора на другой не добиться.

Например: можно ли использовать указатели, не указывающие «внутрь» массива? Вообще говоря нельзя, так как бывают всякие iAPX 432 где это всё контролируется аппаратно. Но иногда очень хочется. Вспомните begin и end из C++. Полуинтервал — он удобен. С зубовным скрежетом было решено, что да, один элемент «сверху» все реализации должны предоставлять. Вернее не сам элемент, а его адрес: обращаться к самому элементу лежащему «за краем массива», разумеется, нельзя, но взять его адрес — можно. Ну а там, где это нельзя аппаратно — пусть компилятор добавит один «невидимый» элемент. Ну и, в общем, много было разных решений, где обсуждалось — какие запреты программист ещё сможет «пережить», а какие — это уже «ни в какие ворота».

Правда некоторые из этих запретов оказались «на грани». Оказалось, например, что нельзя просто так взять и преобразовать int в unsigned int. Ну потому что бывает дополнительный код и обратный код. Тут уж комитет по стандартизации решил: «нет, ну пора и меру знать, это уж какие-то совсем безумные ограничения мы тут на программиста налагаем». Было принято «соломоново решение»: разрешить реализации выбрать один из вариантов (иногда явно перечисленных в стандарте, иногда нет), но обязать реализацию всегда использовать один и тот же подход. То есть реализация может быть с дополнительным кодом или с обратным кодом, но уж какой-то один вариант будет точно. И в любом случае можно преобразовать число из int в unsigned int и получить… ну хоть что-то.
Стандарт говорит даже чуть больше
Положительные числа, представимые в обоих вариантах при этом гарантированно сохраняются, что здорово облегчает жизнь.

Но таких вариантов (они называются "поведение, определяемое реализацией aka "implementation-defined behavior") мало. В основном запреты (получившие название "неопределённого поведения" aka "undefined behavior") были оставлены в стандарте и обязанность бороться с их последствиями возложили на программиста: если программист хочет, чтобы его Си программа работала на разных компиляторах, то он должен позаботится о том, чтобы беды не произошло. Разработчики же компилятора могут делать в таких случаях что угодно (в этом, собственно, весь смысл).
Кстати есть ещё одно близкое понятие
Неуточняемое поведение aka unspecified behavior — это когда возможны несколько вариантов и компилятор в каждом конкретном случае волен выбрать один из них (скажем стандарт не определяет какая из функций вычислится первый в выражении f()+g() и в каждом конкретном случае компилятор волен выбирать более удобный ему вариант, но всё-таки он должен сначала вычислить их обе, а уж потом результаты сложить). Цель — всё та же: облегчить работу разработчикам компиляторов.

Заметьте, что во многих случаях понять — нарушится запрет или нет по тексту программы не так-то просто. Например в стандарте Си запрещено использовать два одинаковых указателя, указывающие на объекты разных типов, которые используются вперемешку. А почему, собственно? Да очень просто: вспомните процессоры 8086 и 8087. Они работают параллельно и, в известной степени, независимо. Так что если специально не вставлять команды синхронизации, то хорошо известный трюк, когда число типа float интерпретируется как число типа int (или long на 16-битных компиляторах) может и не сработать! Искомого числа (который должен породить процессор 8087) просто ещё не будет в памяти, когда процессор 8086 туда «придёт»!

Заметьте: запрещено не наличие двух указателей на объекты разных типов (иначе не было бы никакого смысла в объединениях), запрещено их использование «вперемешку»: если положили int, то и вынимайте уж int, а если засунули float, то и вынимать нужно float. Понятно, что проконтролировать подобного рода запреты чрезвычайно сложно, да и не нужно — это же ограничение не на компилятор, а на программиста! Это его обязанность, не компилятора.

Правильная программа на Си обязана соблюдать эти запреты — иначе никакой переносимости не получится! Ну а раз программа никогда не нарушает эти запреты и никогда не вызывает неопределённого поведения, то грех этим не воспользоваться для ускорения программы, ведь правда? Например можно объявить переменную p до вызова realloc'а и после вызова realloc'а разными виртуальными переменными. Я надеюсь вы понимаете, зачем это нужно: ну, например, вы можете одну «виртуальную переменную» засунуть в регистр даже если адрес другой куда-то там передаётся… они же разные, друг на друга не влияют!

А дальше… имеем то, что имеем:
#include <stdio.h>
#include <stdlib.h>
 
int main() {
  int *p = (int*)malloc(sizeof(int));
  int *q = (int*)realloc(p, sizeof(int));
  *p = 1;
  *q = 2;
  if (p == q)
    printf("%d %d\n", *p, *q);
}
$ clang -O realloc.c ; ./a.out 
1 2
Но… это же жуть, качмар, как так может быть вообще? А вот так: раз вы вызвали realloc, то у вас теперь не две переменные в программе, а три: p₁, p₂ и q. При этом переменная p₂ не получила никакого значения, которое могло бы заставить её указывать на участок памяти, на который указывает q (она, собственно, никакого значения не получила, чем компилятор может и воспользоваться — но не обязан). Ну а стало быть значения *p₂ и *q тоже разные. И мы знаем — какие: *p₂ указывает на 1, а q на 2. Значит можем сразу передать эти значения в printf. Экономия! А вот проверку p₂ == q почему-то в false не удалось соптимизировать, Ну да ладно — может следующая версия компилятора и с этим справится.

Заметьте, что это результат порождается после длинных цепочек преобразований, каждое из которых осмысленно и логично. В предположении, конечно, что программист соблюдает «правила игры» и создаёт программу, которая не нарушает «запреты». Ну а если он их нарушил… ну что тут можно сказать — ССЗБ, нужно было вдумчивее «курить» документацию.

Вот, собственно, и всё: абсолютно естественное предложение (для людей, которые используют С для какой-то одной платформы) реализовывать операции так как они соответствует здравому смыслу, а именно — свойствам целевой платформы оказывается неестественным, если мы исходим из основной цели языка C: созданию быстрых, низкоуровневых, но, вместе с тем, переносимых программ. Для переносимых программ этого не нужно просто потому, что в их коде вообще никогда подобное поведение не должно встречаться. Ну а если вы используете бензопилу для забивания гвоздей, то… берегите свои пальцы!

И ровно по той же причине вопрос можно привести ошибочный псевдокод, который теоретически может быть создан компилятором? также бессмысленен: какой-такой псевдокод? Не будет там никакого псевдокода! Мы же обсуждаем не просто компилятор, а хорошо оптимизирующий компилятор! Он не просто может, но обязан этот код просто выкинуть к чертям собачьим: если код обязательно вызывает неопределённое поведение, то, в правильно написанной программе на Си, он никогда не вызывается и, стало быть, его можно выкинуть — что очевидно только уменьшит размер программы, а может и немного ускорить её. Сплошная выгода!

Разумеется в случаях конкретных компиляторов разработчики могут решить, что они выполнения части из этих правил требовать не будут. Например GCC предлагает аж три способа обращения с переполнением чисел со знаком: поведением по умолчанию (как описано в стандарте — когда он с радостью неописуемой может превратить конечный цикл в бесконечный), режим -fwrapv, когда поддерживается переполнение с использованием дополнительного кода и режим -ftrapv, когда эти случаи отлавливаются и выбрасывается исключение. А для случаев когда вам нужно писать код в котором разные типы могут перемешиваться есть не только -fno-strict-aliasing, но и атрибут типа may_alias. И вообще много чего есть. Но это всё — расширения стандарта, которые, в большинстве случаев, нужно включать явно.

Если же вы досконально знаете поведение вашего конкретного процессора и хотите поиспользовать его особенности, то, увы, вынужден вас разочаровать: вы не только не вправе ожидать поведения, близкого к железу, а напротив — если поведение вашего процессора отличается от какого-то другого, то с вероятностью 99% эту его особенность использовать в Си категорически запрещено. И это — не недосмотр разработчиков какого-либо компилятора, а совсем даже наоборот — прямое следствие из базового принципа положенного в основу этого языка.
Tags:
Hubs:
+69
Comments206

Articles

Change theme settings