Компиляторы
May 25, 2022

Об инструментарии компилятора

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

Начнем с простого.

Входные данные компилятора

Допустим, есть у нас классический пример:

fn main() {
  println("Hello, world!");
}

Компилятор должен уметь прочитать этот текст и преобразовать его в код целевой платформы. Т.е. в самом простейшем случае компилятор должен уметь получить строку с этим кодом. Мы можем подать эту программу в виде параметра командной строки (такое понимают очень многие утилиты командной строки).

А можем — в виде файла:

hello.code:
-----------
fn main() {
  println("Hello, world!");
}

В этом случае компилятор уже должен уметь работать с файлами.

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

Теперь компилятор должен знать, что, собственно, такое println(). То, что представляет собой эта конструкция, может быть встроено непосредственно в сам компилятор (или интерпретатор, если у нас программа исполняется, а не компилируется), а может находится в некоторой самостоятельной сущности. Обычно это называется runtime (среда времени исполнения). Компилятор должен знать, где эта среда находится, чтобы в процессе сборки программы привязать (прилинковать, от англ. link) её к скомпилированной программе.

Пути поиска

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

Очевидный способ работать с файлами с точки зрения компилятора — расположить все файлы в некоторой относительной доступности от файла запуска компилятора:

▹ bin/compiler.exe
▹ runtime/runtime.code
▹ hello.code

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

Однако, это плохо настраиваемый способ. При таком подходе компилятору сложно объяснить, что рантайм лежит в другом месте, кроме как используя символические ссылки или junction в Windows.

Теперь у нас появляется список мест, о которых компилятор «знает». В рамках этой статьи компилятор пока знает только об исходниках и рантайме. Мы можем указывать расположение того и другого несколькими способами:

  • указав явно через командную строку: bin/compiler.exe --source hello.code --runtime /path/to/runtime.code;
  • используя переменные окружения: SOURCE=hello.code RUNTIME=/path/to/runtime.code bin/compiler.exe ;
  • сохранив параметры в файл, о котором компилятор осведомлен;
  • комбинируя предыдущие варианты.

Импорт сущностей

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

hello.code:
-----------
import fmt;

fn main() {
  fmt.println("Hello, world!");
}
fmt.code:
---------
fn println(str: string) {
...
} 

Теперь компилятор дожен знать не только путь до файла, рантайма, но и должен понимать, где искать импортируемую сущность.

Для этого пути поиска расширяются, теперь мы дожны указывать еще и путь поиска модулей: bin/compiler.exe --source hello.code --runtime /path/to/runtime.code --imported /path/to/imported/fmt.code

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

Обычно расширяют понятие путей поиска, указывая сразу группу путей, в которых производится поиск импортируемого файла:

bin/compiler.exe 
--source hello.code 
--runtime /path/to/runtime.code 
--imported /path/to/imported1:/path/to/imported2:...

Подведем промежуточный итог.

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

Компилированные файлы

Компиляция - процесс не очень быстрый. Для того, чтобы снова и снова не компилировать файлы, сохраняя время и вычислительные ресурсы, результат компиляции отдельного модуля записывается в виде некоторого промежуточного представления. Это могут быть как готовые для последующей сборки файлы, вроде *.o файлов в C, *.class файлов в Java, так и файлы других форматов, содержащие дерево разбора, символьную информацию и т.д.

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

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

Включаемые файлы (includes)

Кроме обычных файлов, при компиляции файлов иногда используется механизм включаемых файлов и/или строк. Самый известный пример — это включения в C/C++:

file.c:
-------
#include <stdio.h>
#include "module.h"
...

В том месте, где находится строка #include, компилятор читает файлы, на которые указывают строки <stdio.h> или "module.h" и вставляет их как есть в текст компилируемого модуля file.c.

Это, впрочем, не единственный пример использования включений. Бывает, например, так:

fn main() {
  println("#include hello.txt");
}

Здесь во время компиляции содержимое файла hello.txt подставляется в точке вызова, т. е. внутри строки. Схема работы, с точки зрения компилятора, такая же, как при обработке исходных файлов C/C++ и других, использующих эту же технику, но смысловая нагрузка отличается.

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

fn main() {
  println("#:некоторая-предопределенная-внутрикомпиляторная-строка");
}

Компилятор распознает последовательность символов, в данном случае это #:, после чего выполняет подстановку, как если бы в этом месте была определенная строка.

Такую технику можно использовать, например, для интернационализации.

А если не файл?

Компилятор не всегда работает с файлами. Иногда текст программы может находиться в СУБД или вообще на сайте, т.е. фактически доступно как поток байт в HTTP-запросе. Яркий пример - скрипты на языке Groovy в Jenkins. Компилятор в этом случае должен уметь работать с сетью и получать скрипты с сайта.

Разумеется, технологически можно свести задачу к работе с файлами. А можно и не сводить.

То же самое касается и запросов, триггеров и процедур на языках семейства SQL. Сами по себе скрипты лежат в записях или метаданных СУБД, конвейер компиляции получает тексты скриптов не из файла.

Промежуточные данные также сохраняются в СУБД или, в случае с Jenkins, где-то на сайте.

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

Выводы

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

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

За рамками рассмотрения остались темы, близкие по сути, но не относящиеся напрямую к работе с файлами: работа с шаблонным кодом (когда компилятор генерирует заготовки для модулей), утилиты форматирования, поиска и т.д.