Modern Python programming provides rich typing system to inform the type checkers about static type information. As the typing system envolves, there are more and more comprehensive concepts that needs further understandings.
Generics
Suppose we have the following Python snippet with type annotated:
from typing import Generic, TypeVar
T = TypeVar("T")
class Box(Generic[T]): # or class Box[T]: with Python 3.12+ with TypeVar declaration omitted
def __init__(self, value: T):
self.value = value
def consume(box: Box[int]) -> None:
pass
There are some concepts with the above code:
Generic:Box, with unresolved and variable type parameters (T) during type declaration- type variable:
T, used as a substitutable type - type parameter:
Tin theBoxscope, like placeholder 1 - type argument:
intinBox[int] - parameterization:
Box[int], substitute the type parameters with real types
Then, the type of a parameterized Generic will be:
print(type(Box[int]))
<class 'typing._GenericAlias'>
There is another similar type named types.GenericAlias, which has almost the same functionality with typing._GenericAlias.
It serves to Python built-in generic types like list[int].
Notably, CPython will NOT specialize a dedicated type for each generic alias, the object is still an instance of the "original" type:
print(type(Box[str]("123")))
<class '__main__.Box'>
Generic Alias
With parameterized types, Generic now becomes to _GenericAlias, bound to some important attributes allowing inspect code or type checker to access type info:
| Object | __origin__ | __args__ | __parameters__ |
|---|---|---|---|
Box[T] | Box | (T,) | (T,) |
Box[int] | Box | (int,) | () |
Box[list[T]] | Box | (list[T],) | (T,) |
list[int] | list | (int,) | () |
To distinguish the type parameter declared by Box from the free type parameter in its argument, we redefine Box with U:
from typing import Generic, TypeVar
U = TypeVar("U")
T = TypeVar("T")
class Box(Generic[U]):
pass
A = Box[list[T]]
B = A[int]
print(A.__origin__, A.__parameters__, A.__args__)
print(B.__origin__, B.__parameters__, B.__args__)
# <class '__main__.Box'> (~T,) (list[~T],)
# <class '__main__.Box'> () (list[int],)
Nested generic aliases: parameter collection during construction and substitution during reparameterization.
TypeVarTuple and ParamSpec
There are two kinds of special types used for function signature and forwarding/wrapping type hints: TypeVarTuple and ParamSpec.
Let's consider the following case:
def pack[*Ts](*args: *Ts) -> tuple[*Ts]:
return args
Ts receives a series of TypeVars, where the order and types of each position are preserved and could be detected by type checker.
Additionally, ParamSpec could be further used to record the positional-only and keyword-only information during the function forwarding or wrapping:
def logged[**P, R](func: Callable[P, R]) -> Callable[P, R]:
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
print("calling", func.__name__)
return func(*args, **kwargs)
return wrapper
The construction of TypeVarTuple and ParamSpec are located in:
This utility function helps converting and resolving the case class C[A, *Ts, B]: ... with args C[int, str, bool, bytes].
It will wrap the flatten args into (int, (str, bool), bytes).
As for ParamSpec, typing lib is responsible for validating acceptable args and replace into the default values whenever needed.
Footnotes
-
There is another dunder attribute
__type_param__used to obtain the type parameters specified by new grammer[T]. ↩