typevar variance keywords¶
basedpython adds in and out keywords on PEP 695 type parameters to
declare variance directly at the declaration site:
out T declares T covariant, in T contravariant, and in out T
invariant. a bare T declares nothing: its variance is inferred from
what the class body does with it, so it can come out covariant, contravariant,
invariant or bivariant. writing a keyword is how you pin one down and stop the
inference from following the body:
class Bare[T]:
def get(self) -> T: ... # inferred covariant
class Pinned[in out T]:
def get(self) -> T: ... # invariant, because it says so
Bare[int] is assignable to Bare[object]; Pinned[int] is not. variance
affects subtyping in the obvious way:
Source[Dog]is assignable toSource[Animal](covariant —Tis produced)Sink[Animal]is assignable toSink[Dog](contravariant —Tis consumed)Both[Dog]andBoth[Animal]are assignable to neither (invariant)
the declaration has to match the usage¶
a keyword is a promise the rest of the program is checked against, so the class
that makes it has to keep it. out T may only be produced, in T may only be
consumed, and a mutable T-typed attribute does both — so it needs in out T:
class Bad[out T]: # error: `T` is declared covariant, but `Bad` uses it contravariantly
def set(self, value: T) -> None: ...
without the check the promise is a lie that nothing catches: Bad[int] would be
assignable to Bad[object], and calling set("asdf") through that widened view
would file a str away in int storage
in out T is invariance, which every usage satisfies, so it is never reported —
and neither is a bare T, whose variance is inferred from the usage in the
first place. a constructor is exempt: it produces the instance rather than
writing through an existing one
two things a covariant class can do with a T in an input position: keep it
private, which takes it off the observable surface
altogether, or mark the position
Overlapping[T], which asks only that the argument overlap
T rather than be one
transpilation¶
on Python 3.12+ the keywords are stripped because PEP 695 itself does not yet support inline variance declarations:
on pre-3.12 targets the keywords are passed through to the TypeVar polyfill,
which emits the corresponding covariant=True / contravariant=True
arguments:
_T = TypeVar("_T", covariant=True)
class Source(Generic[_T]): ...
_T_contra = TypeVar("_T_contra", contravariant=True)
class Sink(Generic[_T_contra]): ...
scope¶
variance keywords are recognized in two surface positions:
- on a PEP 695 type-parameter declaration (
class C[out T]:), as shown above — affects the declared variance ofT - on a subscript argument (
list[out int]), described below — affects only this one annotation without touchinglist's declaration
they are not allowed on bare TypeVar(...) calls (use the covariant= /
contravariant= arguments directly there).
variance relates two specializations, so the declaration position has to be one
that specializes: a class, or a generic type alias — which is
exactly as variant as the type it expands to. a function's type parameter is
solved afresh at every call and never specializes, and a type def is erased
before anything could observe one, so a keyword there decides nothing and is
reported (invalid-variance-declaration) rather than dropped:
an alias's keyword is a claim about the expansion rather than a decision of its own, so it is checked against it — the same rule as a class's, reported at the same place:
in out is invariance, which every expansion satisfies, and a parameter the
expansion never mentions is never reported
use-site variance¶
writing Container[out T], Container[in T], or Container[in out T]
gives an annotation a read-only, write-only, or read-write view over a
generic container, without affecting the container's own declared
variance:
def read(data: list[out int]):
data[0] # int
data[0] = 1 # error — write rejected
def write(data: list[in int]):
data[0] = 1 # ok — int accepted
data[0] # error — read rejected
def both(data: list[in out int]):
data[0] # int
data[0] = 1 # ok
the container keeps its nominal identity — the projection rides along as a
per-parameter tag, so list[out int] and set[out int] stay unrelated
types. each argument of a multi-argument subscript is tagged independently,
and an unmarked argument is simply untagged:
def f(m: dict[str, out int]):
m["a"] # int
m["a"] = 1 # error — write rejected through the `out` view
def g(m: dict[out str, in int]):
m["a"] = 1 # ok — int accepted through the `in` view
m["a"] # object — the value reads through the `in` view
a projection only ever adds to what the declaration allows. against a
declared-invariant parameter, out relaxes the position to covariant and
in to contravariant; against a parameter already declared out T or
in T, the declared variance covers everything the projection could give
and the projection is a no-op.
the projection is part of the type, so it also decides a
parametric type test — a is A[out int] matches
covariantly even when A's T is invariant.
inlay hints¶
a type parameter that declares no variance gets its inferred one as an inlay hint, written where the keyword would go:
class Source[⟨out ⟩T]:
def get(self) -> T: ...
class Sink[⟨in ⟩T]:
def put(self, value: T) -> None: ...
a generic type alias is hinted the same way:
a parameter ty infers as bivariant is not hinted — basedpython has no spelling for it