Невыразимое невалидное. Часть 2. Поведение и границы

от автора

В первой части мы разобрались с представлением данных. Мы сделали невалидные состояния невозможными для выражения в рамках модели. В этой части мы разберемся с тем, как можно выражать поведение таких моделей, и где проходит граница между тем, что ловит чекер, и тем, что придется оставить рантайму.

Где живёт поведение

Когда мы говорим о некотором семействе типов, вокруг которых должно строиться поведение, то здесь есть как минимум два подхода:

  1. Оставить модель анемичной записью, а поведение вынести в функцию со сверткой типов:

def _render_predicate(self, p: Predicate, params) -> str:    column = self._column(p.field)    match p:        case NumericRangePredicate() | DateRangePredicate():            ...  # BETWEEN low AND high        case SetPredicate():            ...  # IN (...)        case NumericPredicate() | DatePredicate() | CategoryPredicate():            ...  # column <op> value        case _:            typing.assert_never(p)

Удобно, когда операций много, каждая новая операция это новая функция в одном месте, но приходится платить тем, что, при добавлении нового типа, придётся обойти все такие функции.

  1. Спрятать поведение внутрь, по методу на каждый вариант:

class FilterDefinition(BaseModel, abc.ABC):    field: Field    field_type: FieldType    # ...    @abc.abstractmethod    def to_predicate(self, value: FilterValue | None = None) -> Predicate | None: ...class NumericFieldFilter(FilterDefinition):    field_type: typing.Literal[FieldType.NUMERIC] = FieldType.NUMERIC    operator: EqualityOperator | OrderOperator | RangeOperator    # ...        def to_predicate(self, value=None) -> NumericPredicate | NumericRangePredicate | None:        ...

Теперь дёшево добавлять новые типы, но дорого добавлять операции, новый метод нужно будет дописать в каждый класс.

Проблема выражения: сумма-тип против полиморфизма

Проблема выражения: сумма-тип против полиморфизма

Так можно ли добавлять и новые варианты данных, и новые операции, не переписывая старое и не теряя проверок типов? Давайте посмотрим на таблицу выше. Функциональный стиль режет таблицу по столбцам: операция видит сразу все строки. ООП режет по строкам: класс реализует сразу все столбцы. Что дёшево добавлять по одной оси, дорого по другой, без трейд-оффа здесь обойтись не выйдет. Фил Вадлер описал это как expression problem.

Выбирают обычно по тому, что будет меняться чаще. Если у сущности множатся операции, а набор вариантов стабилен, то берём сумму-тип со свёртками снаружи. Если же варианты множатся, а операция по сути одна, то берём классы с методом. Видеть оба подхода рядом в одной кодовой базе тоже нормально: у предиката стабильные варианты и куча операций над ними, у модели фильтра наоборот.

Когда добавляешь вариант и ничего не забываешь

Вернёмся к свёртке из прошлого раздела:

        case _:            typing.assert_never(p)

assert_never — функция, у аргумента которой тип Never, тип без значений. Пока match разбирает все варианты, в ветку case _ не попадает ни один: для тайп-чекера p там сужен до Never. Если добавим новый вариант и забудем его указать где-то, то p окажется не Never, и assert_never перестанет проходить проверку типов. То есть тайп-чекер сам нам укажет на все места, где нам нужно внести изменения.

Добавим вариант

Допустим, понадобилось новое условие: «поле пустое / не пустое», IS NULL.

class NullPredicate(BaseModel):    kind: typing.Literal["null"] = "null"    field: Field    operator: PresenceOperator   # is_null, is_not_null    # значения нет

Добавим его в union:

Predicate = typing.Annotated[    NumericPredicate | ... | SetPredicate | NullPredicate,    Field(discriminator="kind"),]

Выбирая поведение в функции, мы приняли трейд-офф в виде необходимости дописать новый тип в каждую операцию. Но благодаря assert_never этот обход превращается из «не забыть бы» в гарантированный. Запускаем тайп-чекер, и он немедленно укажет нам на каждую функцию, где match заканчивался на assert_never:

error: Argument 1 to "assert_never" has incompatible type "NullPredicate"; expected "Never"  [arg-type]

Для сравнения, на стороне FilterDefinition тот же шаг — один новый подкласс, и ничего не ломается. Зато новая операция потребовала бы такого же обхода по всем классам, и честность гарантировалась бы @abstractmethod.

Полнота там, где чекер не дотянется

К сожалению, не всё можно поручить тайп-чекеру. Скажем, есть таблица «тип поля → класс фильтра», и она обязана покрывать все типы полей до единого. Тайп-чекер этого не проверит. Зато можно проверить на старте, при импорте модуля:

field_type_filter_mapping = {    _filter_field_type(variant): variant    for variant in typing.get_args(typing.get_args(FilterType)[0])}assert set(field_type_filter_mapping) == set(FieldType), "каждому типу поля нужен фильтр"

Тут мы используем два приёма сразу. Во-первых, варианты не выписаны руками, а вытащены из самого union через typing.get_args, один источник правды, список не разъедется с определением. Вложенный get_args нужен, потому что FilterType — это Annotated[Union[…], …]. Во-вторых, assert на полноту, если добавил тип поля, но забыл фильтр, то модуль просто не импортируется, получим ошибку при старте приложения. То, что в языке с проверкой полноты доказал бы компилятор, мы утверждаем руками в момент загрузки. Это не много, но это лучше, чем ничего.

Тег это контракт, а не деталь реализации

Есть тонкость, которую легко проглядеть. Значение тега kind это не внутренняя деталь. Как только размеченный объект уехал в JSON, лёг в базу или ушёл другому сервису, строка тега стала контрактом с внешним миром. Переименовать класс NumericPredicate ничего не стоит. Переименовать его тег "numeric" это тихо сломать каждую сохранённую строку и каждого клиента, который всё ещё шлёт старое значение.

Отсюда возникает рекомендация значения тегов держат стабильными и отвязанными от имён классов. Мне нравится практика неймспейсинга, например, использовать "filter/numeric" вместо просто "numeric".

Что происходит при добавлении варианта в уже работающей системе?

  • Старые данные, новый код. В новом коде есть NullPredicate, но в старом сохранённом JSON тега "null" просто нет, всё парсится как раньше. Добавление варианта обратно-совместимо само по себе.

  • Новые данные, старый код. А это уже больно. Старый сервис получает kind: "null", которого не знает, и дискриминированный union честно падает. Поведение правильное (лучше громко, чем молча), но требует дисциплины: катить продьюсеров после консьюмеров, держать окно совместимости или заранее учить старый код терпеть незнакомые теги.

Потолок

Соберём вместе всё, что за обе части так и осталось рантайму: start ≤ end у диапазона, «у выражения без оператора ровно один операнд», совпадение типа поля и варианта предиката, len(s) ≥ 1 у непустой строки. Это случаи зависимости значения от значения. Тип «диапазон, у которого начало не больше конца» нельзя описать, не позволив типу заглянуть внутрь значений.

Языки, которые так умеют, существуют: зависимые типы в Idris, Agda, Lean, F*, уточняющие типы в Liquid Haskell. Там Range мог бы иметь тип, гарантирующий start ≤ end ещё на компиляции, и нарушающее значение не собралось бы вовсе. Python сознательно остаётся на прагматичном уровне: статика держит структуру (какой вариант, какой тип значения, полнота разбора), зависимости значений проверяет рантайм, а pydantic делает эту проверку дешёвой и в одном месте.

Где ловится ошибка: компиляция, рантайм или нигде

Где ловится ошибка: компиляция, рантайм или нигде

Что в итоге

Граница теперь видна целиком. Тайп-чекер держит структуру и, через assert_never, полноту разбора. Стартовые assert закрывают полноту таблиц, до которых чекер не дотягивается.

«Сделать невалидные состояния невыразимыми» — это не призыв затащить в типы вообще всё. В Python всё и не затащишь. Нужно подвести черту: что сделать невыразимым (неправильную пару оператор-значение, пустую строку там, где нужна непустая, незакрытый вариант в match), а что честно оставить рантайму. Хорошо проведённая черта стоит больше, чем максимум аннотаций. А value: Any с валидатором на сто строк, с которого мы начинали, это просто отсутствие такой границы.

ссылка на оригинал статьи https://habr.com/ru/articles/1053662/