Одна из самых запомнившихся задач за три года коммерческой разработки — парсер бизнес-формул в аналитической системе. Выражения приходили строками из пользовательского ввода: арифметика, сравнения, логические связки, сорок с лишним функций, ссылки на поля модели данных, переменные, литералы дат. На выходе нужен был фрагмент SQL для колоночной БД плюс валидация типов.
Через эту задачу я плотно познакомился с ANTLR4: прошёл путь от пустого .g4 до продакшен-парсера, а дальше несколько лет курировал этот блок кода в команде.
Сегодня, оглядываясь на тот код, я вижу три центральные темы, которые сделал бы иначе. Расскажу про каждую и покажу, как это выглядит в коде. Плюс три небольшие заметки в конце — про что стоит подумать, когда вы только начинаете свой парсер.
Почему регулярки не подошли
На первых прикидках казалось: ну выражения, ну вложенные скобки, разберём регулярками. Первый же пример выбил эту идею:
@avg_score = (TOTAL([goals]) + 5) / [matches_played]; WHEN( SCOPE(TOTAL([goals]), [tournament] = 'Champions', [date] >= #01.07.2024#) > @avg_score, ROUND(DATESHIFT(MONTH, 1, #01.09.2024#), 0), @avg_score * 0.85 )
Здесь [goals] и [tournament] — ссылки на поля модели данных, @avg_score — пользовательская переменная, #01.07.2024#— литерал даты в формате dd.mm.yyyy.
Тут вложенные скобки нескольких уровней, вызовы функций с произвольным числом аргументов, приоритет операторов, контекстная зависимость (внутри WHEN первым аргументом идёт сравнение, а дальше — выражения), литералы дат с собственным синтаксисом, объявление переменной с последующей подстановкой. Регулярка не умеет рекурсию — скобки парой она не поймает в общем виде. Можно было бы попытаться свести задачу к конечному набору шаблонов, но каждый новый оператор или функция ломал бы уже написанное.
Нужен был настоящий парсер с деревом разбора. С этого начался ANTLR4.
Как ANTLR4 работает — базовый словарь для статьи
Чтобы дальше понимать разговор, нужно договориться о четырёх терминах.
Грамматика — файл .g4, в котором описывается синтаксис языка. ANTLR4 по нему генерирует лексер и парсер на целевом языке (у меня — Java).
Лексер превращает поток символов в поток токенов. Токен — это атомарная единица: число, идентификатор, оператор, ключевое слово.
Парсер собирает токены в дерево разбора (parse tree) по правилам грамматики.
Обходчики дерева — Listener или Visitor. ANTLR4 генерирует для них интерфейсы автоматически, вы реализуете методы под конкретные узлы.
Синтаксис грамматики выглядит так:
grammar Filter; // Корневое правило: программа состоит из объявлений переменных и выражения program : varDeclaration* expression EOF ; // Объявление переменной: @name = expr ; varDeclaration : VARIABLE '=' expression ';' ; // Правила парсера — с маленькой буквы expression : expression op=('*' | '/') expression # MulDiv | expression op=('+' | '-') expression # AddSub | expression op=('=' | '!=' | '<' | '>' | '<=' | '>=') expression # Compare | '(' expression ')' # Parens | functionCall # Function | VARIABLE # VarRef | IDENTIFIER # FieldRef | NUMBER # Number | STRING # StringLit | DATE_LITERAL # DateLit ; functionCall : NAME '(' (expression (',' expression)*)? ')' ; // Лексерные правила — с большой буквы NAME : [A-Z][A-Z_0-9]* ; // имена функций большими буквами VARIABLE : '@' [a-zA-Z_][a-zA-Z_0-9]* ; // @name IDENTIFIER : '[' ~[\]\r\n]+ ']' ; // [field_name] NUMBER : [0-9]+ ('.' [0-9]+)? ; STRING : '\'' ~['\r\n]* '\'' ; DATE_LITERAL : '#' [0-9][0-9] '.' [0-9][0-9] '.' [0-9][0-9][0-9][0-9] '#' ; WS : [ \t\r\n]+ -> skip ;
Несколько важных деталей, которые вам понадобятся:
Правила парсера именуются со строчной буквы (
expression,functionCall),
лексерные токены — с заглавной (IDENTIFIER,NUMBER,VARIABLE).|разделяет альтернативы в одном правиле.# MulDiv,# AddSub— это метки альтернатив. ANTLR сгенерирует для каждой отдельный метод в Listener/Visitor, что удобнее, чем один огромный метод с разбором черезif.-> skipв правилеWSговорит: «этот токен не включать в поток, это пробелы».op=('*' | '/')— это именованная ссылка на токен. В обработчике можно обратиться какctx.op.getType()и узнать, какой именно оператор распознался.~[\]\r\n]+в правилеIDENTIFIERозначает «любой символ, кроме закрывающей скобки и переноса строки».~[...]— это инверсия, сильное средство в лексерных правилах.
Это минимум, которого хватит для остального текста. Теперь переходим к центральным решениям.
Дерево разбора одного фрагмента
Чтобы видеть, что ANTLR4 на самом деле строит из формулы, разберём один фрагмент примера из лида — контекстную функцию с фильтрами:
SCOPE(TOTAL([goals]), [tournament] = 'Champions', [date] >= #01.07.2024#)
Дерево разбора этого фрагмента по нашей грамматике выглядит так:
Function └── functionCall ├── NAME: SCOPE └── arguments: ├── arg 0: Function TOTAL([goals]) │ └── functionCall │ ├── NAME: TOTAL │ └── arguments: │ └── FieldRef: [goals] ├── arg 1: Compare ([tournament] = 'Champions') │ ├── left: FieldRef: [tournament] │ ├── op: '=' │ └── right: StringLit: 'Champions' └── arg 2: Compare ([date] >= #01.07.2024#) ├── left: FieldRef: [date] ├── op: '>=' └── right: DateLit: #01.07.2024#
Корень — узел Function (метка альтернативы из правила expression), внутри него functionCall с тремя аргументами. Каждый аргумент — снова expression, потому что в грамматике аргументы функции описаны как (expression (',' expression)*)?. Поэтому в дереве естественно появляется вложенность: функция внутри функции, сравнение с литералом даты внутри аргумента.
Что важно увидеть на этом примере:
Каждое сравнение
=или>=создаёт узелCompareс тремя слотами —left,op,right. Это работа альтернативыexpression op=(...) expression # Compareиз грамматики.Литералы (
'Champions',#01.07.2024#) — листы дерева. Их разбирает лексер, парсер уже работает с готовыми токенами.Полное дерево всей формулы из лида занимает ~30 строк — оно построено по тем же принципам, просто крупнее. Здесь мы посмотрели на одну ветку, чтобы видеть форму.
Дерево — это вход для всех обходчиков, которые мы дальше обсудим. Visitor пойдёт по нему сверху вниз, возвращая значения; Listener пройдёт автоматически, дёргая enter и exit для каждого узла.
Решение 1. Приоритет операторов — через левую рекурсию, а не через Listener
Когда вы первый раз пишете грамматику для выражений, есть два очевидных искушения:
Искушение А. Написать плоское правило «операнд оператор операнд оператор...» без приоритетов, а приоритет воспроизвести в коде обработчика — собирать * и / первыми, потом + и -.
Искушение Б. Вспомнить классику и расписать слоистую пирамиду: addExpr → mulExpr → atom, где каждый слой — это выражение соответствующего приоритета.
Первое — антипаттерн: грамматика перестаёт отражать семантику. Парсер вернёт дерево без приоритетов, и правильную структуру придётся достраивать руками в обработчике. Любой другой обход этого дерева получит «неправильные» приоритеты — вы теперь обязаны помнить про свой костыль везде.
Второе — корректно, но громоздко. Для DSL с четырьмя уровнями приоритета это пять-шесть правил вместо одного.
Правильный способ в ANTLR4 — левая рекурсия с упорядоченными альтернативами. ANTLR4 умеет прямую левую рекурсию и автоматически разруливает приоритет: чем выше альтернатива в списке, тем выше её приоритет.
Возьмём фрагмент правила expression из нашей грамматики и посмотрим только на операторные альтернативы:
expression : expression op=('*' | '/') expression # MulDiv <- высший приоритет | expression op=('+' | '-') expression # AddSub | expression op=('=' | '!=' | '<' | '>' | '<=' | '>=') expression # Compare | ... // остальные альтернативы ;
Парсер автоматически свяжет * и / сильнее, чем + и -, а + и - — сильнее, чем сравнения. Достаточно правильно расставить альтернативы по убыванию приоритета.
Для входа 2 + 3 * 4 парсер построит дерево, где 3 * 4 — внутренний узел, а 2 + (3 * 4) — внешний. Без единой строчки кода.
Если где-то нужна правоассоциативность (например, у возведения в степень или тернарного оператора), это делается тэгом:
expression : expression '^' expression # Power | <assoc=right> expression '?' expression ':' expression # Ternary ;
Внутри Visitor это выглядит максимально скучно — как и должно быть:
@Override public BigDecimal visitMulDiv(FilterParser.MulDivContext ctx) { BigDecimal left = visit(ctx.expression(0)); BigDecimal right = visit(ctx.expression(1)); return switch (ctx.op.getText()) { case "*" -> left.multiply(right); case "/" -> left.divide(right, 10, RoundingMode.HALF_UP); default -> throw new IllegalStateException("Unknown op: " + ctx.op.getText()); }; }
Никакого ручного склеивания. Структура дерева — это и есть семантика, один в один.
Решение 2. Listener vs Visitor — выбирайте по природе задачи
ANTLR4 даёт два паттерна обхода дерева, и выбор между ними — это не вопрос вкуса, а вопрос того, что вы делаете.
Коротко про механику, чтобы дальше говорить на одном языке.
Listener запускается по готовому дереву через ParseTreeWalker.DEFAULT.walk(listener, tree), либо регистрируется прямо в парсере через parser.addParseListener(listener) и работает по ходу парсинга. В обоих режимах listener-ов можно повесить сколько угодно — парсер хранит их списком и на каждый узел дёргает по очереди. Методы enter<Rule>() и exit<Rule>() не возвращают значений: всё, что нужно накопить, живёт во внешнем состоянии (поле класса, стек, ParseTreeProperty).
Visitor парсеру не передаётся. Вы строите дерево, а потом вручную вызываете visitor.visit(tree). Управление рекурсией — на вас: вызываете visit(ctx.child()) там, где хотите спуститься. Каждый метод возвращает значение, и результат узла собирается из результатов детей снизу вверх. Никто не мешает запустить на одном дереве два разных Visitor-а, но на практике обычно нужен один — если от дерева нужен один составной результат, Visitor возвращает record сразу со всеми полями, а не делится на несколько обходчиков.
Простое правило выбора:
Нужен возвращаемый результат? — Visitor. Вычисление выражения, построение объекта, трансляция в другой AST — это всё Visitor.
Нужно собрать сайд-эффекты по дереву? — Listener. Валидация, подсветка синтаксиса, подсчёт количества определённых узлов, сбор символ-таблицы.
В моём коммерческом проекте было чёткое разделение ответственностей: один Visitor + три Listener-а, каждый из них отвечает за свою задачу:
Visitor — семантическая валидация и типизация. Для каждого подвыражения возвращает его тип, проверяет совместимость типов операндов, проверяет сигнатуры функций, собирает флаги (есть ли в поддереве агрегат, есть ли условная функция).
Listener №1 — транслятор в SQL. Использует
ParseTreeProperty<String>для хранения «уже сконвертированного фрагмента» на каждом узле, вexit-методах склеивает дочерние куски.Listener №2 — собирает колонки, попавшие под агрегат, для построения
GROUP BY.Listener №3 — строит шаблон выражения с плейсхолдерами для отдельного сценария.
Разделение задач между четырьмя обходчиками было осознанным применением Single Responsibility: каждый класс делает одну вещь, тесты на них независимые, поддерживать их можно параллельно. Это сработало — эти четыре класса спокойно жили и развивались параллельно в команде.
Но есть нюанс, про который я тогда не задумывался. Каждый из этих обходчиков делает свой проход по одному и тому же дереву. Четыре обхода. Для коротких выражений это незаметно. Для больших — это четырёхкратный объём работы, и часть логики между обходчиками неизбежно дублируется (например, и Visitor, и один из Listener-ов независимо определяют, есть ли в поддереве агрегатная функция).
Сегодня я бы собрал это одним Visitor-ом, возвращающим record FormulaResult(String sql, Type type, Set<String> columns, Flags flags, ...). Один обход, вся информация собрана снизу вверх, состояние «мы внутри агрегата» прокидывается параметром рекурсии, а не флагом во внешнем поле. Разделение ответственностей при этом сохраняется — просто переезжает из четырёх классов в четыре метода внутри одного.
Но если у вас нет проблем с производительностью и есть выигрыш в читаемости — разделять на Listener-ы тоже валидно. Главное — не смешивать Listener и Visitor «наугад». Два активных обходчика на одном дереве в одной задаче — это сигнал, что границы ответственности размыты.
Решение 3. Храните промежуточные результаты на узлах, а не в памяти пересчёта
Когда Visitor или Listener обрабатывает один узел, ему часто нужны результаты обработки детей — их типы, их транслированный SQL, их флаги. Есть три способа это сделать.
Способ первый, плохой. Взять ctx.getText() от поддерева и запустить парсинг заново в другом контексте. Соблазн большой: вроде же можно. У меня в коде это было, для нескольких узлов — объявления переменных и тело одной специальной функции парсились заново с другим набором параметров.
Что с этим не так:
getText()склеивает токены без пробелов.sum( [price] , [quantity] )послеgetText()становитсяsum([price],[quantity]). Если в вашем языке есть конструкции, чувствительные к исходному форматированию — теряете их.Локации ошибок нового парсинга относятся к подстроке, а не к исходной формуле. Пользователь видит «ошибка на позиции 5», но в исходной формуле это позиция, например, 47. Отладка становится болью.
Лексер запускается повторно для того же текста. ANTLR внутри кеширует DFA, но сам факт повторной токенизации — лишняя работа.
Все остальные Listener-ы и Visitor-ы вы тоже вызываете заново для поддерева. Если их четыре, это четыре обхода уже обойдённого.
Способ второй, ANTLR-ский. Использовать ParseTreeProperty<T> — это мапа «узел → произвольное значение», которую ANTLR даёт специально для аннотации дерева:
public class SqlTranslator extends FilterBaseListener { private final ParseTreeProperty<String> sqlFragments = new ParseTreeProperty<>(); @Override public void exitMulDiv(FilterParser.MulDivContext ctx) { String left = sqlFragments.get(ctx.expression(0)); String right = sqlFragments.get(ctx.expression(1)); String op = ctx.op.getText(); sqlFragments.put(ctx, "(" + left + " " + op + " " + right + ")"); } public String getResult(ParseTree root) { return sqlFragments.get(root); } }
Выходим из узла — кладём результат на узел. Выходим из родителя — читаем результаты детей с их узлов. Это работает одним проходом и не требует ни повторного парсинга, ни внешнего стека с ручным управлением.
Способ третий, для Visitor-а. Просто возвращать значение из метода. Это даже проще, чем ParseTreeProperty:
@Override public String visitMulDiv(FilterParser.MulDivContext ctx) { String left = visit(ctx.expression(0)); String right = visit(ctx.expression(1)); return "(" + left + " " + ctx.op.getText() + " " + right + ")"; }
Когда нужно пройти то же самое поддерево ещё раз с другим контекстом (у меня был этот сценарий для переменных и специальной функции) — не вызывайте парсер заново. Вызовите новый Visitor на том же узле:
String resultInContextA = new ContextAVisitor().visit(ctx.expression()); String resultInContextB = new ContextBVisitor().visit(ctx.expression());
Одно дерево — несколько обходчиков с разными задачами. Парсинг выполнен один раз, локации токенов сохранены, производительность в порядке.
Два файла грамматики: когда одна .g4 становится слишком большой
ANTLR4 поддерживает два режима грамматики:
Combined grammar — один файл, где лексер и парсер описаны вместе. Подходит для небольших DSL до пары сотен строк.
Split grammar — отдельно
FooLexer.g4(только лексерные правила) иFooParser.g4(только правила парсера, импортирует лексер черезoptions { tokenVocab = FooLexer; }). Для больших грамматик это стандартный приём.
У меня грамматика разрослась до объёма, при котором combined становилась неудобной — больше пятисот строк, сорок лексем, два языка ключевых слов (английский + локализованный), несколько десятков правил парсера. Я разделил на два файла сразу, как только стало ясно, что это не hello world на сто строк.
Внутри лексерного файла порядок правил важен: лексер матчит первое подходящее правило в порядке их объявления. Если у вас есть ключевые слова (AND, OR, NULL) и идентификаторы ([a-zA-Z_][a-zA-Z_0-9]*) — ключевые слова должны идти раньше идентификаторов, иначе любое ключевое слово попадёт под идентификатор.
lexer grammar FilterLexer; // Сначала ключевые слова AND : 'and' ; OR : 'or' ; NOT : 'not' ; NULL : 'null' ; // Потом идентификаторы IDENTIFIER : [a-zA-Z_][a-zA-Z_0-9]* ; // Потом литералы и пробелы NUMBER : [0-9]+ ('.' [0-9]+)? ; STRING : '\'' (~['\r\n])* '\'' ; WS : [ \t\r\n]+ -> skip ;
Для больших грамматик также полезны fragment-правила — вспомогательные куски, которые не генерируют токенов сами по себе, но используются в других лексерных правилах:
fragment DIGIT : [0-9] ; fragment LETTER : [a-zA-Z_] ; NUMBER : DIGIT+ ('.' DIGIT+)? ; IDENTIFIER : LETTER (LETTER | DIGIT)* ;
Это чистит грамматику и упрощает правки.
Ещё три вещи, о которых стоит подумать сразу
Трансляция — строка или целевое AST?
У меня Listener-транслятор собирал выходной SQL как строку через ParseTreeProperty<String>. Это работает, но каждый раз, когда нужно добавить новый трюк к выходному SQL (обернуть деление в decimal-функцию, добавить isNull(...) OR ... к каким-то условиям), приходится править склейку строки по месту. Для сложного выходного языка лучше строить целевой AST (свой или библиотечный — jOOQ, Calcite), и сериализовать его в SQL одним проходом. Тогда изменения выходного формата делаются в одном месте, а не в трёх exit-методах.
Типизация — не все узлы обязаны иметь тип.
В моей системе типов формулы было шесть значений: NUMBER, STRING, DATE, BOOLEAN, плюс два синтетических — KEYWORD (для аргументов-констант вроде единицы времени в функции «прибавить к дате») и MODIFIER_FUNCTION (для специальной конструкции внутри одной функции). Синтетические типы сделали систему единообразной, но это лишние сущности.
Правильнее признать: бывают узлы, у которых нет типа данных, потому что они не представляют значения. Такие узлы просто не участвуют в типовой проверке, а не получают «ненастоящий» тип для единообразия.
Возвращаемый объект парсера — разрезайте по сценариям.
У меня публичный API парсера был прост: один метод parse(String formula, ParserContext context) возвращающий FormulaData с пятнадцатью полями. Каждый вызывающий код брал из объекта свои 2-3 поля. Это удобно на старте, но превращается в бога-объект со временем.
Лучше сразу разделить возвращаемые типы по сценариям: ValidationResult для валидатора, TranslationResult для транслятора, MetadataResult для сервиса, которому нужна только структурная информация. Да, внутри парсер всё равно считает всё за один обход — но наружу отдаёт ровно то, что запрошено.
Итог
ANTLR4 — зрелый инструмент с продуманной архитектурой. Большая часть решений в нём уже правильная — нужно просто их знать:
Левая рекурсия с порядком альтернатив для выражений с приоритетом — вместо слоистых пирамид или ручной приоритезации в обработчике.
Один Visitor или один Listener с
ParseTreeProperty— вместо смеси нескольких активных обходчиков на одном дереве.Новый обходчик того же узла — вместо повторного парсинга через
getText().
Три центральных приёма, плюс привычки быстро уйти на split grammar при росте объёма, строить целевое AST вместо склейки строк и разделять возвращаемые типы по сценариям — и ваш парсер будет жить долго и не будет болеть, когда добавятся новые операторы, функции и типы.
Версии, которые я использовал: ANTLR4 4.13.2, Java 21, Maven plugin antlr4-maven-plugin.
Что дальше
Это вторая статья в серии материалов из моих проектов. Первая была про Spec-Driven Development на примере Telegram-бота — про то, как я теперь работаю с AI-ассистентами и что это сделало с моим инженерным процессом.
В следующей статье планирую разобрать:
FullStack web-приложение LifeSync (B2C-трекер привычек с гексагональной архитектурой, Kafka и jOOQ вместо JPA, React 19 + TypeScript, 251 тест).
valdemar-const
Мне очень нравится эта тема. На рабочем проекте есть задача создать встроенный в приложение интерпретатор языка для скриптинга. Но существующие языки (lua, python) не удовлетворяют требованиям (важна статическая типизация и определенный синтаксис для совместимости + дальнейшая трансляция в ANSI C). Я уже изучал ранее тему по книге дракона (создание компиляторов flex/bison), а потом ещё sicp и нашел тутор по kaleidoscope от llvm.
Хотел высказаться по поводу поддержки операторов. Мне как раз больше зашло решение не фиксировать приоритеты инфиксных операторов в самой грамматике (и вообще отказать от любых встроенных операторов), идея такая:
То есть, любой оператор просто функция. Инфиксная нотация - синтаксический сахар. Любое выражение парсится в PriorityAgnisticExpression как список '<expr> (<op> <expr>)*' а дерево с приоритетами строится в препроцессоре по таблице этих самых приоритетов алгоритмом сортировочной станции Дейкстры. Регистрировать операторы так же просто как обычные функции. Даже встроенных типов нет, их также нужно регистрировать явно. Такой себе конструктор получается "собери свой Си с сахаром и перегрузками" .
Antlr использую для прототипирования грамматики онлайн в AntlrLab, а реализую все в boost::spirit::x3. Я заметил, что мне неудобна концепция лексеров, как отдельного этапа парсинга, там есть свои неудобства, когда трактовка лексемы должна зависеть от контекста и вообще лексеры бывают черезчур жадными. С комбинаторами парсеров я добился большей гибкости.
zahaand Автор
Спасибо за развёрнутый комментарий — интересный кейс с регистрацией операторов через аннотации.
Тут вы правы — для языков, где приоритет операторов задаётся в коде программиста, а не в грамматике, левая рекурсия не подходит в принципе: грамматика просто не знает приоритеты на этапе компиляции. Для такой задачи «плоский парсер + sorting yard в постобработке» — обоснованный приём, не ANTLR-антипаттерн.
Я в статье говорил про более узкий случай: DSL с фиксированным набором операторов и приоритетов, прибитых на этапе разработки языка. Там левая рекурсия работает, потому что грамматика знает всё заранее.
valdemar-const
Да, я понимаю. Это не критика вашего решения. Такие темы поднимаются не очень часто. Разработка языков - довольно нишевое занятие, и обсудить эту тему особо не с кем.
Когда меня попросили сделать технический доклад о реализации интерпретатора для команды, меня в мёртвой тишине выслушали, выпучили глаза и разошлись. Похоже, никто из них не хотел заниматься развитием этой темы (я про сопровождение или разработку чего‑то подобного).
Только один человек мог вести активную дискуссию - но он по долгу службы занимался подобными вещами и был «заражён», как и я.