handbookНаписать

patterns/behavioral/interpreter/README.md · тема · 4 языка · 27 тестов

+ практика: фильтры товаров

Интерпретатор (Interpreter) ​

Правила маленького языка становятся деревом объектов, и каждый узел сам вычисляет свою часть выражения.

Проблема ​

Служба доставки считает цену по формуле: базовая ставка плюс вес, умноженный на тариф за килограмм. Формулы меняются по регионам и акциям, и их хочется хранить как данные, а не переписывать функцию на каждый случай. Без приёма получается функция с switch по виду формулы, которая знает обо всех операциях сразу: новая операция (минимум, скидка) правит её целиком, а вложенные формулы превращаются в рекурсию с проверками типа на каждом шаге.

Решение ​

Каждое правило грамматики становится классом с одним методом interpret(context). Expression — общий интерфейс узла, Context — значения переменных, на которых считается формула. Формула base + weight * rate — это дерево: Add(Variable base, Multiply(Variable weight, Variable rate)). Корень вычисляется, спрашивая детей, дети — своих детей, и так до листьев.

Дерево собирается один раз и потом считается сколько угодно раз на разных контекстах: формула отдельно, данные отдельно. Откуда берётся дерево — из кода, конфига или текста через парсер — приём не решает; разбор текста показан в практике.

Структура ​

left, right

left, right

reads

«interface»

Expression

+interpret(context) : int

Context

+lookup(name) : int

Constant

-value: int

+interpret(context) : int

Variable

-name: string

+interpret(context) : int

Add

-left: Expression

-right: Expression

+interpret(context) : int

Multiply

-left: Expression

-right: Expression

+interpret(context) : int

Терминальные выражения — листья грамматики: 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 и вычисляют их рекурсивно. Им всё равно, что внизу: число, переменная или ещё одна операция. Новая операция — новый класс рядом, существующие не меняются.

ЯзыкОсобенность
PHPinterface Expression, узлы final readonly; контекст бросает OutOfBoundsException на неизвестной переменной
GoЛистья — именованные типы Constant int и Variable string; Interpret возвращает (int, error), общий хелпер binary протаскивает ошибку
PythonProtocol и 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 токенизатором и рекурсивным парсером с приоритетами в дерево фильтров, вычисляет его на товарах и падает с позицией в строке на опечатках и неизвестных полях.

Связанные темы ​

© 2014-2026 Тимур ТурдыевСайт и справочник работают на VPS от AdminVPS