Допустим, мы хотим определить собственный общий (базовый) класс, который наследуется от 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
.
Возьмите GenericBase
из кортежа __orig_bases__
подкласса, передайте его typing.get_args
, возьмите первый элемент из кортежа, который он возвращает, и убедитесь, что у вас есть конкретный тип.
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
, мы можем легко извлечь его аргумент типа.
__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
.
Если мы сложим эти два вместе, мы можем быстро построить упрощенное решение, подобное этому:
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__
.
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
С этими компонентами теперь мы можем написать функцию 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
кажется уместным.
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
, что действительно приятно. 🎉
__orig_bases__
плохо документирован. Я не уверен, что его следует считать полностью стабильным. Хотя это тоже не похоже на «просто деталь реализации». Я бы посоветовал следить за этим.mypy
, похоже, согласен с этим предостережением и выдает ошибку no attribute
в том месте, где вы получаете доступ к __orig_bases__
. Таким образом, в этой строке был помещен type: ignore
.GenericBase
класса, т.е. GenericBase[str].get_type_arg()
. Но для этого нужно просто вызвать typing.get_args
, как показано в самом начале.