Правила как данные: Interpreter, Specification и композиция предикатов
Есть класс задач, где обычный код проигрывает не по красоте, а по скорости изменения. Скидочные правила, антифрод, тарификация, роутинг заявок, право доступа, условия выдачи кредита, критерии попадания в рассылку. Общее у них одно: правило меняется чаще, чем выходит релиз, и меняет его не тот, кто пишет код.
Типичная эволюция такого места известна заранее. Сначала один if. Через полгода — метод на 300
строк с двенадцатью вложенными условиями, который никто не рискует трогать. Ещё через год — тот же
метод, но с флагами is_legacy_promo_2024, и никто в компании не может ответить на вопрос
«какие скидочные правила сейчас действуют» иначе как чтением кода.
Эта глава — про третий путь между «всё в коде» и «купим движок правил». Он называется правило как объект: предикат становится значением, значения композируются, композиция исполняется — и, что важнее, проверяется, логируется и объясняется.
Карта главы
Это не проблема"] B -->|"Раз в квартал, много вариантов"| D["Specification:
предикат как объект"] B -->|"Раз в неделю, меняет не разработчик"| E["Правило как данные:
таблица решений, JSON, выражение"] B -->|"Своя грамматика, вложенность"| F["Interpreter: AST + вычислитель"] D --> G["Композиция and/or/not"] D --> H["Одно правило — две интерпретации:
проверка в памяти и WHERE в SQL"] E --> I["Версионирование, аудит,
теневой режим, откат"] F --> J["Если горячо — компиляция AST
в замыкания"] F --> K["Если грамматика растёт —
это уже язык, берите готовый"]
Specification: предикат, ставший объектом
Specification — паттерн, в котором логическое условие представлено объектом с методом «удовлетворяет ли кандидат», а сложные условия строятся композицией простых. Формализован Эриком Эвансом и Мартином Фаулером в «Specifications» (2002).
Структурно это Composite над предикатами и одновременно вырожденный Interpreter: у него есть грамматика из трёх операций (И, ИЛИ, НЕ) и листья-условия.
Зачем это, если есть обычные функции? Функция lambda c: c.age >= 18 тоже предикат. Разница
в интроспекции: объект-спецификацию можно не только вызвать, но и разобрать — перевести
в SQL, показать пользователю на человеческом языке, сохранить в базу, сравнить с другой
спецификацией, объяснить, почему кандидат не подошёл. Замыкание всего этого не умеет: внутрь
функции заглянуть нельзя.
from dataclasses import dataclass
from typing import Any
class Spec:
"""Базовый класс: даёт композицию через операторы & | ~."""
def is_satisfied_by(self, c: "Customer") -> bool:
raise NotImplementedError
def __and__(self, other: "Spec") -> "Spec":
return And(self, other)
def __or__(self, other: "Spec") -> "Spec":
return Or(self, other)
def __invert__(self) -> "Spec":
return Not(self)
@dataclass(frozen=True)
class And(Spec):
left: Spec
right: Spec
def is_satisfied_by(self, c) -> bool:
# Короткое замыкание: правая часть может быть дорогой (запрос, вызов сервиса).
return self.left.is_satisfied_by(c) and self.right.is_satisfied_by(c)
@dataclass(frozen=True)
class Or(Spec):
left: Spec
right: Spec
def is_satisfied_by(self, c) -> bool:
return self.left.is_satisfied_by(c) or self.right.is_satisfied_by(c)
@dataclass(frozen=True)
class Not(Spec):
inner: Spec
def is_satisfied_by(self, c) -> bool:
return not self.inner.is_satisfied_by(c)
# --- листья: каждое условие живёт в одном месте и имеет имя ---
@dataclass(frozen=True)
class AgeAtLeast(Spec):
years: int
def is_satisfied_by(self, c) -> bool:
return c.age >= self.years
@dataclass(frozen=True)
class CountryIn(Spec):
codes: frozenset[str]
def is_satisfied_by(self, c) -> bool:
return c.country in self.codes
@dataclass(frozen=True)
class OrdersAtLeast(Spec):
count: int
def is_satisfied_by(self, c) -> bool:
return c.orders_count >= self.count
Правило маркетинга «совершеннолетние из России или Казахстана, у кого от трёх заказов, кроме корпоративных клиентов» превращается в выражение, которое читается как исходная фраза:
loyal = (
AgeAtLeast(18)
& CountryIn(frozenset({"RU", "KZ"}))
& OrdersAtLeast(3)
& ~IsCorporate()
)
if loyal.is_satisfied_by(customer):
apply_discount(customer, percent=10)
Сложность. Проверка — O(n) по числу листьев, память — O(d) по глубине дерева на стек
рекурсии. Практически это ничто по сравнению с любым обращением к базе, но при 10 000 правил на
событие структура начинает стоить денег — об этом ниже, в разделе про масштаб.
Главная выгода: одно правило — две интерпретации
Вот сцена, знакомая любому, кто это писал. Правило «лояльный клиент» нужно в двух местах:
- Проверить конкретного клиента — когда он оформляет заказ.
- Найти всех таких — когда маркетинг делает рассылку.
Наивное решение: написать предикат в Python и отдельно написать WHERE в SQL. Через месяц
маркетинг поменяет «от трёх заказов» на «от пяти», кто-то поправит одно место из двух, и система
начнёт давать скидку тем, кому не рассылает, и наоборот. Это не гипотеза, это самый частый баг
такого рода.
Решение: вторая интерпретация той же структуры. Дерево спецификации разбирается и переводится в SQL — это буквально Visitor из поведенческих паттернов, только записанный через сопоставление с образцом.
def to_sql(spec: Spec) -> tuple[str, list[Any]]:
"""Переводит спецификацию в фрагмент WHERE и список параметров.
Параметры возвращаются отдельно — конкатенация значений в строку запроса
была бы SQL-инъекцией. Это обязательное свойство, а не стилистика.
"""
match spec:
case And(left, right):
ls, lp = to_sql(left)
rs, rp = to_sql(right)
return f"({ls} AND {rs})", lp + rp
case Or(left, right):
ls, lp = to_sql(left)
rs, rp = to_sql(right)
return f"({ls} OR {rs})", lp + rp
case Not(inner):
s, p = to_sql(inner)
return f"(NOT {s})", p
case AgeAtLeast(years):
return "age >= %s", [years]
case CountryIn(codes):
return "country = ANY(%s)", [sorted(codes)]
case OrdersAtLeast(count):
return "orders_count >= %s", [count]
case _:
# Явный отказ лучше молчаливого расхождения интерпретаций.
raise NotImplementedError(f"нет SQL-перевода для {type(spec).__name__}")
where, params = to_sql(loyal)
rows = conn.execute(f"SELECT id, email FROM customers WHERE {where}", params)
Три детали, которые отличают рабочий код от учебного:
raise NotImplementedErrorвcase _. Спецификация, которую нельзя перевести в SQL (например, «клиент звонил в поддержку за последние сутки» — данные в другой системе), должна падать явно, а не тихо выпадать из условия. Молчаливое расхождение двух интерпретаций — худший исход из возможных.- Параметры отдельно от текста. См. инъекции.
- Согласованность проверяется тестом. Свойство «для любого клиента
is_satisfied_byи SQL дают одинаковый ответ» — идеальная мишень для property-based тестирования: генерируем случайных клиентов, сохраняем в тестовую базу, сверяем два ответа. Один такой тест закрывает целый класс расхождений.
Тот же приём переводит спецификацию в человеческий текст («вам нужно ещё 2 заказа») — это уже не техническая, а продуктовая ценность: пользователю показывают причину, а не просто «не подходит».
Interpreter: когда правила складываются в маленький язык
Спецификации хватает, пока условия строятся из фиксированного набора листьев. Дальше приходит запрос: «пусть аналитик сам пишет условие в админке». Это переход к Interpreter — единственному паттерну GoF, который в предыдущих главах трека упоминался только в классификации.
Interpreter — паттерн, в котором для простого языка определяется представление грамматики в виде дерева объектов и вычислитель, обходящий это дерево. Формула из GoF: одному правилу грамматики соответствует один класс.
Наивный вычислитель — рекурсивный обход:
def evaluate(node, env: dict[str, float]) -> Any:
match node:
case Const(v):
return v
case Var(name):
return env[name]
case Add(a, b):
return evaluate(a, env) + evaluate(b, env)
case Mul(a, b):
return evaluate(a, env) * evaluate(b, env)
case Gt(a, b):
return evaluate(a, env) > evaluate(b, env)
case AndNode(a, b):
return evaluate(a, env) and evaluate(b, env)
Стоимость: O(n) по числу узлов на каждое вычисление, где вся работа — это диспетчеризация по
типу узла и рекурсивные вызовы. Для правила, применяемого миллион раз в час, накладные расходы
дерева начинают доминировать над полезной арифметикой.
Приём, который стоит знать: компиляция дерева в замыкания
Обход дерева повторяется при каждом вызове, хотя структура дерева не меняется. Значит, диспетчеризацию можно выполнить один раз — на этапе подготовки, а не на этапе вычисления. Это частичное вычисление (partial evaluation), и в динамических языках оно даёт кратный выигрыш:
from typing import Callable
Program = Callable[[dict[str, float]], Any]
def compile_expr(node) -> Program:
"""Обходит дерево ОДИН раз и возвращает замыкание, которое считает быстро."""
match node:
case Const(v):
return lambda env, v=v: v
case Var(name):
return lambda env, name=name: env[name]
case Add(a, b):
fa, fb = compile_expr(a), compile_expr(b) # диспетчеризация — здесь
return lambda env: fa(env) + fb(env) # горячий путь — только вызовы
case Mul(a, b):
fa, fb = compile_expr(a), compile_expr(b)
return lambda env: fa(env) * fb(env)
case Gt(a, b):
fa, fb = compile_expr(a), compile_expr(b)
return lambda env: fa(env) > fb(env)
case AndNode(a, b):
fa, fb = compile_expr(a), compile_expr(b)
return lambda env: fa(env) and fb(env)
rule = compile_expr(parse("amount * 1.2 > limit and score > 700"))
# Дальше миллион вызовов rule(env) не трогают дерево вообще.
Тот же смысл на статически типизированных платформах носят деревья выражений: Expression<Func<T,bool>>
в C# компилируется в делегат через expr.Compile(), LINQ-провайдеры переводят его же в SQL —
это буквально «две интерпретации одного дерева» из предыдущего раздела, встроенные в платформу.
Где Interpreter перестаёт быть уместным
GoF честно пишут: паттерн применим для простых грамматик. Признаки, что вы вышли за границу:
| Признак | Что это значит | Что делать |
|---|---|---|
| Нужны переменные, присваивание, циклы | Вы пишете язык программирования | Взять готовый: Lua, Starlark, CEL |
| Нужны понятные сообщения об ошибках с позицией | Нужен настоящий парсер и лексер | Разбор и парсеры, парсер-комбинаторы из функциональных паттернов |
| Правила пишет пользователь, а исполняет ваш сервер | Появляется модель угроз | Песочница, лимиты по времени и памяти, никогда eval |
| Грамматика растёт каждый спринт | Это продукт, а не деталь | Проектирование DSL |
Отдельно про eval. Соблазн «пусть аналитик пишет питон-выражение, а мы вызовем eval» стоит
компании инцидента: выражение исполняется с правами процесса, а __import__("os").system(...)
внутри строки — рабочая нагрузка, а не теория. Если нужен пользовательский язык выражений,
берите изолированный вычислитель: CEL (Google, используется
в Kubernetes и Envoy), Starlark,
JsonLogic, OPA/Rego для политик доступа.
Правила как данные: таблица решений
Когда условий много, а структура у них плоская, дерево объектов избыточно — лучше работает таблица решений: строки-правила, столбцы-условия, отдельный столбец с результатом.
# rules/shipping.yaml — правило редактирует логист, а не разработчик.
version: 7
rules:
- id: free-express-msk
when: {country: RU, city: Москва, total_gte: 5000, weight_lt_kg: 10}
then: {method: express, price: 0}
valid_from: 2026-07-01
- id: standard-ru
when: {country: RU, total_gte: 0}
then: {method: standard, price: 350}
- id: fallback
when: {}
then: {method: pickup, price: 0}
def match_rule(order, rules: list[Rule]) -> Rule:
"""Первое совпадение выигрывает. Порядок — часть смысла таблицы,
поэтому он фиксируется в файле, а не в порядке обхода словаря."""
for rule in rules:
if rule.valid_from and order.created_at < rule.valid_from:
continue
if all(cond.matches(order) for cond in rule.conditions):
# Решение и его причину логируем вместе: без этого разбор
# обращения «почему мне посчитали 350 рублей» невозможен.
audit.log(order_id=order.id, rule_id=rule.id, version=RULES_VERSION)
return rule
raise NoRuleMatched(order.id)
Два свойства, которые нужно закладывать сразу, иначе через полгода будет больно:
- Полнота. Последнее правило-заглушка обязано срабатывать всегда.
NoRuleMatchedв проде — это отказ обслуживания на ровном месте. Проверка на пересечение и полноту правил — статический анализ таблицы, который стоит написать один раз и гонять в CI. - Объяснимость. Записывайте не только результат, но и идентификатор сработавшего правила и версию таблицы. Это превращает часовое расследование в один запрос в логи.
Жизненный цикл правила
Как только правила становятся данными, они перестают проходить через код-ревью и релизный цикл. Значит, свой цикл нужно построить для них — иначе вы получили не гибкость, а незащищённый прод.
сравниваем с текущим правилом Теневой --> Активно: расхождения объяснены и приняты Теневой --> Черновик: расхождения неприемлемы Активно --> Выведено: заменено новой версией Активно --> Откат: инцидент Откат --> Активно: вернули предыдущую версию Выведено --> [*] note right of Теневой Теневой режим — главный приём. Он даёт цифру «правило изменит решение для 3.1% заказов» ДО того, как это случится в проде. end note
Минимальный набор требований к системе правил, которую пишете сами: схема и валидация; версия у каждой таблицы; неизменяемая история (кто, когда, что поменял); прогон на исторических данных; теневой режим; откат за одну команду; и обязательный аудит применённого правила в каждом решении. Если на всё это нет ресурса — не делайте правила данными, оставьте их в коде: код хотя бы проходит ревью, тесты и умеет откатываться релизом.
Масштаб: когда правил становятся тысячи
Линейный перебор правил при 10 000 правил и 5 000 событий в секунду — это 50 миллионов проверок в секунду, и здесь появляются приёмы посерьёзнее:
- Индексация по дискриминатору. Большинство правил применимы к узкой группе событий. Группируем правила в словарь по типу события/стране/категории — перебор сокращается на порядки. Это самый дешёвый и самый эффективный шаг.
- Порядок условий по стоимости и селективности. Дешёвое и отсекающее — первым, вызов внешнего
сервиса — последним. Короткое замыкание
andделает остальное. - RETE — алгоритм из систем продукционных правил (лежит в основе Drools): строит сеть общих подусловий и переиспользует промежуточные результаты между правилами. Оправдан, когда правил тысячи и они сильно пересекаются; цена — память и совсем другой уровень сложности отладки.
- Кэширование результата для неизменной части входа — см. кэширование.
Типичные ошибки
| Ошибка | Как выглядит | Что делать |
|---|---|---|
| Спецификация ради двух условий | Пять классов вместо одного if |
Порог: 3+ комбинируемых условия или вторая интерпретация |
| Две реализации одного правила | Предикат в коде и WHERE в SQL живут отдельно |
Один источник: дерево + переводчики; property-тест на согласие |
Молчаливый case _ |
Непереводимый лист тихо выпадает из фильтра | Явный NotImplementedError |
eval пользовательской строки |
«Мы же доверяем аналитикам» | Изолированный вычислитель: CEL, Starlark, JsonLogic |
| Правила без версии и аудита | «Почему клиенту дали скидку?» — нет ответа | Версия таблицы + идентификатор правила в каждом решении |
| Нет правила по умолчанию | NoRuleMatched в проде |
Обязательная заглушка + проверка полноты в CI |
| Выкатка правила сразу в прод | Инцидент вместо эксперимента | Теневой режим, прогон на истории |
| Свой язык вместо готового | Полгода пишем парсер и отладчик | Взять CEL/Starlark/Lua |
| Внутренняя платформа | Конфиг превратился в недоязык с циклами | Признать, что это код, и вернуть его в код |
Последний пункт — известный антипаттерн inner-platform effect, частный случай спекулятивной обобщённости: система конфигурации дорастает до кривой копии языка программирования, только без отладчика, тестов и типов.
Как это выглядит в проде
- Django
Q-объекты и SQLAlchemy-выражения — спецификации в чистом виде: композируются через&,|,~и компилируются в SQL. Если писали на них — вы уже применяли паттерн. - Elasticsearch Query DSL, MongoDB-фильтры — дерево условий как данные, отправляемое по сети.
- CEL — язык выражений Google: используется в Kubernetes (валидация CRD), Envoy, IAM-политиках; специально спроектирован так, чтобы вычисление всегда завершалось.
- OPA/Rego — политики доступа как данные, вынесенные из сервисов.
- Drools / RETE — классический промышленный движок правил в банках и страховании.
- Фича-флаги — вырожденный случай правил как данных: условие «показывать ли фичу» вынесено из кода в панель.
- LINQ и
Expression<Func<T, bool>>в .NET — платформенная реализация «одно дерево, два вычислителя».
Мини-итог
- Правило как объект оправдано, когда условие композируется, меняется часто или нужно в двух
местах сразу. Для двух стабильных условий
ifостаётся правильным ответом. - Specification = Composite над предикатами. Главная выгода не в композиции, а в интроспекции: дерево можно перевести в SQL, в текст, в объяснение отказа.
- Одно правило — один источник истины. Две независимые реализации одного условия расходятся всегда; спасает общий разбор дерева плюс тест на согласие интерпретаций.
- Interpreter уместен для маленькой стабильной грамматики. Если грамматика растёт — берите готовый язык выражений, а не пишите свой.
- Дерево обходится один раз: компиляция AST в замыкания убирает диспетчеризацию с горячего пути.
- Правила-данные требуют собственного релизного цикла: схема, версия, прогон на истории, теневой режим, аудит, откат. Без него гибкость оборачивается неуправляемым продом.
- Никогда не исполняйте пользовательские выражения через
eval.
Источники
- Eric Evans, Martin Fowler, «Specifications», 2002 — martinfowler.com/apsupp/spec.pdf. Первоисточник паттерна.
- Erich Gamma et al., «Design Patterns», 1994 — глава Interpreter, включая честное ограничение «для простых грамматик».
- Martin Fowler, «Domain-Specific Languages», 2010 — различие внутренних и внешних DSL, семантическая модель, таблицы решений.
- Google CEL — github.com/google/cel-spec: язык выражений с гарантией завершаемости.
- JsonLogic — jsonlogic.com: правила как JSON, исполняемые и на сервере, и в браузере.
- Open Policy Agent — openpolicyagent.org: политики как данные.
- Charles Forgy, «Rete: A Fast Algorithm for the Many Pattern/Many Object Pattern Match Problem», Artificial Intelligence, 1982 — dl.acm.org.
- Microsoft, «Expression Trees» — learn.microsoft.com: как одно дерево компилируется в делегат и в SQL.
- Python, PEP 636 «Structural Pattern Matching: Tutorial» — peps.python.org.
Что дальше
Правила как данные — частный случай более общей задачи: дать поведению системы меняться без пересборки. Следующая глава про её максимум — точки расширения и плагины: реестры, контракты расширений, обнаружение и загрузка чужого кода, изоляция ошибок и версионирование того, что однажды опубликовано наружу.