- Регистрация
- 23 Авг 2023
- Сообщения
- 4,163
- Реакции
- 0
- Баллы
- 36
Ofline
Одна из самых запомнившихся задач за три года коммерческой разработки — парсер бизнес-формул в аналитической системе. Выражения приходили строками из пользовательского ввода: арифметика, сравнения, логические связки, сорок с лишним функций, ссылки на поля модели данных, переменные, литералы дат. На выходе нужен был фрагмент SQL для колоночной БД плюс валидация типов.
Через эту задачу я плотно познакомился с ANTLR4: прошёл путь от пустого
Сегодня, оглядываясь на тот код, я вижу три центральные темы, которые сделал бы иначе. Расскажу про каждую и покажу, как это выглядит в коде. Плюс три небольшие заметки в конце — про что стоит подумать, когда вы только начинаете свой парсер.
На первых прикидках казалось: ну выражения, ну вложенные скобки, разберём регулярками. Первый же пример выбил эту идею:
Здесь
Тут вложенные скобки нескольких уровней, вызовы функций с произвольным числом аргументов, приоритет операторов, контекстная зависимость (внутри
Нужен был настоящий парсер с деревом разбора. С этого начался ANTLR4.
Чтобы дальше понимать разговор, нужно договориться о четырёх терминах.
Грамматика — файл
Лексер превращает поток символов в поток токенов. Токен — это атомарная единица: число, идентификатор, оператор, ключевое слово.
Парсер собирает токены в дерево разбора (parse tree) по правилам грамматики.
Обходчики дерева — Listener или Visitor. ANTLR4 генерирует для них интерфейсы автоматически, вы реализуете методы под конкретные узлы.
Синтаксис грамматики выглядит так:
Несколько важных деталей, которые вам понадобятся:
Это минимум, которого хватит для остального текста. Теперь переходим к центральным решениям.
Чтобы видеть, что ANTLR4 на самом деле строит из формулы, разберём один фрагмент примера из лида — контекстную функцию с фильтрами:
Дерево разбора этого фрагмента по нашей грамматике выглядит так:
Корень — узел
Что важно увидеть на этом примере:
Дерево — это вход для всех обходчиков, которые мы дальше обсудим. Visitor пойдёт по нему сверху вниз, возвращая значения; Listener пройдёт автоматически, дёргая
Когда вы первый раз пишете грамматику для выражений, есть два очевидных искушения:
Искушение А. Написать плоское правило «операнд оператор операнд оператор...» без приоритетов, а приоритет воспроизвести в коде обработчика — собирать
Искушение Б. Вспомнить классику и расписать слоистую пирамиду:
Первое — антипаттерн: грамматика перестаёт отражать семантику. Парсер вернёт дерево без приоритетов, и правильную структуру придётся достраивать руками в обработчике. Любой другой обход этого дерева получит «неправильные» приоритеты — вы теперь обязаны помнить про свой костыль везде.
Второе — корректно, но громоздко. Для DSL с четырьмя уровнями приоритета это пять-шесть правил вместо одного.
Правильный способ в ANTLR4 — левая рекурсия с упорядоченными альтернативами. ANTLR4 умеет прямую левую рекурсию и автоматически разруливает приоритет: чем выше альтернатива в списке, тем выше её приоритет.
Возьмём фрагмент правила
Парсер автоматически свяжет
Для входа
Если где-то нужна правоассоциативность (например, у возведения в степень или тернарного оператора), это делается тэгом:
Внутри Visitor это выглядит максимально скучно — как и должно быть:
Никакого ручного склеивания. Структура дерева — это и есть семантика, один в один.
ANTLR4 даёт два паттерна обхода дерева, и выбор между ними — это не вопрос вкуса, а вопрос того, что вы делаете.
Коротко про механику, чтобы дальше говорить на одном языке.
Listener запускается по готовому дереву через
Visitor парсеру не передаётся. Вы строите дерево, а потом вручную вызываете
Простое правило выбора:
В моём коммерческом проекте было чёткое разделение ответственностей: один Visitor + три Listener-а, каждый из них отвечает за свою задачу:
Разделение задач между четырьмя обходчиками было осознанным применением Single Responsibility: каждый класс делает одну вещь, тесты на них независимые, поддерживать их можно параллельно. Это сработало — эти четыре класса спокойно жили и развивались параллельно в команде.
Но есть нюанс, про который я тогда не задумывался. Каждый из этих обходчиков делает свой проход по одному и тому же дереву. Четыре обхода. Для коротких выражений это незаметно. Для больших — это четырёхкратный объём работы, и часть логики между обходчиками неизбежно дублируется (например, и Visitor, и один из Listener-ов независимо определяют, есть ли в поддереве агрегатная функция).
Сегодня я бы собрал это одним Visitor-ом, возвращающим
Но если у вас нет проблем с производительностью и есть выигрыш в читаемости — разделять на Listener-ы тоже валидно. Главное — не смешивать Listener и Visitor «наугад». Два активных обходчика на одном дереве в одной задаче — это сигнал, что границы ответственности размыты.
Когда Visitor или Listener обрабатывает один узел, ему часто нужны результаты обработки детей — их типы, их транслированный SQL, их флаги. Есть три способа это сделать.
Способ первый, плохой. Взять
Что с этим не так:
Способ второй, ANTLR-ский. Использовать
Выходим из узла — кладём результат на узел. Выходим из родителя — читаем результаты детей с их узлов. Это работает одним проходом и не требует ни повторного парсинга, ни внешнего стека с ручным управлением.
Способ третий, для Visitor-а. Просто возвращать значение из метода. Это даже проще, чем
Когда нужно пройти то же самое поддерево ещё раз с другим контекстом (у меня был этот сценарий для переменных и специальной функции) — не вызывайте парсер заново. Вызовите новый Visitor на том же узле:
Одно дерево — несколько обходчиков с разными задачами. Парсинг выполнен один раз, локации токенов сохранены, производительность в порядке.
ANTLR4 поддерживает два режима грамматики:
У меня грамматика разрослась до объёма, при котором combined становилась неудобной — больше пятисот строк, сорок лексем, два языка ключевых слов (английский + локализованный), несколько десятков правил парсера. Я разделил на два файла сразу, как только стало ясно, что это не hello world на сто строк.
Внутри лексерного файла порядок правил важен: лексер матчит первое подходящее правило в порядке их объявления. Если у вас есть ключевые слова (
Для больших грамматик также полезны
Это чистит грамматику и упрощает правки.
Трансляция — строка или целевое AST?
У меня Listener-транслятор собирал выходной SQL как строку через
Типизация — не все узлы обязаны иметь тип.
В моей системе типов формулы было шесть значений:
Правильнее признать: бывают узлы, у которых нет типа данных, потому что они не представляют значения. Такие узлы просто не участвуют в типовой проверке, а не получают «ненастоящий» тип для единообразия.
Возвращаемый объект парсера — разрезайте по сценариям.
У меня публичный API парсера был прост: один метод
Лучше сразу разделить возвращаемые типы по сценариям:
ANTLR4 — зрелый инструмент с продуманной архитектурой. Большая часть решений в нём уже правильная — нужно просто их знать:
Три центральных приёма, плюс привычки быстро уйти на split grammar при росте объёма, строить целевое AST вместо склейки строк и разделять возвращаемые типы по сценариям — и ваш парсер будет жить долго и не будет болеть, когда добавятся новые операторы, функции и типы.
Версии, которые я использовал: ANTLR4
Это вторая статья в серии материалов из моих проектов. Первая была про Spec-Driven Development на примере Telegram-бота — про то, как я теперь работаю с AI-ассистентами и что это сделало с моим инженерным процессом.
В следующей статье планирую разобрать:
Через эту задачу я плотно познакомился с 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 *[I] 4[/I] парсер построит дерево, где [I]3 *[/I] 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 с[B]ParseTreeProperty[/B]— вместо смеси нескольких активных обходчиков на одном дереве.
Новый обходчик того же узла — вместо повторного парсинга через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 тест).