SerafimArts
@SerafimArts
Senior Notepad Reader

На какие слои должен (может) разделяться ЯП и его компилятор?

Доброго времени суток!

Ситуация следующая: В наличии есть GraphQL SDL (Schema Definition Language). Это декларативный предметноориентированный ЯП под строго заточенную платформу (встраиваемый в другой язык). Примерно как "XML" + "парсер XML".

Всё уже есть, всё готовое. Как выглядит всё сейчас в целом:
1) EBNF-Like грамматика.

2) Самопальный лексер: Делит исходники по этой грамматике на токены.

3) Самопальный LL(k) парсер: Выстраивает AST.

4) Объектная модель и Reflection: Далее происходит выстраивание базовой объектной модели типов (классов рефлексии, как в Java или PHP). Например, мы находим тип X, и создаём в "словаре", помечая, что он ещё не собран до конца и отправляемся дальше.

5) Линкер: После предварительного билда проходит линковка, находим все типы и выстраиваем отношения между ними. Например, берём те типы, которые ещё не были собраны и начинаем их файтюнить. Как только находим неизвестную связь с другим - ищем его в системе и пытаемся дособрать, если он так же не собран до конца. В противном случае вылетают ошибки, вида "Undefined type X".

6) Coercion: Допустимые приведения типов и исправляторы кода. Например, если аргумент помечен как "Nullable" (т.е. может содержать NULL), то к нему принудительно добавляется NULL в качестве значения по-умолчанию, если программа не содержит обратного.

7) Валидатор: Проверка типов, правил наследования, аргументов и прочего. Например, у нас есть тип-объект "X", который содержит два поля с одинаковыми названиями. Надо проверить это и сказать человеку, мол, нельзя так.

8) Препроцессинг: Выполняются константные скалярные выражения, перегрузки. Например, мы указываем какую-то директиву (аннотация или аттрибут в другом языке), это invocation инструкция, т.е. вызов существующей задекларированной директивы.

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

В итоге мы на выходе получаем АПИ (например, на псевдоджаве):
Compiler compiler = new Compiler();

// Собирает или берёт уже скомпиленный файл и возвращает результат - класс рефлексии этого файла (документа)
ReflectionDocument document = compiler.compile('interface Example {}');

// Массив типов в документе (в нашем случае он один)
document.getTypes(); 

// Массив полей у типа (интерфейса) Example
document.getType('Example').getFields();


А теперь к вопросам:
1) Насколько правильна такая модель работы? У меня есть подозрения, что превращаение из AST в классы рефлексии не совсем перспективно в т.ч. для поддержки. Но т.к. это декларативный язык - хз. Если есть какой-то опыт запиливания подобных штук - хорошо бы услышать советы.

2) Может имеет смысл вначале сборки в опкод (или байткод), для переносимости между платформами (т.е. возможности напрямую считывать императивные команды, вроде "создай тип Х", "добавь ему поле Y", уже выстроенные в нужном порядке и отвалидированные) и уже потом из этих инструкций генерировать и Reflection API, и какие-нибудь XML схемки?
  • Вопрос задан
  • 1203 просмотра
Решения вопроса 2
SerafimArts
@SerafimArts Автор вопроса
Senior Notepad Reader
Итак, корректный ответ на поставленный вопрос:

Для начала, стоит понимать, что задача компилятора - взять сырец на X языке и перегнать его на Y язык. В современной жизни устоялась терминология "транслятора" и процесс "транслитерации", дабы не путать перегон в машинный код и на другой язык.

В классическом варианте этапы следующие:
1) Лексический анализ: Используя правила разбивает сырец на набор токенов (терминалов)
2) Синтаксический анализ: Используя правила и набор токенов структурирует их в AST
3) Семантический анализ: Проверяет корректность данных внутри AST (какого фига семантический анализ на этом этапе, а не позже, я, признаться хз).
4) Генерация промежуточного кода: В частности, трёхадресного и этот этап имеет смысл при реализации императивных языков. Трёхадресный код так же можно заменить на опкод, байткод или прочие "заменители" промежуточного кода.
5) Оптимизатор кода.
6) Результат (генерация в конечный код)
Во время всех процессов активно используется "таблица символов", грубо говоря "словарь".

Ориентируясь на эти данные я сделал несколько выводов и перестроил архитектуру следующим образом:
1) Парсинг (лексер + синтаксис)
2) Построение таблицы символов в немного фривольном формате: Имя + Тип + Адрес/Позиция в исходном документе + Метаданные (AST и проч.)
3) Билдер промежуточного кода (Reflection API)
4) Приведение типов
5) Валидация (вместо проверки корректности семантики из AST мне показалось удобнее оперировать высокоуровневым API, применяя некий паттерн матчинг, т.к. каждый тип проходит эту стадию сборки от 1 до 8).
6) Экстенды (есть спец.тип в GralphQL, который занимается манкипатчингом) -> после него переход к п.3
7) Рантайм (вызовы функций и проч) -> после него переход к п.3
8) Выходной результат (Reflection API)

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

Крайне рекомендую к прочтению:
1) Тут много инфы человеческим языком https://ps-group.github.io/compilers/ особенно вот эта статья понравилась: https://ps-group.github.io/compilers/fsm которая рассказывает о построении таблицы переходов в синтаксическом анализаторе.
2) И вот эта книжеца: https://ru.wikipedia.org/wiki/%D0%9A%D0%BE%D0%BC%D... Довольно сложная для понимания для неподготовленного читателя, но позволяет уточнить некоторые нюансы и ответить на вопросы.
Ответ написан
Насколько правильна такая модель работы?

Расскажите подробнее, какая задача решается. Если бы вы спрашивали про обработку ЯП общего назначения, указав целевой язык для компиляции (например, "TypeScript в JavaScript" или "Java в JVM"), то ваши цели были бы понятны. Но у вас декларативный язык, и другие цели, не указанные в вопросе. Судя по примеру API цель - предоставить объектную модель? Или сгенерировать выходное представление на другом языке?

возможности напрямую считывать императивные команды, вроде "создай тип Х", "добавь ему поле Y"

Хоть я до конца не понял ваших целей, мне кажется это плохой идеей - не вижу смысла добавлять императивность там, где есть декларативное описание, тем самым вы потеряете часть информации о вашем декларативном представлении и привнесёте паразитную - например, зависимость от порядка исполнения этих опкодов. Оно вам зачем? Ведь мы как раз стараемся избавляться от такой паразитной информации, когда используем DSL с собственным синтаксисом вместо ЯП общего назначения (вспоминаются всякие ORM маппинги записанные кодом на C#, где код как бы императивный, но порядок декларативных инструкций на самом деле значения не имеет).
В этом ВОЗМОЖНО был бы смысл если ваша цель - сгенерировать императивный же код на разных целевых языках. Ну не знаю, например валидаторы. Валидатор на C#, валидатор на JavaScript, валидатор на PHP. Тогда да, возможно стоило бы использовать такое промежуточное представление, и то непонятно был бы от него толк или вред.
Ответ написан
Пригласить эксперта
Ваш ответ на вопрос

Войдите, чтобы написать ответ

Похожие вопросы