Variance for your own classes
In this tutorial, I will explain how to customize variance for your own types.
Invariance: Box
Let's take a look at a generic class we made earlier:
from typing import Generic, TypeVar
T = TypeVar("T")
class Box(Generic[T]):
def __init__(self, value: T) -> None:
self._value = value
def get(self) -> T:
return self._value
def put(self, new_value: T) -> None:
self._value = new_value
By default, a type variable is invariant. In other words, Box[Child]
is not a subtype or
supertype of Box[Parent]
. Is this the right thing for our Box
?
A Box
is very close to a 1-element list, so the reasoning is very similar to list
. Consider
this function:
class Parent:
pass
class ChildA(Parent):
pass
class ChildB(Parent):
pass
def do_something(box: Box[Parent]) -> None:
box.put(ChildA())
This is a reasonable function to make. So if we were to call do_something
with a Box[ChildB]
,
we would have gotten a very unpleasant surprise. So Box
can't be covariant.
Similarly, Box
can't be contravariant either:
def something_else(box: Box[ChildA]) -> None:
assert isinstance(box.get(), ChildA)
parent_box = Box(Parent())
something_else(parent_box) # Error
So our Box
class should stay invariant.
How to make the Box
covariant?
Let's take a look at a different version of the Box
class:
from typing import Generic, TypeVar
T = TypeVar("T")
class Box(Generic[T]):
def __init__(self, value: T) -> None:
self._value = value
def get(self) -> T:
return self._value
def display(self) -> None:
print(self._value)
Now that we can't set
a value, we can make Box
covariant. To do this, we need to set the
covariant
flag to True
on our type variable.
from typing import Generic, TypeVar
T = TypeVar("T", covariant=True)
class Box(Generic[T]):
def __init__(self, value: T) -> None:
self._value = value
def get(self) -> T:
return self._value
def display(self) -> None:
print(self._value)
Let's see if it works.
class Parent:
pass
class Child(Parent):
pass
child_box: Box[Child] = Box(Child())
parent_box: Box[Parent] = child_box
No errors, so it looks like we've succeeded.
Contravariance example
Making a class contravariant is similar to making it covariant: you switch a flag on a type variable.
from typing import Generic, TypeVar
T = TypeVar("T", contravariant=True)
class EventSink(Generic[T]):
def __init__(
self,
queue: SomeMessageQueue,
to_json: Callable[[T], object],
) -> None:
self._queue = queue
self._to_json = to_json
def push(self, event: T) -> None:
json_event = self._to_json(event)
self._queue.publish({
"error": False,
"data": json_event,
})
def push_error(self, code: str, message: str) -> None:
self._queue.publish({
"error": True,
"code": code,
"message": message,
})
Two type variables
You can mix and match different variances for different type variables. For example:
from typing import Generic, TypeVar
In = TypeVar("In", contravariant=True)
Out = TypeVar("Out", covariant=True)
class EventSink(Generic[In, Out]):
def __init__(
self,
queue: SomeMessageQueue,
in_to_json: Callable[[In], object],
json_to_out: Callable[[object], Out],
) -> None:
self._queue = queue
self._in_to_json = in_to_json
self._json_to_out = json_to_out
def push(self, event: In) -> Out:
json_event = self._in_to_json(event)
json_resp = self._queue.publish({
"error": False,
"data": json_event,
})
return self._json_to_out(json_resp)
def push_error(self, code: str, message: str) -> None:
self._queue.publish({
"error": True,
"code": code,
"message": message,
})
Do I need to do this reasoning every time I make a generic class?
Thankfully, no!
The type checker will report an error if you mistakenly make a class co/contravariant.
from typing import Generic, TypeVar
T = TypeVar("T", covariant=True)
class Box(Generic[T]):
def __init__(self, value: T) -> None:
self._value = value
def get(self) -> T:
return self._value
def put(self, new_value: T) -> None:
# ^^^
# Covariant type variable cannot be used in parameter
self._value = new_value