Поведение, определяемое реализацией

Как я уже неоднократно упоминал в этом блоге ранее, язык C# был тщательно спроектирован таким образом, чтобы устранить некоторое «неопределенное поведение» или «поведение, определяемое реализацией», с которым можно столкнуться в языках типа С и С++. Но я забегаю вперед; начать нужно с нескольких определений.

Обычно мы говорим, что некоторая идиома языка программирования обладает неопределенным поведением, если ее использование может привести к чему угодно; она может вести себя ожидаемым образом, или может отформатировать вам жесткий диск или привести к аварийному завершению работы компьютера. Более того, разработчики компилятора даже не обязаны предупреждать вас о неопределенном поведении. (На самом деле, существуют языки программирования, в спецификации которых сказано, что использование «неопределенного поведения» в программе должно приводить к аварийному завершению работы компилятора!)

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

В отличие от этого, поведение, определяемое реализацией (implementation-defined behavior) заключается в том, что у авторов компилятора есть несколько вариантов реализации некоторой возможности, и они должны выбрать один из них. Как подсказывает название, поведение, определяемое реализацией, по крайней мере, является определенным. Например, при делении на нуль спецификация языка C# позволяет генерировать исключение или возвращать некоторое значение, но авторы компилятора должны выбрать один этих вариантов. Вы можете не бояться за то, что при этом ваш жесткий диск будет отформатирован.

Теперь, до конца этой статьи я не буду делать серьезных различий между двумя вариантами неопределенного поведения. На самом деле, сегодня я хочу ответить на следующий вопрос:

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

Первая причина заключается в следующем: существуют ли на рынке две реализации языка программирования, которые расходятся относительно поведения конкретной программы? Если компилятор компании FooCorp компилирует «M(A(),B())» как «вызов A, вызов B, вызов M», а компилятор компании BarCorp компилирует в «вызов B, вызов A, вызов M» и ни одно из поведений не является «очевидно правильным», то существует хороший повод для комитета проектирования языка сказать «обе реализации корректны» и сделать это поведение, определяемым реализацией. Это особенно часто бывает, когда обе компании, FooCorp и BarCorp имеют в комитете своих представителей.

Вторая причина: предоставляет ли эта возможность естественное множество разных реализаций, некоторые из которых явно лучше других? Например, анализ компилятором языка C# выражения запроса (query comprehension) звучит так: «выполнить синтаксическое преобразование в эквивалентную программу, не содержащую выражения запроса, а затем выполнить ее анализ обычным образом». В данном случае существует минимум свободы у разработчика компилятора поступить как-то иначе. Например, все мы знаем, что два запроса:

 from c in customers
from o in orders
where c.Id == o.CustomerId
select new {c, o}

и

 from c in customers
join o in orders on c.Id equals o.CustomerId
select new {c, o}

семантически эквивалентны, и второй вариант, скорее всего более эффективен. Но компилятор языка C# ни в коем случае не преобразует первый вариант в вызов метода Join; он всегда преобразует его к вызову методов SelectMany и Where. Среда исполнения, конечно же, имеет полное право определить, что объект, возвращенный методом SelectMany, передается методу Where, и в случае необходимости оптимизировать его до join-а, но компилятор языка C# никогда не сделает подобного предположения. В случае использования первого варианта всегда будет вызван метод SelectMany и никогда не будет вызван метод Join. Мы хотели, чтобы преобразование выражений запросов было исключительно синтаксическим; хитрые оптимизации мы оставили среде исполнения.

В отличие от этого, спецификация языка C# говорит, что цикл foreach должен рассматриваться как эквивалентный цикл while внутри блока try, но дает реализации определенную гибкость. Компилятор языка C#, например, может сказать: «я знаю, как реализовать для массива цикл более эффективно» и использовать индексатор массива, а не преобразовывать массив к последовательности, как рекомендует спецификация. Реализация языка C# может опустить вызов метода GetEnumerator.

Третья причина: является ли возможность настолько сложной, что детальное описание точного поведения является сложным или слишком дорогим? В спецификации C# очень мало говорится о реализации анонимных методов, лямбда-выражений, деревьев выражений, динамических вызовах (dynamic calls), блоках итераторов или асинхронных блоках; в ней всего лишь дается описание желаемой семантики и некоторые ограничения поведения, оставляя все остальное на долю реализации. Разные реализации могут генерировать разный код, обеспечивая при этом желаемое поведение.

Четвертая причина: налагает ли возможность слишком большой груз для анализа со стороны компилятора? Например, если у нас есть следующий фрагмент C# кода:

 Func<int, int> f1 = (int x)=>x + 1;
Func<int, int> f2 = (int x)=>x + 1;
bool b = object.ReferenceEquals(f1, f2);

Предположим, мы бы требовали, чтобы b равнялась true. Как вы собираетесь определять «эквивалентность» двух функций? Анализировать «содержимое» (выполняет ли содержимое функций одно и то же?), или выполнять анализ «поведения» (приводит ли выполнение двух функций к одному результату при одних и тех же входных данных?) – еще сложнее. Комитет разработки языка программирования должен минимизировать количество открытых проблем, которые необходимо решить разработчикам компилятора! Поэтому данное поведение оставлено на усмотрение реализации; компилятор может вернуть одинаковые ссылки или нет, на свое усмотрение.

Пятая причина: накладывает ли возможность слишком большой груз на среду выполнения?

Например, поведение при разыменовывании указателя, который указывает за последний элемент массива, четко определено; это приводит к генерации соответствующего исключения. Эта возможность может быть реализована с небольшими (но не нулевыми) затратами во время выполнения. Вызов экземплярного или виртуального метода на пустом (null) объекте приводит к генерации исключения; опять-таки, эта возможность может быть реализована с небольшими, но не нулевыми затратами. Мы избавляемся от неопределенного поведения небольшой ценой времени выполнения. Но определение, что разыменовывание произвольного указателя в небезопасном (unsafe) коде допустимо, требует значительных затрат времени выполнения, поэтому мы этого не делаем; мы перемещаем груз ответственности за это на разработчика, который, прежде всего, сам отключил безопасную систему типов.

Шестая причина: препятствует ли четкое определение поведения выполнению серьезных оптимизаций. Например, язык C# определяет порядок побочных эффектов, наблюдаемых из потока, вызвавшего эти побочные эффекты. Однако поведение программы, когда побочные эффекты одного потока наблюдаются из другого потока, определяется реализацией всегда, кроме нескольких «специальных» случаев. (Как volatile-запись или вход в блок lock.) Если бы язык C# требовал, чтобы все потоки наблюдали одни и те же побочные эффекты в одинаковом порядке, то это ограничило бы эффективность работы современных процессоров; современные процессоры для достижения высокой эффективности строятся на основе исполнения с изменением последовательности команд (out-of-order execution) и сложными стратегиями кэширования.

Это всего лишь несколько причин, которые приходят в голову; конечно, существует множество других факторов, которые обсуждаются комитетом разработки языка программирования, прежде чем они принимают решение, что поведение будет неопределенным или зависеть от реализации.

Оригинал статьи