- PVSM.RU - https://www.pvsm.ru -

Такую фразу произнёс архитектор из нашей команды, передавая мне The Dragon Book [1]. Разработкой компиляторов я увлёкся где-то 15 лет назад ещё на заре своей карьеры. Как-то раз, читая эту книгу поздно вечером, я заснул, небрежно уронив её на пол. Надеюсь, владелец не заметил небольшую вмятину на обложке после того, как я ему её вернул.
Вышла эта книжка в 1986 году. В те времена создание компиляторов было крайне сложной задачей, требовавшей обладания различными навыками в области компьютерных наук в целом и программирования в частности. Теперь, почти четыре десятилетия спустя, этой задачей занимаюсь я. Насколько сложна она сегодня? Приглашаю вместе разобрать процесс создания языка и посмотреть, насколько современные инструменты его упростили.
Для начала нам нужно выбрать какой-нибудь конкретный язык, чтобы разговор был более предметным. Я всегда считал, что реальные примеры намного эффективнее вымышленных, поэтому буду использовать язык ZModel, который мы создаём в ZenStack [2]. Это предметно-ориентированный язык (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 [3].
В общих чертах, построение компилятора за все эти годы не особо изменилось. Сначала нам потребуется лексер для разбивки текста на лексемы (токены), а затем парсер для выстраивания потока из этих токенов в синтаксическое дерево. Инструменты создания высокоуровневых языков обычно объединяют эти два шага и позволяют вам сразу перейти от текста к дереву.
Мы для создания языка использовали опенсорсный набор инструментов Langium [4]. Это прекрасный набор, построенный на базе 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 для преобразования нашего изначального фрагмента кода в следующее синтаксическое дерево:

Синтаксическое дерево сильно помогает понять семантику исходного файла. Тем не менее зачастую нужно проделать ещё один завершающий шаг.
Наш язык ZModel позволяет использовать так называемые «перекрёстные ссылки». Например, поле posts модели User ссылается на модель Post, которая ссылается обратно на него через поле author. Когда в процессе обхода дерева мы достигнем узла ModelReference, то увидим, что он ссылается на имя Post, но не сможем понять, что конкретно это означает. В этом случае можно прибегнуть к поиску, чтобы обнаружить модель с соответствующим именем, но более систематическим подходом будет выполнение «связывающего» обхода для разрешения всех подобных ссылок и связывания их с целевыми узлами. После завершения связывания наше синтаксическое дерево будет выглядеть так (показана лишь часть):

Связанное синтаксическое дерево (его часть)
В техническом смысле теперь это скорее граф, нежели дерево, но по соглашению мы продолжим называть его синтаксическим деревом.
В Langium хорошо то, что в большинстве случаев этот инструмент помогает выполнять связывающий обход автоматически. Он прослеживает иерархию вложений спарсенных узлов и использует её для построения «областей». Это позволяет ему при встрече имени разрешать его и связывать с соответствующим целевым узлом. В сложных языках будут случаи, когда вам потребуется реализовать особое поведение разрешения имён. Langium же упрощает эту задачу, позволяя вам влиять на процесс связывания, реализовав собственные сервисы.
Если исходный файл будет содержать ошибки парсера/лексера, компилятор сообщит об этом и остановит выполнение.
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]
В отличие от лексинга, парсинга и связывания кода, проверку семантики нельзя назвать особо декларативным или систематическим процессом. В сложных языках вам приходится писать с помощью императивного кода множество правил.

Сегодня планка создания хороших инструментов разработки весьма высока. Для успешного развития инновации должны не только отлично работать, но и быть удобными. В контексте языков и компиляторов удобство для разработчиков определяют три аспекта:
Высокая поддержка IDE — выделение синтаксиса, форматирование, автозаполнение и так далее – значительно снижает сложность освоения и упрощает жизнь разработчика. И чем мне в этом плане нравится Langium, так это встроенной поддержкой Language Server Protocol [5]. Ваши правила парсинга и проверки на валидность автоматически становятся приемлемой базовой реализацией LSP, напрямую работающей с VSCode и последними IDE от JetBrains [6] (с ограничениями). Тем не менее, чтобы обеспечить высококлассный опыт работы в IDE, вам потребуется дополнительно постараться, переопределив дефолтную реализацию связанных с LSP сервисов с помощью Langium.

Ваша логика проверки во многих случаях будет генерировать сообщения об ошибках. При этом точность и информативность таких сообщений будет во многом определять то, насколько быстро разработчик сможет понять их и предпринять необходимые действия.
Если ваш язык «выполняется» (подробнее в следующем разделе), то возможность отладки в нём необходима. Причём значение отладки будет зависеть от природы языка. Если это императивный язык, включающий инструкции и поток управления, то в ней должна присутствовать возможность пошагового продвижения и инспекции состояния. Если же язык декларативный, отладка будет, скорее всего, подразумевать визуализацию, помогающую прояснить сложные моменты (правила, выражения и так далее).
Получение полностью разрешённого и лишённого ошибок синтаксического дерева – это, конечно, круто, но само по себе особой пользы не принесёт. С этого момента у вас есть несколько возможных путей, которые позволят придать языку фактическую ценность:
Поздравляю! Теперь можете передохнуть, поскольку проделали 20% работы по созданию нового языка. Как и почти с любой новой разработкой, самое сложное – это продать её людям, даже когда продукт бесплатный. Этот вопрос вас может не волновать, если язык предназначен исключительно для использования вами или вашей командой. Если же он создавался для внешней аудитории, то распространить его будет не так просто. В этом и заключаются оставшиеся 80% работы.
Учитывая ту стремительность, с которой развивалась сфера разработки ПО за последние десятилетия, создание компилятора ощущается эдаким древним искусством. Но я всё равно считаю, что любому серьёзному разработчику следует реализовать такой проект даже просто ради уникального опыта. В этом процессе очень хорошо проявляется дуализм программирования – эстетичность и прагматизм. Превосходные системы ПО обычно несут в себе элегантную концептуальную модель, но вы также встретите множество импровизаций, которые изнутри выглядят не очень симпатично.
Вам следует попробовать написать язык программирования. Потому что, почему бы и нет?
Автор: Дмитрий Брайт
Источник [7]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/programmirovanie/389933
Ссылки в тексте:
[1] The Dragon Book: https://en.wikipedia.org/wiki/Compilers:_Principles,_Techniques,_and_Tools
[2] ZenStack: https://zenstack.dev/
[3] Prisma Schema Language: https://www.prisma.io/docs/orm/prisma-schema
[4] Langium: https://langium.org/
[5] Language Server Protocol: https://en.wikipedia.org/wiki/Language_Server_Protocol
[6] IDE от JetBrains: https://blog.jetbrains.com/platform/2023/07/lsp-for-plugin-developers/
[7] Источник: https://habr.com/ru/companies/ruvds/articles/790868/?utm_source=habrahabr&utm_medium=rss&utm_campaign=790868
Нажмите здесь для печати.