Интерпретатор (Interpreter)
Правила маленького языка становятся деревом объектов, и каждый узел сам вычисляет свою часть выражения.
Проблема
Служба доставки считает цену по формуле: базовая ставка плюс вес, умноженный на тариф за килограмм. Формулы меняются по регионам и акциям, и их хочется хранить как данные, а не переписывать функцию на каждый случай. Без приёма получается функция с switch по виду формулы, которая знает обо всех операциях сразу: новая операция (минимум, скидка) правит её целиком, а вложенные формулы превращаются в рекурсию с проверками типа на каждом шаге.
Решение
Каждое правило грамматики становится классом с одним методом interpret(context). Expression — общий интерфейс узла, Context — значения переменных, на которых считается формула. Формула base + weight * rate — это дерево: Add(Variable base, Multiply(Variable weight, Variable rate)). Корень вычисляется, спрашивая детей, дети — своих детей, и так до листьев.
Дерево собирается один раз и потом считается сколько угодно раз на разных контекстах: формула отдельно, данные отдельно. Откуда берётся дерево — из кода, конфига или текста через парсер — приём не решает; разбор текста показан в практике.
Структура
Терминальные выражения — листья грамматики: Constant возвращает своё число, Variable берёт значение из контекста по имени. Только они касаются данных, поэтому ошибка «нет такой переменной» живёт в одном месте, а не в каждой операции.
Как это собирается
Деревья формул собирает сервис расчёта доставки при старте: в приложении формулы лежат в таблице тарифов, и для каждого вида доставки один раз строится своё дерево. В примере их два: city считает base + weight * rate, express считает 2 * base + distance * 30. Дальше деревья только читаются, поэтому их можно держать в памяти и делить между запросами.
Контекст, наоборот, собирается на каждый заказ: общие ставки тарифа плюс данные самого заказа. Вид доставки выбирает дерево, контекст подставляет числа. Если формуле не хватает данных, как экспресс-заказу без distance, Variable бросает ошибку "unknown variable", и вызывающий код помечает заказ как "cannot price", а не ставит цену 0. Остальные заказы считаются дальше.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
Нетерминальные выражения Add и Multiply хранят поддеревья как Expression и вычисляют их рекурсивно. Им всё равно, что внизу: число, переменная или ещё одна операция. Новая операция — новый класс рядом, существующие не меняются.
| Язык | Особенность |
|---|---|
| PHP | interface Expression, узлы final readonly; контекст бросает OutOfBoundsException на неизвестной переменной |
| Go | Листья — именованные типы Constant int и Variable string; Interpret возвращает (int, error), общий хелпер binary протаскивает ошибку |
| Python | Protocol и frozen dataclass; контекст — любой Mapping[str, int] |
| TypeScript | Структурный interface Expression, контекст ReadonlyMap; Constant.interpret() без параметра всё равно подходит под интерфейс |
Запуск: make test T=patterns/behavioral/interpreter
Плюсы и минусы
Тесты вычисляют по одной таблице число, переменную, произведение и всю формулу: вызов везде один, interpret(context), так что клиенту всё равно, лист перед ним или поддерево. Второй тест считает одно и то же дерево на двух контекстах (вес 3 и 10 кг, 450 и 800): формула собрана один раз, данные подставляются при вычислении. Третий проверяет, что переменная, которой нет в контексте, падает с её именем, а не молча превращается в ноль.
- ✅ Грамматика читается по классам: одно правило — один класс.
- ✅ Новая операция добавляется новым классом без правки остальных.
- ✅ Дерево — данные: его можно хранить, кэшировать и вычислять на разных контекстах.
- ❌ На каждое правило свой класс: при большой грамматике их десятки, и иерархия становится тяжёлой.
- ❌ Новая операция над деревом (печать, оптимизация, проверка типов) правит все классы узлов; для этого есть Посетитель.
- ❌ Наивное вычисление обходит дерево целиком на каждый вызов; для горячих формул понадобится компиляция.
Когда не применять
- Язык большой или растёт быстро: проще взять генератор парсеров или готовый движок выражений.
- Выражение одно и не меняется: обычная функция понятнее дерева объектов.
- Формулы пишут пользователи без программиста рядом: нужен готовый язык с песочницей и ограничением ресурсов, а не самописный.
Отличия между языками
- PHP — классический вид из книги: интерфейс и по классу на правило.
final readonlyделает дерево неизменяемым, поэтому одно дерево безопасно делится между запросами. - Go — листьям не нужна структура:
Constant— этоint,Variable—stringс методом. Ошибка неизвестной переменной возвращается, а не бросается, поэтому каждый нетерминал её протаскивает; общий хелперbinaryубирает повтор. - Python —
Protocolпроверяется статически, узлы ничего не наследуют. Контекст типизирован какMapping, так что подойдёт иdict, и объект только для чтения. - TypeScript — структурная типизация: метод с меньшим числом параметров совместим с интерфейсом, поэтому
Constantконтекст не принимает. Контекст —ReadonlyMap, а не объект: у литерала{}есть унаследованные ключи вродеtoString.
Практика
Учебное дерево собирается в коде, а настоящая польза приёма — когда выражение пишет человек. Практика: фильтры товаров разбирает строку price > 100 and category = "books" or not in_stock токенизатором и рекурсивным парсером с приоритетами в дерево фильтров, вычисляет его на товарах и падает с позицией в строке на опечатках и неизвестных полях.
Связанные темы
- Практика: фильтры товаров — мини-язык условий, парсер с приоритетами и ошибки с позицией
- Компоновщик (Composite) — дерево выражений и есть компоновщик: интерпретатор добавляет ему смысл грамматики.
- Стратегия (Strategy) — когда правил немного и они не вкладываются друг в друга, хватит набора стратегий.