или что нужно знать об использовании путей в компиляторе

Алтай. Путешествие в самое сердце Сибири

С чего начинается работа

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

Из того, что он знает гарантированно, можно назвать только путь до бинарного файла, который послужил «пускачом». Откуда мы это знаем?

Мы знаем это из соглашения, которое все известные мне операционные системы стараются соблюдать — на старте приложения путь до исполняемого файла передается в качестве параметра argv:

int main(int argc, char** argv) {
  ...
}

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

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

Всю остальную информацию компилятору приходится добывать.

Информация об окружении

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

Информацию, которую нужно узнать, можно немного сгруппировать.

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

  1. Операционная система

    Компилятору как инструменту по‑крупному не очень интересно знание о том, на какой ОС мы запустились. Ему нужно уметь работать с файлами и текстами, а вот это, в свою очередь, может зависеть от операционной системы. Из очевидного, от этого зависит структура путей. В Windows путь начинается с буквы диска и двоеточия, вроде «C:». В разных ОС разные разделители пути, где‑то используется «\», где‑то «/», в совсем экзотических случаях могут использоваться другие виды разделителей и существенно отличаться способ построения имени файла, например, версия файла может быть частью имени «имя.файла;версия».

  2. Текущая директория

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

  3. Переменные окружения

    В ОС много информации передается с помощью перечня переменных окружения. Это набор пар «имя=значение». Получение этого списка (или его элементов также платформо‑зависимое.

Что компилятору нужно для работы

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

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

  1. Расположение компилируемого файла

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

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

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

    В любом случае, файл (или другая сущность), с которой начинается работа компилятора — это первая и необходимая информация для работы.

  2. Расположение библиотечного кода

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

    Например, в Delphi есть модуль System.pas, который, если я правильно помню, не компилировался, а поставлялся для того, чтобы были доступны «псевдоопределения», то есть реализовываны сущности (например, типы) в компиляторе, а выглядят они так, будто объявлены в файле.

    В Haskell есть модуль Prelude, используемый при компиляции любого модуля.

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

    Наличие таких модулей обязательно и компилятор должен о них знать.

  3. Импортированные сущности

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

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

  4. Кешированные импортированные сущности

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

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

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

    Тем не менее, эта группа артефактов должна иметь свой механизм доступа (и/или поиска).

  5. Инструментальные средства

    В процессе компиляции могут использоваться не только средства самого компилятора, но и внешние вспомогательные инструменты. Очень многие компиляторы используют вызов компиляторов C/C++ или их составных частей. Здесь также возможен вызов ассемблеров, линтеров, чекеров и других инструментов.

    И хотя в интерактивном режиме почти всегда достаточного того, что эти инструменты можно найти по системному пути PATH, в рамках компиляции на серверах CI/CD (или внутри IDE) это становится очень неочевидным и зачастую требует дополнительной настройки или прямого указания путей.

  6. Процессоры аннотаций

    В Java и Lisp есть механизмы, позволяющие вмешиваться в процесс компиляции с помощью кода, написанного на этих языках (в других языках такое тоже встречается, но не так явно).

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

  7. Целевое хранение

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

    Для каких‑то компиляторов сохранение производится рядом с исходным текстом, для каких‑то может быть задано явно, а для каких‑то компиляторов сохранение скомпилированного результата определяется вызовом отдельного инструмента (jar в той же Java).

  8. Вторичные артефакты

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

    Кроме того, компилятор может включать в себя механизмы для управления сборками и уметь создавать то, что называется «дистрибутивы».

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

Куда смотреть?

Теперь поговорим о том, на что компилятор может опереться, выполняя свою работу.

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

Жесткий путь

Начнем с самого простого, хотя и весьма ограниченного варианта, с «жесткого пути».

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

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

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

Так, в свое время, Windows-системы ощутимо усложнили запись в корневые папки дисков, что привело к поломкам большого количества систем, написанных исходя из предположения, что они будут работать по пути «C:\my_system\».

Перенос в %USERPROFILE% ситуацию не сильно изменил, поскольку начались сложности с длинными именами и именами с пробелами.

Кроме того, переезд такой программы на Linux или FreeBSD требовал нетривиальных усилий. Больше того, учитывая различие между операционными системами Unix‑семейства, нередко бывает так, что путь на одной ОС может существовать и быть доступным для исполнения, а на другой ОС может или отсутствовать, или иметь какие‑то другие сложности, например, может требоваться административный доступ для нормальной работы.

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

Портативная сборка

Этот термин родился с появлением USB накопителей, где программа вся целиком могла поместиться на этом накопителе или в отдельной папке на жестком диске. Такой программе для работы не требуется ничего, все расположено в одном месте.

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

Здесь наверное уместна шутка про глубину пещеры с npm modules, но вообще это не очень смешно.

Поэтому, хотя схема в целом рабочая, при превышении какого‑то размера сборки становится обременительной.

Комбинированный путь

Название этой механики весьма условное.

Можно каким‑то образом объединить предыдущие два подхода — часть компилятора, включая, возможно, часть библиотечных функций и других артефактов, разместить так, чтобы компилятор получал к ним доступ вообще не обращаясь к ОС, а часть разместить так, чтобы их использование, с одной стороны, не зависело от компилятора, с другой стороны, достаточно легко могло быть изменено, с сохранением работоспособности.

Так, в языке flow9 была применена интересная механика.

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

- flow
- product1
- product2
...
- productN

и из папки любого продукта мы можем всегда вызвать ../flow/bin/flowc main.flow, что приведет к компиляции продукта и получению дистрибутива (я немного опустил детали, но в целом все так и работает).

Кроме того, если в коде написано import ds/list;, то компилятор знает, что ему нужно сходить в папку ../lib/ds/ относительно своего исполняемого файла и взять оттуда файл list.flow.

Или, например, в Go, есть переменные окружения GOROOT и GOPATH, определяющие, соответственно, где находятся бинарные файлы компилятора и где искать файлы для компиляции.

Примерно ту же функцию выполняет комбинация JAVA_HOME/CLASSPATH в Java. Хотя это не единственные переменные, которые можно настроить, эти две существенно влияют на работоспособность компилятора.

При этом, часть файлов могут располагаться в местах с жестко заданными путями. Так, кешированные импортированные сущности для ECL хранятся в /home/user/.cache/common-lisp/ecl-23.9.9-9f27c69d-linux-x64/ и могут быть в любое время удалены.

Rust, например, хранит импортируемые модули в папке ~/.cargo.

И так далее

Как это использовать

Что компилятор может использовать для получения доступа к требуемым сущностям?

  • путь до собственного исполняемого файла;

  • содержимое переменных окружения, вродеGOPATH или CLASSPATH;

  • файл с настройками, расположенный по известному адресу, например ~/.sbclrc или %USERPROFILE%/my.cnf;

  • файл с настройками, заданный в компиляторе;

  • файл с настройками, заданный в проекте;

  • параметры, передаваемые в командной строке;

  • параметры, зашиваемые в файлы при инсталляции (так делал Go, например);

  • динамически изменяемые параметры, если система это позволяет.

Если использовать несколько из приведенных способов настройки, то у них обязательно должна быть выстроена система приоритетов, то есть какие‑то должны иметь более высокую «важность», какие‑то более низкую, что позволит разрешать конфликты, если одновременно указано несколько способов задания настроек.

Заключение

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

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