Сколько проходов?

В огромном объеме кода, написанном на языках C/C++, обычно происходит разделение кода на заголовочные файлы, в которых содержится только объявления методов и типов (а также определения макросов). Сами тела методов и типов содержатся в совершенно других файлах.

Меня иногда спрашивают, а почему в языке C # не требуются заголовочные файлы? Мне такая постановка вопроса кажется странной. Я могу задать тот же самый вопрос: а для чего нужны заголовочные файлы в С++? Заголовочные файлы – это место потенциальных ошибок: каждый раз при редактировании кода и изменении сигнатуры метода, если я забуду внести изменения в заголовочный файл, код перестает компилироваться и зачастую компилятор выдает весьма загадочное сообщение об ошибке. Будем надеяться, что за эту большую цену вы действительно что-то получите.

Это дает по одному преимуществу разработчикам компилятора и пользователю.

Пользователю это дает возможность компилировать отдельный cpp-файл в объектный код независимо (конечно, при наличии всех необходимых заголовочных файлов). Вся информация, необходимая для генерации тел данного cpp-файла может быть получена из набора заголовочных файлов. Это означает, что система сборки может перекомпилировать только измененные cpp-файлы, в случае отсутствия изменений в заголовочных файлах.

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

Язык C# не требует таких объявлений перед использованием, что имеет два следствия, опять-таки, одно для пользователя и одно для разработчика компилятора. Последствие для пользователя состоит в том, что вы не можете перекомпилировать только тот IL-код, который изменился вследствие изменения файла; вместо этого будет перекомпилирована вся сборка. К счастью компилятор C# значительно быстрее, поэтому это редко является серьезной проблемой. (Если посмотреть на эту ситуацию с другой стороны, то можно сказать, что единицей перекомпиляции в C# является проект, а не файл.)

Последствие для разработчика компилятора заключается в необходимости «двухпроходного» компилятора. При первом проходе мы ищем объявления и игнорируем содержимое. После того, как получена вся информацию из объявлений (которую мы бы получили из заголовочных файлов в C++), выполняется второй проход и генерируется IL-код для содержимого.

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

Хотя на практике, мы выполняем «два прохода» только по необработанному тексту, но мы выполняем множество дополнительных проходов по результирующим структурам данных. Думаю, мне стоит рассказать вам, какие проходы выполняет компилятор, чтобы реализовать спецификацию языка C#.

Первый этап определения «метаданных» происходит следующим образом:

Сначала мы берем текст исходного кода и разбиваем его на поток лексем. Т.е. мы выполняем лексический анализ, для определения того, что “class c : b { }” – это class, идентификатор, двоеточие, идентификатор, левая фигурная скобка, правая фигурная скобка.

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

Затем выполняется проход для получения объявлений, при котором помечаются местоположения каждого пространства имен и объявления типа в программе. На этом завершается просмотр исходного кода для первой фазы. Каждый последующий проход осуществляется по набору «символов», полученных из объявлений.

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

Затем следует проход для проверки того, что все ограничения обобщенных параметров также не содержат циклов.

Затем выполняется проход для проверки, является ли каждый член каждого типа – методы классов, поля структур, значения перечислений и т.п. – согласованными. Отсутствие циклических зависимостей в перечислениях, проверка того, что переопределяемый (overriding) метод переопределяет виртуальный метод и т.п. В этот момент мы можем вычислить структуру таблицы “vtable” для всех интерфейсов, классов с виртуальными методами и т.п.

Затем следует проход для обработки всех «константных» полей.

В этот момент имеется достаточно информации для генерации практически всех метаданных для этой сборки. У нас все еще нет информации о метаданных для итераторов/замыканий анонимных функций или анонимных типов; мы получим ее позднее.

Здесь можно начать генерацию IL-кода. Для тела каждого метода (а также свойства, индексатора, конструктора и т.д.) мы раскручиваем лексический анализатор до точки начала метода и разбираем тело метода.

[Дополнение: некоторые спрашивали меня о том, как оптимизируются эти проходы. Во время первого прохода мы делаем пометки, вроде «здесь применяется арифметика с nullable-типами», или «здесь находится инициализатор объекта» и т.п. Когда мы приступаем, скажем, к компиляции инициализатора объекта, то сначала проверяем, а есть ли он вообще, и пропускаем этот этап целиком, если точно знаем, что его нет. (Но тесты запускаются со специальной версией компилятора, который выполняет проход в любом случае и сообщает о том, получен какой-то результат или нет. Таким образом мы можем проверить корректность нашей логики пропуска некоторых проходов.)]

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

Сначала идет проход для преобразования циклов к формату меток (labels) и операторов goto.

(Следующие несколько проходов предназначены для поиска всяких ошибок.)

Затем следует проход для поиска устаревших (deprecated) типов для выдачи предупреждений.

Затем настает очередь прохода для поиска использования анонимных типов, для которых еще не созданы метаданные. При этом они создаются.

Затем идет проход для поиска неправильных использований деревьев выражений (expression tree). Например, использование оператора ++ в дереве выражения.

Затем выполняется проход для поиска всех локальных переменных, которые определены, но не используются, для выдачи предупреждений.

Затем следует проход для поиска некорректных шаблонов внутри блоков итераторов.

Затем запускается проверка достижимости, для выдачи предупреждений о недостижимом коде и сообщаем вам, например, о том, что забыт оператор return, для метода, возвращающего значение.

Затем мы выполняем проход, который проверяет, что каждый оператор goto указывает на правильную метку и что на каждую метку указывает достижимый оператор goto.

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

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

Затем следует проход, который определяет пропущенные ref-аргументы для вызова COM-объектов и исправляет их. (Это новая функция в C# 4.)

Затем мы выполняем проход для поиска кода вида: “new MyDelegate(Foo)” и переписываем этот код в виде вызова CreateDelegate.

Затем идет проход для преобразования деревьев выражений в последовательность вызовов фабричных методов, необходимых для создания деревьев выражений во время выполнения.

Затем наступает очередь прохода, который переписывает вычисления с nullable-типами в код, который проверяет свойство HasValue и т.п.

Затем мы выполняем проход для поиска всех ссылок вида base.Blah() и заменяем их на невиртуальные вызовы методов базового класса.

Затем идет проход для поиска инициализаторов объектов и коллекций, для преобразования их в соответствующие вызовы setter-ов свойств и т.п.

Затем выполняется проход для поиска динамических вызовов (в C# 4) и преобразования их в динамические вызовы с использованием DLR.

Затем следует проход для поиска вызовов «удаленных» методов (т.е. частичных методов (partial methods) без реализации или условных методов, для которых не определены соответствующие символы компиляции). Они преобразуются в пустые операции.

Затем мы ищем неиспользуемый код и удаляем его из дерева. Ему нет места в сгенерированном IL-коде.

Затем идет проход для оптимизации, который преобразовывает простые операторы “is” и “as”.

Затем выполняется проход для оптимизации, который ищет switch(constant) и преобразует его в переход напрямую к соответствующей ветке оператора case.

Затем идет проход, который преобразовывает конкатенацию строк в вызовы соответствующих версий метода String.Concat.

(Ох уж эти воспоминания. Именно над этими последними двумя проходами я работал, когда присоединился к команде разработчиков компилятора.)

Затем мы выполняем проход, который преобразовывает использование именованных и необязательных параметров в вызовы таким образом, чтобы побочные эффекты происходили в правильном порядке.

Затем следует проход, оптимизирующий арифметические вычисления. Например, если мы знаем, что метод M() возвращает int, а у нас есть код 1 * M(), то мы просто заменяем его на M().

Затем мы приступаем к генерации кода анонимных типов, впервые используемые этим методом.

Затем мы преобразовываем анонимные функции в методы классов замыканий.

В конце концов, мы преобразовываем блоки итераторов в конечный автомат на основе оператора switch.

Затем мы генерируем IL-код для преобразованного дерева, которое мы только что получили.

Дополнение: Я умолчал о нескольких дополнительных проходах, которые происходят во время генерации IL-кода. По-сути, генератор IL-кода преобразует дерево в набор «базовых блоков», т.е. в такую последовательность кода, которая никогда не прерывается «в середине» и в середину которой невозможно попасть из другого блока. Как только мы получили код в форме базовых блоков, мы можем начать выполнять над ним дополнительный анализ: преобразовывать ветвления более эффективным способом, удалять блоки, недостижимость которых доказана арифметическим оптимизатором и т.п. Для более подробной информации о том, что делается во время этого прохода, смотрите мою статью об оптимизации.

Все проще простого!

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