«Эта книга – классика. Относитесь к ней бережно».
Такую фразу произнёс архитектор из нашей команды, передавая мне The Dragon Book. Разработкой компиляторов я увлёкся где-то 15 лет назад ещё на заре своей карьеры. Как-то раз, читая эту книгу поздно вечером, я заснул, небрежно уронив её на пол. Надеюсь, владелец не заметил небольшую вмятину на обложке после того, как я ему её вернул.
Вышла эта книжка в 1986 году. В те времена создание компиляторов было крайне сложной задачей, требовавшей обладания различными навыками в области компьютерных наук в целом и программирования в частности. Теперь, почти четыре десятилетия спустя, этой задачей занимаюсь я. Насколько сложна она сегодня? Приглашаю вместе разобрать процесс создания языка и посмотреть, насколько современные инструменты его упростили.
Целевой язык
Для начала нам нужно выбрать какой-нибудь конкретный язык, чтобы разговор был более предметным. Я всегда считал, что реальные примеры намного эффективнее вымышленных, поэтому буду использовать язык ZModel, который мы создаём в ZenStack. Это предметно-ориентированный язык (Domain Specific Language, DSL), используемый для моделирования таблиц баз данных и правил управления доступом. И чтобы не растягивать статью, я возьму для демонстрации только небольшую часть его возможностей. Нашей целью будет компиляция следующего фрагмента кода:
model User {
id Int
name String
posts Post[]
}
model Post {
id Int
title String
author User
published Boolean
@@allow('read', published == true)
}
Несколько коротких примечаний:
- синтаксис модели описывает таблицу базы данных, и её поля отображаются в столбцы таблицы;
- модели могут иметь перекрёстные ссылки, формируя нужные связи. В примере выше модели
User
иPost
формируют связь «один-ко-многим»; - выражение
@@allow
представляет правила управления доступом и получает два аргумента: один, описывающий тип доступа (create
,read
,update
,delete
илиall
), и второй логический, указывающий на наличие прав для этого вида доступа.
Вот и всё. Пора закатать рукава и приступить к компиляции!
ZModel является надмножеством Prisma Schema Language.
Создание языка в шесть шагов
▍ Шаг 1: из текста в синтаксическое дерево
В общих чертах, построение компилятора за все эти годы не особо изменилось. Сначала нам потребуется лексер для разбивки текста на лексемы (токены), а затем парсер для выстраивания потока из этих токенов в синтаксическое дерево. Инструменты создания высокоуровневых языков обычно объединяют эти два шага и позволяют вам сразу перейти от текста к дереву.
Мы для создания языка использовали опенсорсный набор инструментов Langium. Это прекрасный набор, построенный на базе TypeScript, который упрощает весь процесс создания языка. Langium предоставляет интуитивный DSL, позволяющий определять правила для лексера и парсера.
При этом сам Langium DSL создан с помощью Langium. Такая рекурсия на жаргоне компиляторов называется бутстрэппингом. Первая версия компилятора должна быть написана с помощью другого языка/инструмента.
Синтаксис ZModel можно представить так:
grammar ZModel
entry Schema:
(models+=Model)*;
Model:
'model' name=ID '{'
(fields+=Field)+
(rules+=Rule)*
'}';
Field:
name=ID type=(Type | ModelReference) (isArray?='[' ']')?;
ModelReference:
target=[Model];
Type returns string:
'Int' | 'String' | 'Boolean';
Rule:
'@@allow' '('
accessType=STRING ',' condition=Condition
')';
Condition:
field=SimpleExpression '==' value=SimpleExpression;
SimpleExpression:
FieldReference | Boolean;
FieldReference:
target=[Field];
Boolean returns boolean:
'true' | 'false';
hidden terminal WS: /s+/;
terminal ID: /[_a-zA-Z][w_]*/;
terminal STRING: /"(\.|[^"\])*"|'(\.|[^'\])*'/;
Надеюсь, этот синтаксис достаточно интуитивен для понимания. Состоит он из двух частей:
- Правила лексинга.
Правила терминала в нижней части – это правила лексинга, определяющие порядок разбивки исходного текста на токены. Наш простой язык имеет только токены идентификаторов (ID
) и строк (STRING
). Пробелы в нём игнорируются. - Правила парсинга.
Остальная часть – это правила парсинга. Они определяют порядок организации потока токенов в дерево и могут содержать ключевые слова (например,Int
,@@allow
), используемые также и в процессе лексинга. В сложном языке у вас наверняка будут рекурсивные правила парсинга (например, вложенные выражения), при создании которых требуется особое внимание. Но в нашем примере мы обойдёмся без этого.
После подготовки правил можно использовать Langium API для преобразования нашего изначального фрагмента кода в следующее синтаксическое дерево:
▍ Шаг 2: от синтаксического дерева к связанному
Синтаксическое дерево сильно помогает понять семантику исходного файла. Тем не менее зачастую нужно проделать ещё один завершающий шаг.
Наш язык ZModel позволяет использовать так называемые «перекрёстные ссылки». Например, поле posts
модели User
ссылается на модель Post
, которая ссылается обратно на него через поле author
. Когда в процессе обхода дерева мы достигнем узла ModelReference
, то увидим, что он ссылается на имя Post
, но не сможем понять, что конкретно это означает. В этом случае можно прибегнуть к поиску, чтобы обнаружить модель с соответствующим именем, но более систематическим подходом будет выполнение «связывающего» обхода для разрешения всех подобных ссылок и связывания их с целевыми узлами. После завершения связывания наше синтаксическое дерево будет выглядеть так (показана лишь часть):
Связанное синтаксическое дерево (его часть)
В техническом смысле теперь это скорее граф, нежели дерево, но по соглашению мы продолжим называть его синтаксическим деревом.
В Langium хорошо то, что в большинстве случаев этот инструмент помогает выполнять связывающий обход автоматически. Он прослеживает иерархию вложений спарсенных узлов и использует её для построения «областей». Это позволяет ему при встрече имени разрешать его и связывать с соответствующим целевым узлом. В сложных языках будут случаи, когда вам потребуется реализовать особое поведение разрешения имён. Langium же упрощает эту задачу, позволяя вам влиять на процесс связывания, реализовав собственные сервисы.
▍ Шаг 3: от связанного дерева к семантической корректности
Если исходный файл будет содержать ошибки парсера/лексера, компилятор сообщит об этом и остановит выполнение.
model {
id
title String
}
Expecting token of type 'ID' but found `{`. [Ln1, Col7]
Но отсутствие подобных ошибок ещё не гарантирует семантическую корректность кода. Например, приведённый ниже фрагмент синтаксически корректен, но содержит семантическую проблему, так как сравнивать title
с true
бессмысленно.
model Post {
id Int
title String
author User
published Boolean
@@allow('read', title == true) // <- это сравнение является невалидным.
}
Семантические правила обычно у каждого языка свои, и инструменты редко справляются с их обработкой автоматически. В Langium для этого предоставляются хуки, позволяющие оценивать валидность различных типов узлов.
export function registerValidationChecks(services: ZModelServices) {
const registry = services.validation.ValidationRegistry;
const validator = services.validation.ZModelValidator;
const checks: ValidationChecks<ZModelAstType> = {
SimpleExpression: validator.checkExpression,
};
registry.register(checks, validator);
}
export class ZModelValidator {
checkExpression(expr: SimpleExpression, accept: ValidationAcceptor) {
if (isFieldReference(expr) && expr.target.ref?.type !== 'Boolean') {
accept('error', 'Only boolean fields are allowed in conditions', {
node: expr,
});
}
}
}
Теперь мы получим интересную семантическую ошибку:
Only boolean fields are allowed in conditions [Ln 7, Col 19]
В отличие от лексинга, парсинга и связывания кода, проверку семантики нельзя назвать особо декларативным или систематическим процессом. В сложных языках вам приходится писать с помощью императивного кода множество правил.
▍ Шаг 4: повышение удобства для разработчика
Сегодня планка создания хороших инструментов разработки весьма высока. Для успешного развития инновации должны не только отлично работать, но и быть удобными. В контексте языков и компиляторов удобство для разработчиков определяют три аспекта:
▍ 1. Поддержка IDE
Высокая поддержка IDE — выделение синтаксиса, форматирование, автозаполнение и так далее – значительно снижает сложность освоения и упрощает жизнь разработчика. И чем мне в этом плане нравится Langium, так это встроенной поддержкой Language Server Protocol. Ваши правила парсинга и проверки на валидность автоматически становятся приемлемой базовой реализацией LSP, напрямую работающей с VSCode и последними IDE от JetBrains (с ограничениями). Тем не менее, чтобы обеспечить высококлассный опыт работы в IDE, вам потребуется дополнительно постараться, переопределив дефолтную реализацию связанных с LSP сервисов с помощью Langium.
▍ 2. Оповещения об ошибках
Ваша логика проверки во многих случаях будет генерировать сообщения об ошибках. При этом точность и информативность таких сообщений будет во многом определять то, насколько быстро разработчик сможет понять их и предпринять необходимые действия.
▍ 3. Отладка
Если ваш язык «выполняется» (подробнее в следующем разделе), то возможность отладки в нём необходима. Причём значение отладки будет зависеть от природы языка. Если это императивный язык, включающий инструкции и поток управления, то в ней должна присутствовать возможность пошагового продвижения и инспекции состояния. Если же язык декларативный, отладка будет, скорее всего, подразумевать визуализацию, помогающую прояснить сложные моменты (правила, выражения и так далее).
▍ Шаг 5: закладывание пользы
Получение полностью разрешённого и лишённого ошибок синтаксического дерева – это, конечно, круто, но само по себе особой пользы не принесёт. С этого момента у вас есть несколько возможных путей, которые позволят придать языку фактическую ценность:
- Остановиться на этом этапе.
Можно остановиться здесь, утвердив синтаксическое дерево как конечный результат, чтобы пользователи могли применять его на своё усмотрение. - Преобразовать его в другие языки.
Зачастую у языка будет «бэкенд» для преобразования синтаксического дерева в более низкоуровневый язык. Например, бэкенд компилятора Java генерирует байт-код JVM (Java Virtual Machine). У себя в ZenStack мы трансформируем ZModel в Prisma Schema Language, после чего инструменты или среда выполнения целевого языка могут принимать его в качестве ввода. - Реализовать механизм трансформации в виде плагина.
Вы также можете создать встраиваемый механизм, который позволит пользователям выполнять трансформацию самостоятельно, что станет более конструктивной вариацией пункта 1. - Создать среду для выполнения синтаксического дерева.
Это наиболее «полноценный» путь построения языка. Вы можете реализовать интерпретатор для «выполнения» спарсенного кода. Что именно при этом будет подразумеваться под «выполнением» уже определять вам. В ZenStack у нас тоже есть среда выполнения, интерпретирующая правила управления доступом для их применения во время обращения к данным.
▍ Шаг 6: поиск пользователей
Поздравляю! Теперь можете передохнуть, поскольку проделали 20% работы по созданию нового языка. Как и почти с любой новой разработкой, самое сложное – это продать её людям, даже когда продукт бесплатный. Этот вопрос вас может не волновать, если язык предназначен исключительно для использования вами или вашей командой. Если же он создавался для внешней аудитории, то распространить его будет не так просто. В этом и заключаются оставшиеся 80% работы.
Заключение
Учитывая ту стремительность, с которой развивалась сфера разработки ПО за последние десятилетия, создание компилятора ощущается эдаким древним искусством. Но я всё равно считаю, что любому серьёзному разработчику следует реализовать такой проект даже просто ради уникального опыта. В этом процессе очень хорошо проявляется дуализм программирования – эстетичность и прагматизм. Превосходные системы ПО обычно несут в себе элегантную концептуальную модель, но вы также встретите множество импровизаций, которые изнутри выглядят не очень симпатично.
Вам следует попробовать написать язык программирования. Потому что, почему бы и нет?
Автор: Дмитрий Брайт