2025, Nov 01 08:47

Связь «сырых» и обработанных типов в Python через Protocol

Как задать в Python типобезопасное соответствие «сырых» и обработанных классов с помощью Protocol и дженериков, без overload; поддержка mypy и Pyright.

Сопоставление набора «сырых» классов с их обработанными аналогами кажется простым на этапе выполнения, но быстро дает сбой, как только вы хотите, чтобы тайпчекер вывел возвращаемый тип из входного. Обычный словарь соответствий класс→класс не помогает статическому анализу, а аннотирование функции с дженериковым входом и неточным типом результата приводит к Any там, где хотелось бы точной передачи типа.

Постановка задачи

Цель — передать экземпляр «сырого» типа и добиться, чтобы проверяющий типы понял, какой именно обработанный тип вернётся. Наивный подход — описать соответствие словарём и оставить одну функцию конвертации:

from typing import Any

class X:
    pass

class Y:
    pass

class PreX:
    pass

class PreY:
    pass

mappings = {
    PreX: X,
    PreY: Y,
}


def materialize[TOrig: Any](item: TOrig) -> Any:
    ...

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

Почему словарь соответствий не управляет типами

Проверяющие типы не читают словари времени выполнения, чтобы выводить дженерики. Таблица соответствий класс→класс существует лишь во время исполнения, тогда как типизация требует статического контракта, связывающего экземпляр «сырого» типа с соответствующим обработанным типом. Без такого контракта чекер видит у materialize результат Any или другой слишком общий тип, и вы теряете преимущества точного сужения типов.

Решение на основе Protocol

Идея — вынести соответствие в типовую поверхность, дав каждому «сырому» классу общий атрибут, указывающий на его обработанный класс. Затем это ожидание описывается через Protocol, параметризованный обработанным типом. Как только функция принимает такой Protocol, параметр типа разрешается в нужный конкретный тип.

class X: ...
class Y: ...

class PreX:
    bind = X  # связь на уровне класса с обработанным типом

class PreY:
    bind = Y  # связь на уровне класса с обработанным типом
from typing import Protocol

class Carrier[T](Protocol):
    @property
    def bind(self) -> type[T]: ...


def materialize[T](item: Carrier[T]) -> T: ...
reveal_type(materialize(PreX()))  # X
reveal_type(materialize(PreY()))  # Y

Таким образом соответствие зашивается в сами типы. Каждый «сырой» класс несёт атрибут класса, обозначающий конкретный целевой тип; Protocol фиксирует эту связь, а дженериковая функция возвращает именно этот тип.

Есть тонкость с атрибутами уровня класса и протоколами для экземпляров. Атрибут, которым связываем обработанный тип, объявлен на классе, но обращаемся к нему через экземпляры. Поэтому в типовой аннотации его нельзя явно пометить как переменную класса в рамках этого приёма. Как уже отмечалось, здесь нет способа выразить отношение «экземпляр → атрибут уровня класса» в протоколах.

Невозможно выразить отношение «экземпляр → протокол на уровне класса» (например, Instance[Raw[T]]).

Зачем нужен такой подход

Когда нужно, чтобы тайпчекер следовал соответствию между «сырыми» и обработанными типами без ручного перечисления множества overload’ов, Protocol даёт масштабируемый структурный контракт. Соответствие хранится рядом с типами, а не в отдельном словаре времени выполнения, поэтому инструменты статического анализа могут протянуть T от входа к выходу. Этот паттерн корректно работает с современными проверяющими типы, такими как Mypy, Pyright и Pyrefly.

Выводы

Держите соответствие в системе типов, а не в структуре, живущей только во время выполнения. Дайте каждому «сырому» классу общий атрибут, указывающий на его обработанный класс. Опишите это ожидание через параметризованный Protocol и типизируйте функцию преобразования через него. Если вы думали об overload, этот вариант избавляет от хрупкого перечисления случаев и при этом сохраняет точные возвращаемые типы. В итоге получается простой и поддерживаемый контракт, дружелюбный к статическому анализу.

Статья основана на вопросе на StackOverflow от JoniKauf и ответе от InSync.