Аргумент типа доступа в любом конкретном подклассе определяемого пользователем класса Generic[T]

Контекст

Допустим, мы хотим определить собственный общий (базовый) класс, который наследуется от typing.Generic.

Для простоты мы хотим, чтобы он был параметризован одной переменной типа T. Итак, определение класса начинается так:

from typing import Generic, TypeVar

T = TypeVar("T")

class GenericBase(Generic[T]):
    ...

Вопрос

Есть ли способ получить доступ к аргументу типа T в любом конкретном подклассе GenericBase?

Решение должно быть достаточно универсальным, чтобы работать в подклассе с дополнительными базами помимо GenericBase и быть независимым от инстанцирования (т.е. работать на уровне класса).

Желаемый результат - это метод класса, подобный этому:

class GenericBase(Generic[T]):

    @classmethod
    def get_type_arg(cls) -> Type[T]:
        ...

Применение

class Foo:
    pass

class Bar:
    pass

class Specific(Foo, GenericBase[str], Bar):
    pass

print(Specific.get_type_arg())

Вывод должен быть <class 'str'>.

Бонус

Было бы неплохо, если бы все соответствующие аннотации типов были сделаны так, чтобы средства проверки статических типов могли правильно определить конкретный класс, возвращаемый get_type_arg.

Похожие вопросы

Почему в Python есть оператор "pass"?
Почему в Python есть оператор "pass"?
Оператор pass в Python - это простая концепция, которую могут быстро освоить даже новички без опыта программирования.
Некоторые методы, о которых вы не знали, что они существуют в Python
Некоторые методы, о которых вы не знали, что они существуют в Python
Python - самый известный и самый простой в изучении язык в наши дни. Имея широкий спектр применения в области машинного обучения, Data Science,...
Основы Python Часть I
Основы Python Часть I
Вы когда-нибудь задумывались, почему в программах на Python вы видите приведенный ниже код?
LeetCode - 1579. Удаление максимального числа ребер для сохранения полной проходимости графа
LeetCode - 1579. Удаление максимального числа ребер для сохранения полной проходимости графа
Алиса и Боб имеют неориентированный граф из n узлов и трех типов ребер:
Оптимизация кода с помощью тернарного оператора Python
Оптимизация кода с помощью тернарного оператора Python
И последнее, что мы хотели бы показать вам, прежде чем двигаться дальше, это
Советы по эффективной веб-разработке с помощью Python
Советы по эффективной веб-разработке с помощью Python
Как веб-разработчик, Python может стать мощным инструментом для создания эффективных и масштабируемых веб-приложений.
0
0
64
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 1

Ответ принят как подходящий

TL;DR

Возьмите GenericBase из кортежа __orig_bases__ подкласса, передайте его typing.get_args, возьмите первый элемент из кортежа, который он возвращает, и убедитесь, что у вас есть конкретный тип.

1) Начиная с get_args

Как указано в этом посте , модуль typing для Python 3.8+ предоставляет функцию get_args. Это удобно, потому что, учитывая специализацию универсального типа, get_args возвращает аргументы своего типа (в виде кортежа).

Демонстрация:

from typing import Generic, TypeVar, get_args

T = TypeVar("T")

class GenericBase(Generic[T]):
    pass

print(get_args(GenericBase[int]))

Вывод:

(<class 'int'>,)

Это означает, что когда у нас есть доступ к специализированному типу GenericBase, мы можем легко извлечь его аргумент типа.

2) Продолжая с __orig_bases__

Как было указано в вышеупомянутом посте, есть небольшой удобный атрибут класса __orig_bases__, который устанавливается метаклассом type при создании нового класса. Это упоминается здесь, в PEP 560, но в остальном почти не задокументировано.

Этот атрибут содержит (как следует из названия) исходные базы в том виде, в каком они были переданы конструктору метакласса в виде кортежа. Это отличает его от __bases__ , который содержит уже разрешенные базы, возвращаемые types.resolve_bases.

Демонстрация:

from typing import Generic, TypeVar

T = TypeVar("T")

class GenericBase(Generic[T]):
    pass

class Specific(GenericBase[int]):
    pass

print(Specific.__bases__)
print(Specific.__orig_bases__)

Вывод:

(<class '__main__.GenericBase'>,)
(__main__.GenericBase[int],)

Нас интересует исходная база, потому что это специализация нашего универсального класса, то есть тот, который «знает» об аргументе типа (int в этом примере), тогда как разрешенный базовый класс является просто экземпляром type.

3) Упрощенное решение

Если мы сложим эти два вместе, мы можем быстро построить упрощенное решение, подобное этому:

from typing import Generic, TypeVar, get_args

T = TypeVar("T")

class GenericBase(Generic[T]):
    @classmethod
    def get_type_arg_simple(cls):
        return get_args(cls.__orig_bases__[0])[0]

class Specific(GenericBase[int]):
    pass

print(Specific.get_type_arg_simple())

Вывод:

<class 'int'>

Но это сломается, как только мы добавим еще один базовый класс поверх нашего GenericBase.

from typing import Generic, TypeVar, get_args

T = TypeVar("T")

class GenericBase(Generic[T]):
    @classmethod
    def get_type_arg_simple(cls):
        return get_args(cls.__orig_bases__[0])[0]

class Mixin:
    pass

class Specific(Mixin, GenericBase[int]):
    pass

print(Specific.get_type_arg_simple())

Вывод:

Traceback (most recent call last):
  ...
    return get_args(cls.__orig_bases__[0])[0]
IndexError: tuple index out of range

Это происходит потому, что cls.__orig_bases__[0] теперь оказывается Mixin, который не является параметризованным типом, поэтому get_args возвращает пустой кортеж ().

Итак, нам нужен способ однозначно идентифицировать GenericBase из кортежа __orig_bases__.

4) Отождествление с get_origin

Точно так же, как typing.get_args дает нам аргументы типа для универсального типа, typing.get_origin дает нам неуказанную версию универсального типа.

Демонстрация:

from typing import Generic, TypeVar, get_origin

T = TypeVar("T")

class GenericBase(Generic[T]):
    pass

print(get_origin(GenericBase[int]))
print(get_origin(GenericBase[str]) is GenericBase)

Вывод:

<class '__main__.GenericBase'>
True

5) Соединяем их вместе

С этими компонентами теперь мы можем написать функцию get_type_arg, которая принимает класс в качестве аргумента и — если этот класс является специализированной формой нашего GenericBase — возвращает аргумент своего типа:

from typing import Generic, TypeVar, get_origin, get_args

T = TypeVar("T")

class GenericBase(Generic[T]):
    pass

class Specific(GenericBase[int]):
    pass

def get_type_arg(cls):
    for base in cls.__orig_bases__:
        origin = get_origin(base)
        if origin is None or not issubclass(origin, GenericBase):
            continue
        return get_args(base)[0]

print(get_type_arg(Specific))

Вывод:

<class 'int'>

Теперь все, что осталось сделать, это внедрить это напрямую как метод класса GenericBase, немного оптимизировать его и исправить аннотации типов.

Одна вещь, которую мы можем сделать, чтобы оптимизировать это, — запустить этот алгоритм только один раз для любого заданного подкласса GenericBase, а именно, когда он определен, а затем сохранить тип в атрибуте класса. Поскольку аргумент типа, по-видимому, никогда не меняется для определенного класса, нет необходимости вычислять его каждый раз, когда мы хотим получить доступ к аргументу типа. Для этого мы можем подключиться к __init_subclass__ и выполнить там наш цикл.

Мы также должны определить правильный ответ, когда get_type_arg вызывается в (неуказанном) универсальном классе. AttributeError кажется уместным.

6) Полный рабочий пример

from typing import Any, Generic, Optional, Type, TypeVar, get_args, get_origin


# The `GenericBase` must be parameterized with exactly one type variable.
T = TypeVar("T")


class GenericBase(Generic[T]):
    _type_arg: Optional[Type[T]] = None  # set in specified subclasses

    @classmethod
    def __init_subclass__(cls, **kwargs: Any) -> None:
        """
        Initializes a subclass of `GenericBase`.

        Identifies the specified `GenericBase` among all base classes and
        saves the provided type argument in the `_type_arg` class attribute
        """
        super().__init_subclass__(**kwargs)
        for base in cls.__orig_bases__:  # type: ignore[attr-defined]
            origin = get_origin(base)
            if origin is None or not issubclass(origin, GenericBase):
                continue
            type_arg = get_args(base)[0]
            # Do not set the attribute for GENERIC subclasses!
            if not isinstance(type_arg, TypeVar):
                cls._type_arg = type_arg
                return

    @classmethod
    def get_type_arg(cls) -> Type[T]:
        if cls._type_arg is None:
            raise AttributeError(
                f"{cls.__name__} is generic; type argument unspecified"
            )
        return cls._type_arg


def demo_a() -> None:
    class SpecificA(GenericBase[int]):
        pass

    print(SpecificA.get_type_arg())


def demo_b() -> None:
    class Foo:
        pass

    class Bar:
        pass

    class GenericSubclass(GenericBase[T]):
        pass

    class SpecificB(Foo, GenericSubclass[str], Bar):
        pass

    type_b = SpecificB.get_type_arg()
    print(type_b)
    e = type_b.lower("E")  # static type checkers correctly infer `str` type
    assert e == "e"


if __name__ == '__main__':
    demo_a()
    demo_b()

Вывод:

<class 'int'>
<class 'str'>

IDE, такая как PyCharm, даже предоставляет правильные автоматические предложения для любого типа, возвращаемого get_type_arg, что действительно приятно. 🎉

7) Предостережения

  • Атрибут __orig_bases__ плохо документирован. Я не уверен, что его следует считать полностью стабильным. Хотя это тоже не похоже на «просто деталь реализации». Я бы посоветовал следить за этим.
  • mypy, похоже, согласен с этим предостережением и выдает ошибку no attribute в том месте, где вы получаете доступ к __orig_bases__. Таким образом, в этой строке был помещен type: ignore.
  • Вся настройка предназначена для одного единственного параметра типа для нашего универсального класса. Его можно относительно легко адаптировать к нескольким параметрам, хотя аннотации для средств проверки типов могут стать более сложными.
  • Этот метод не работает при прямом вызове из специализированного GenericBase класса, т.е. GenericBase[str].get_type_arg(). Но для этого нужно просто вызвать typing.get_args, как показано в самом начале.

Другие вопросы по теме