В сентябре я опубликовал статью, описывающую теорию синтаксического анализатора на основе Shunting Yard. Эта статья является практическим продолжением, в которой описывается реализация синтаксического анализатора. В ближайшем будущем планируется публикация ряда статей, в которых другие реализации будут основаны на аналогичной теории. В данной статье представлена реализация с использованием lambda-выражений, которая, на мой взгляд, является наиболее простой, но наименее эффективной.
Примеры кода предоставлены на языке Java
Лексический анализатор
В рамках данной статьи лексический анализатор не будет реализован. Для понимания синтаксического анализатора достаточно нескольких интерфейсов и перечисления (enum).
Токен
На этапе синтаксического анализа нам будет нужен только тип токена. Если в языке нет знака ;
, можно также добавлять доступ к номеру строки, где находится токен.
Тип токена
Токен стоит разбить по типам — различным операторам, литералам, ключевым словам и т.д. Также типы токена разбиваются по группам.
Группа типов токена
Типы токена удобно разделять на следующие группы:
-
delimiter
— разделители. Например, скобка, запятая, точка с запятой и т.д. -
operator
— операторы. Например, оператор присваивания, плюс, минус и т.д. -
keyword
— ключевые и зарезервированные слова, частично определяющие синтаксис языка. Их нельзя использовать в качестве имен переменных, функций и т.д. -
identify
— имена переменных и функций. -
literal
— числа, строки, символы и в принципе любые константные значения.
Интерфейс взаимодействия с токенизатором
public interface TokenStream { @NonNull Token next(); Token get(); boolean hasNext(); }
-
Token next()
— возвращает токен на текущей позиции и переходит к следующему. -
Token get()
— возвращает токен на текущей позиции без перемещения указателя. -
boolean hasNext()
— возвращаетtrue
, если есть следующий токен.
На этом описание лексического анализатора завершается. Далее перейдем к синтаксическому анализу.
Архитектура синтаксического анализатора
В предыдущей статье рассматривались три стека: стек операторов, стек состояний и результативный стек. В реализации с lambda-выражениями будет использоваться только стек состояний.
Основные элементы синтаксического анализатора
Состояние в данном анализаторе — это обработчик какой-либо структуры синтаксиса или ее части.
Синтаксический анализатор включает:
-
Управляющую часть, которая вызывает выполнение состояний и следит за отсутствием циклического повторения состояний.
-
Состояния, содержащие основной метод для анализа части конструкции.
-
Контроллер состояний, который хранит шаблоны и часто используемые состояния.
Основные правила реализации
-
Управляющая часть добавляет только начальное состояние в стек.
-
Одно и то же состояние не должно повторяться на вершине стека подряд.
-
Состояние обязано либо исключить себя из стека, либо добавить новые на вершину.
Реализация управляющей части
private final TokenStream stream; private final Stack<State> states = new Stack<>(); public @NonNull FileNode analyze() { FileNode file = new FileNode(); states.push(new FileState(states)); while(states.size() != 0) { /* * Проверяем, не повторилось ли состояние и * не замкнулись ли они между собой. */ states.peek().analyze(); } return file; }
Проверки, описанные в комментариях, необходимы только в том случае, если вы не можете утверждать, что все состояния реализованы корректно (то есть всегда.. но в рамках статьи опустим этот момент).
Помимо этого данная реализация предусматривает восстановление после ошибок. Например, допускается ставить try-catch над выполнением analyze
состояния и удаления состояния в случае ошибки (в реализации с восстановлением после ошибок необходимо подправить правило №1). Перед этим необходимо провести восстановление после ошибок в пределах состояния, если это предоставляется возможным.
Реализация состояний и контроллера состояний
Определим State для дальнейшей работы
public interface State { void analyze(); }
В реализации контроллера состояний напишем несколько статических методов:
-
void custom(State state)
— добавляет в стек заданное состояние, которое затем себя удаляет. -
void body(Consumer<List<Node>> result)
— добавляет состояние для обработки тела функции (например, объявления переменных, условий) и передает результат черезresult
. -
void expression(Consumer<Expression> result)
— добавляет состояние для обработки выражений и передает его черезresult
.
Методы body()
и expression()
для соответствующих состояний имеют множество тонкостей, и позже им будет посвящена отдельная статья.
Пример реализации if-else
Рассмотрим реализацию для простейшего if-else
и предположим, что у нас уже есть состояния для анализа выражений и блоков кода. Чтобы удовлетворить правилам 2 и 3, if-else
разбивается на несколько состояний. Добавляем состояния в обратном порядке, учитывая стек:
-
Обрабатываем
TokenType.IF
иTokenType.LEFT_PARENT
. -
Добавляем в стек состояние для
expression
, направляя результат в объект условной конструкции. -
Добавляем состояние для
body
, направляя результат в объект условной конструкции. -
Проверяем наличие
TokenType.ELSE
. Еслиelse
найден, добавляемbody
, направляя результат в объект условной конструкции. -
Передаем итоговый объект через lambda-выражение.
Записав действия в обратном порядке, получаем следующую реализацию состояния:
public static void analyze(Consumer<IfStatement> result) { IfStatement ifStatement = new IfStatement(); StateController.custom(() -> result.accept(ifStatement)); StateController.custom(() -> { if (analyzer.boolEat(TokenType.ELSE)) StateController.body(ifStatement::setElseBody); }); StateController.body(ifStatement::setBody); StateController.custom(() -> analyzer.eat(TokenType.RIGHT_PARENT)); StateController.custom(() -> { analyzer.eat(TokenType.IF); analyzer.eat(TokenType.LEFT_PARENT); StateController.expression(ifStatement::setExpression); }); }
-
@NonNull Token eat(TokenType type)
— возвращает текущий токен и переходит на следующий, если находит его. Если тип не совпал — выдает ошибку. -
boolean boolEat(TokenType type)
— возвращаетtrue
и переходит на следующий токен, если тип совпал. Если тип не совпадает, то выдаетfalse
.
Итоги
Мы получили простую и немного неэффективную реализацию, которая легко масштабируется и модернизируется для восстановления после ошибок. Хотелось бы называть реализации не сухо по их особенностям, а придумывать для каждой уникальное название. В связи с наиболее простой возможностью добавления восстановления после ошибок, можно сравнить реализацию с аксолотлем, так как у них хорошая регенерация.
ссылка на оригинал статьи https://habr.com/ru/articles/856616/
Добавить комментарий