Введение

В компьютерном программировании компилятор с одним проходом — это компилятор, который просматривает части каждой единицы компиляции только один раз, немедленно преобразуя каждую часть в конечный машинный код. Это отличается от многопроходного компилятора, который преобразует программу в одно или несколько промежуточных представлений поэтапно, между исходным кодом и машинным кодом, и повторно обрабатывает всю единицу компиляции на каждом последовательном проходе. Речь идет о логической работе компилятора, а не о однократном чтении исходного файла. Например, исходный файл может быть прочитан один раз во временное хранилище, но затем копия этого файла может быть просканирована многократно. Компилятор Fortran для IBM 1130 хранил исходный код в памяти и использовал несколько проходов; в отличие от него, ассемблер на системах без дискового накопителя требовал, чтобы колода исходных карт была представлена считывателю/перфоратору дважды.

Свойства

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

Трудности

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

Местный контекст

Предположим, что символ < распознается как символ сравнения "меньше", а не "больше", например. Из-за ограничений кодирования символов, глиф ≤ может быть недоступен в стандартной кодировке, поэтому допускается составное представление "<=". Даже если контекст определяется следующим символом, неизвестно, когда встречается "<". Аналогично, символ "=" не всегда означает "=", как, например, когда он является частью составного символа. Другие составные символы могут включать ".lt." для случая, когда специальный символ "<" недоступен. Еще одна возможность, когда код символа для глифа ¬ ("не") недоступен, – это "<>" для "¬=" или "не равно", некоторые системы используют ~ или ! в качестве дальнейшей вариации. Один из подходов заключается в продолжении сканирования после "<" и, при встрече "=", возврате назад. Это, конечно, означает, что потребуется два прохода по этой части текста, чего следует избегать. К тому же, исходный файл может поступать с устройства, не поддерживающего операцию возврата и повторного чтения, например, считывателя карт. Вместо того, чтобы принимать преждевременное решение, которое впоследствии может потребоваться отменить, лексический анализатор может поддерживать несколько интерпретаций, подобно понятию квантовой суперпозиции, сводя их к конкретному выбору только при последующем определении определяющего символа. Примечательно, что компиляторы COBOL выделяют проход для различения точек, встречающихся в десятичных константах, и точек, стоящих в конце операторов. Такая схема недоступна однопроходному компилятору. То же самое относится и к именам элементов. Мало языков ограничиваются именами из одного символа, поэтому символ "x" как имя из одного символа существенно отличается от символа "x" внутри имени, например, "text" – теперь контекст выходит за рамки непосредственно соседних символов. Задача лексического анализатора – разделить элементы последовательного исходного потока на лексемы языка. Не только слова, поскольку "<" и "<=" также являются лексемами. Имена обычно начинаются с буквы и продолжаются буквами и цифрами, а также, возможно, несколькими дополнительными символами, такими как " ". Синтаксис, используемый для указания чисел, на удивление сложен, например, +3.14159E+0 может быть допустимым. Обычно допускается произвольное количество пробелов между лексемами, а Фортран необычен тем, что разрешает (и игнорирует) пробелы внутри видимых лексем, так что "GO TO" и "GOTO" эквивалентны, как и "<=" и "< =". Однако в некоторых системах могут потребоваться пробелы для разделения определенных лексем, а в других, таких как Python, используются начальные пробелы для указания области программных блоков, которая в противном случае могла бы быть обозначена маркерами Begin – End или аналогичными.

Неудачные решения

Хотя в описании выше использовалось понятие о том, что код может быть сгенерирован с некоторыми полями, которые будут доработаны позже, подразумевалось, что размер таких последовательностей кода остаётся неизменным. Это не всегда так. Многие компьютеры поддерживают операции, занимающие разный объем памяти, в частности, относительное адресование: если пункт назначения находится, скажем, в пределах 128 или +127 шагов адресации, можно использовать восьмибитное адресное поле, иначе потребуется гораздо большее поле для достижения цели. Таким образом, если код был сгенерирован с расчетом на короткое адресное поле, позже может потребоваться вернуться и изменить код, чтобы использовать более длинное поле. В результате, более ранний код, ссылающийся на позиции после внесённого изменения, также придётся скорректировать. Аналогично, более поздние ссылки, указывающие на позиции до внесённого изменения, также нужно будет исправить, даже если они указывали на известные адреса. Кроме того, саму информацию о необходимости исправления тоже придётся правильно обновить. С другой стороны, можно использовать длинные адреса во всех случаях, когда близость не гарантирована, но в этом случае код перестанет быть оптимальным.

Однопроходный последовательный вход, нерегулярный последовательный выход

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

Процедуры и функции

Декларация перед использованием также является простым требованием для процедур и функций, и это применимо также к вложению процедур друг в друга. Как и в ALGOL, Pascal, PL/I и многих других, MATLAB и (с 1995 года) Fortran позволяют функции (или процедуре) содержать определение другой функции (или процедуры), видимой только внутри содержащей функции, но эти системы требуют, чтобы они были определены после завершения содержащей процедуры. Однако, когда допускается рекурсия, возникает проблема. Две процедуры, каждая из которых вызывает другую, не могут быть обе объявлены перед использованием. Одна из них должна быть первой в исходном файле. Это не имеет значения, если, как при встрече с неизвестной переменной, из контекста вызова можно вывести достаточно информации, чтобы компилятор мог сгенерировать подходящий код для вызова неизвестной процедуры, с, разумеется, механизмом "подстановки" для последующего заполнения правильного адреса назначения при обнаружении определения процедуры. Это справедливо, например, для процедуры без параметров. Возвращаемый результат вызова функции может иметь тип, определяемый из вызова, но это не всегда верно: функция может возвращать значение с плавающей точкой, которое затем присваивается целому числу. Pascal решает эту проблему, требуя "предварительного объявления". Одно из объявлений процедуры или функции должно быть приведено первым, но вместо тела процедуры или функции указывается ключевое слово `forward`. Затем можно объявить другую процедуру или функцию и определить ее тело. В какой-то момент процедура или функция, объявленная с помощью `forward`, переобъявляется вместе с телом функции. Для вызова процедуры (или функции) с параметрами их тип будет известен (поскольку они объявлены перед использованием), но их использование при вызове процедуры может быть неизвестно. Например, Fortran передает все параметры по ссылке (то есть по адресу), поэтому нет непосредственных трудностей с генерацией кода (как всегда, с последующей подстановкой фактических адресов), но Pascal и другие языки позволяют передавать параметры разными способами по выбору программиста (по ссылке, по значению или даже, возможно, по "имени"), и это указывается только в определении процедуры, которое неизвестно до момента его обнаружения. В частности, для Pascal префикс `Var` в спецификации параметров указывает, что параметр должен быть передан по ссылке, а его отсутствие – по значению. В первом случае компилятор должен генерировать код, передающий адрес параметра, а во втором – другой код, передающий копию значения, обычно через стек. Как всегда, можно использовать механизм "подстановки", но это будет сложно. Многопроходные компиляторы, конечно, могут собрать всю необходимую информацию при многократном проходе по коду, но однопроходные компиляторы не могут. Генерация кода может быть приостановлена во время сканирования (а результаты сохранены во внутренней памяти) до тех пор, пока не будет обнаружена необходимая сущность, и это не обязательно означает второй проход по исходному коду, поскольку этап генерации кода вскоре догонит, а лишь временно приостановится. Но это было бы сложно. Вместо этого вводится специальная конструкция, при которой определение использования параметров процедуры объявляется "вперед" ее полного определения, чтобы компилятор мог знать его до использования, как того требует. Начиная с First Fortran (1957), стала возможна раздельная компиляция частей программы, что поддерживает создание библиотек процедур и функций. Процедура в компилируемом исходном файле, вызывающая функцию из внешней коллекции, должна знать тип возвращаемого значения неизвестной функции, хотя бы для генерации кода, который ищет результат в правильном месте. Изначально, когда существовали только целочисленные и переменные с плавающей точкой, выбор можно было оставить правилам неявного объявления, но с увеличением количества размеров и типов вызывающая процедура потребует объявления типа для функции. Это не является чем-то особенным и имеет ту же форму, что и объявление переменной внутри процедуры. Требование состоит в том, что в текущий момент однопроходной компиляции необходима информация об объекте, чтобы можно было сгенерировать для него правильный код сейчас, с последующей подстановкой адресов. Независимо от того, будет ли необходимая информация встречена позже в исходном файле или находится в отдельно скомпилированном файле, информация предоставляется посредством определенного протокола. То, проверяются ли все вызовы процедуры (или функции) на совместимость друг с другом и с их определениями, – это отдельный вопрос. В языках, произошедших от Algol, эта проверка обычно строгая, но другие системы могут быть безразличны. Отбрасывая системы, которые позволяют процедурам иметь необязательные параметры, ошибки в количестве и типе параметров обычно приводят к аварийному завершению программы. Системы, которые позволяют раздельно компилировать части полной программы, которые затем "связываются" вместе, также должны проверять правильность типа и количества параметров и результатов, поскольку ошибки сделать еще проще, хотя это часто не делается. Некоторые языки (например, Algol) имеют формальное понятие "повышения типа" или "расширения" или "приведения", при котором процедура, ожидающая, скажем, параметр двойной точности, может быть вызвана с параметром одинарной точности, и в этом случае компилятор генерирует код, который сохраняет переменную одинарной точности во временную переменную двойной точности, которая становится фактическим параметром. Однако это изменяет механизм передачи параметров на копирование, что может привести к тонким различиям в поведении. Гораздо более заметные последствия возникают, когда процедура получает адрес переменной одинарной точности, когда она ожидает параметр двойной точности или другие различия в размерах. Когда внутри процедуры считывается значение параметра, будет прочитано больше памяти, чем для данного параметра, и полученное значение вряд ли будет улучшением. Гораздо хуже, когда процедура изменяет значение своего параметра: что-то наверняка будет повреждено. Можно потратить много времени на поиск и исправление этих ошибок.

Препроцессор рекурсии

При объявлении сложных агрегатов данных может возникнуть необходимость в использовании функций Odd и Even. Например, если агрегат данных X имеет размер хранения, равный нечетному числу байт, к нему может быть добавлен один байт под управлением проверки на Odd(ByteSize(X)), чтобы сделать размер четным. Учитывая эквивалентные определения функций Odd и Even, как указано выше, предварительное объявление, вероятно, не потребуется, поскольку препроцессор знает, как используются параметры, и вряд ли предоставит возможность выбора между передачей по ссылке и по значению. Однако вызовы этих функций в исходном коде (вне их определений) не могут выполняться до их фактического определения, так как результат вызова должен быть известен. Если, конечно, препроцессор не выполняет несколько проходов по исходному файлу.

Форвардные декларации, считающиеся вредными

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