2025, Sep 25 13:17
Типобезопасный реестр в Python: возврат экземпляра по подклассу
Как смоделировать в Python реестр «подкласс → экземпляр», совместим с mypy и Pyright: приватный dict[type[Root], Root], типизированный fetch и точный cast.
Когда в кодовой базе поддерживается единое сопоставление классов их экземплярам, естественно захотеть метод, который возвращает «экземпляр, соответствующий переданному классу», и чтобы этот факт подтверждался проверкой типов. Задача в том, чтобы выразить эту связь для статического анализа: при запросе подкласса получить экземпляр именно этого подкласса, а не просто базового типа.
Постановка задачи
Рассмотрим небольшую иерархию наследования и реестр, который концептуально сопоставляет подклассы их экземплярам. Интуитивная аннотация типов здесь — словарь, где ключи представляют подклассы, а значения — экземпляры совпадающего типа. На первом приближении это часто выглядит так:
from typing import TypeVar
class Root:
    ...
class KindOne(Root):
    ...
class KindTwo(Root):
    ...
class Registry:
    U = TypeVar('U', bound=Root)
    entries: dict[type[U], U] = {}
    def fetch(self, cls: type[U]) -> U:
        return self.entries[cls]
Замысел понятен: словарь сопоставляет KindOne → KindOne(), а KindTwo → KindTwo(). Метод fetch должен возвращать ровно запрошенный тип, а не просто Root.
Что происходит на самом деле
Прямой тип словаря, связывающий type[U] и U, выглядит убедительно, но со статическими проверяющими он работает не лучшим образом. Зависимость «для каждого ключа‑подтипа соответствующее значение — экземпляр того же подтипа» действует на уровне конкретной пары ключ–значение, а не равномерно для всей структуры. Параметры типов словаря описывают все записи одинаково, и большинство проверяющих не будут отслеживать эту зависимость при произвольных обращениях к словарю.
Если цель — дать только get‑подобную точку входа и скрыть внутреннее хранилище, есть прагматичный вариант. Реестр может хранить значения, используя базовый класс и для ключей, и для значений, а публичный метод выполняет точечное приведение к запрошенному подтипу. Для пользователей API результат — чёткий и точный возвращаемый тип; внутри же сопоставление остаётся простым, а приведение служит небольшим мостиком, выражающим намерение.
Рабочий подход с типизированной точкой входа
Следующий приём корректно работает с популярными статическими проверяющими, такими как mypy и Pyright, при этом реализация остаётся прямолинейной:
from typing import cast, reveal_type
class Root:
    ...
class KindOne(Root):
    ...
class KindTwo(Root):
    ...
class Registry:
    _store: dict[type[Root], Root] = {KindOne: KindOne(), KindTwo: KindTwo()}
    def fetch[T: Root](self, cls: type[T]) -> T:
        return cast(T, self._store[cls])
reg = Registry()
reveal_type(reg.fetch(KindOne))  # KindOne
reveal_type(reg.fetch(KindTwo))  # KindTwo
Сопоставление остаётся скрытым как dict[type[Root], Root]. Публичный метод fetch несёт точную связь типов через параметр типа и использует cast, отражая факт, что по построению сохранённый экземпляр соответствует запрошенному классу.
Подход намеренно минималистичен. Можно разработать отдельную абстракцию над словарём, чтобы формально зафиксировать ограничение, но веских причин выходить за рамки приватного dict и одного хорошо типизированного аксессора здесь нет.
Зачем это важно
Точно аннотированные API снижают вероятность неправильного использования в клиентском коде. Передали KindOne — получили KindOne, и это подтверждает проверяющий. Значит, меньше лишних приведений, меньше подавлений предупреждений и более ясные контракты в месте вызова.
При этом важно сохранять трезвость ожиданий. Приведение выражает намерение и помогает вызывающей стороне, но не заставляет проверяющий подтверждать, что приватное хранилище всегда соблюдает соответствие «ключ → значение». Если внутреннее состояние перестанет удовлетворять инварианту, одно лишь наличие cast не вызовет у проверяющего претензий. Иными словами, этот приём защищает клиентов от ошибок, но не валидирует автоматически вашу внутреннюю логику сопоставления.
Выводы
Чтобы смоделировать словарь «подкласс → экземпляр этого подкласса», держите хранилище приватным и опирайтесь на строго типизированный аксессор. Храните данные как dict[type[Root], Root], а метод fetch сделайте параметризованным по базовому типу, возвращающим конкретный подтип через узкое приведение. Так поверхность API для пользователей остаётся чистой и точной, а соблюдение внутреннего инварианта остаётся вашей ответственностью.