type reification¶
standard python erases inferred specializations: A(1) constructs an A
with no record that it was an A[int]. the transpiler makes the inferred
specialization of a user-defined generic constructor explicit in the generated
python:
→
A[int](…) routes through types.GenericAlias.__call__, which stamps
__orig_class__ on the constructed instance — the specialization becomes an
observable runtime value:
the stamp is what makes a runtime specialization visible — see
parametric type tests for x is A[int], which
reads it back.
a builtin cannot record a specialization¶
the stamp needs somewhere to live, and __orig_class__ is an ordinary
attribute: types.GenericAlias.__call__ sets it on the value it just built,
and moves on silently when the value won't take it. a c type has no instance
dictionary, so it never takes it — every builtin generic is in the same
position, whatever it looks like at the type level:
list[int]([1, 2]).__orig_class__ # None — silently dropped
enumerate[str](["a"]).__orig_class__ # None — the same
zip[tuple[int, bool]]([1], [True]) # TypeError: not subscriptable
the last line is the same fact one step earlier. subscripting a class needs a
__class_getitem__, which a python-level class inherits from Generic but a c
type only has if cpython wrote one by hand — list got one in 3.9,
array.array in 3.12, memoryview in 3.14, and zip, map, filter,
reversed and itertools.count never did. so a builtin either accepts the
subscript and drops the stamp, or refuses the subscript outright
so a builtin never reports a specialization back. two rules follow from that
a display is left exactly as written, even where its element type is known. wrapping it would turn syntax into a constructor call and record nothing:
a call is rewritten only when the subscript is known to evaluate — a class
whose real definition is in view, one typeshed writes a __class_getitem__ for
at the version being targeted, or one that inherits a base which does. zip is
none of those, so it is left alone rather than becoming python that raises when
it runs:
pairs = zip([1, 2], [True, False]) # stays bare — `zip[…]` is a TypeError
counts = enumerate(["a"]) # → enumerate[str](["a"]) — inert, but valid
the second line is the honest cost of one uniform rule: on a builtin that does accept the subscript the wrap is inert rather than wrong, and the transpiler does not try to guess which classes can hold the stamp
this is also why a parametric test cannot read a specialization back off a
builtin: x is list[int] resolves statically or through a
reified type parameter instead
where the types come from¶
the injected spelling is read from the specialization ty already inferred for
the expression — the same solution the checker reports — promoting each
argument as it is spelled (Literal[1] → int, since only a class object can
be written at runtime; a covariant parameter keeps its literal in the checker's
own view, see fluid specializations). there is no
separate solver, so the injected arguments never disagree with the checker, and
usage-based widening of an inferred specialization flows straight into the
injection
an explicit specialization is always kept as written: A[int](1) transpiles
unchanged, and is never wrapped twice
best-effort, never an error¶
unlike reified type parameters — where the runtime needs the type argument and an uninjectable bare call is a checker error — constructor reification changes nothing the body can observe, so it simply doesn't fire when no runtime spelling exists:
-
an unsolved or dynamic argument —
A()inferred asA[Unknown], orA(x)for an unannotatedx -
a type argument with no spelling at the call site, such as a class defined inside a function:
A(Local())stays bare -
a non-generic class, which has no specialization to make explicit
-
a class the target runtime is not known to subscript — see above
what never reifies¶
- type expressions: annotations, type-parameter lists,
type X = …values, and type-context subscript slices (the[int]list of a legacyCallable[[int], str]is type syntax, not a value) - the values of dunders that static readers consume structurally:
__all__,__slots__,__match_args__ sys.version_infocomparisons — every static reader (including ty on the generated python) must see the literal tuple gate- function parameter defaults — a non-scalar default is consumed whole by the mutable defaults lowering and re-evaluated in a body guard. lambda defaults are not sentinel-lowered, so they do reify
- stubs (no runtime to observe) and targets below python 3.9