Skip to content

← Precise Type Hints with Literals and Generics step 16 of 26

Hard Framework

Control Variance in Your Own Generic Classes

Build three small generic classes that pass dogs around. A Producer hands out values, a Consumer takes them in, and a Cell holds one value that you can read and replace. Which one can stand in for which depends on what each class lets callers do with T.

The exercise "Why a list[Dog] Is Not a list[Animal]" showed that Sequence is covariant and list is invariant. When you write class Producer[T]:, mypy works out the variance of your own class. It looks at how the public methods and attributes use T. Shape each class so that mypy infers this:

Class Relationship Why it is safe
Producer Producer[Dog] can be used as Producer[Animal] Every Dog it hands out is an Animal.
Consumer Consumer[Animal] can be used as Consumer[Dog] A consumer that accepts any Animal can accept Dogs.
Cell Neither substitution is allowed Reading and writing need the same element type.

Producer is covariant, and Cell is invariant. Consumer is contravariant, which goes the other way. Something that accepts any Animal can stand in for something that accepts a Dog. mypy checks these relationships. Python ignores them at runtime.

What to write

Producer[T]:

  • The constructor takes a Sequence[T]. Store tuple(items) in a private attribute, _items.
  • Expose it through a read-only items property that returns tuple[T, ...], with no setter.
  • Later changes to the caller's sequence must not change the producer's items. Keep the same item objects, without deep copies.

Consumer[T]:

  • It starts empty, and each consumer has its own storage.
  • put(self, item: T) -> None stores each item in arrival order, duplicates included.
  • log(self) -> list[str] returns a new list of str(item) for each stored item, computed when you call log.
  • Store the items in a private list[object]. Then the class accepts T but never returns it.

Cell[T]:

  • The constructor takes a value and stores it in a public attribute, value: T, that callers can read and assign.

pipe:

def pipe[T](producer: Producer[T], consumer: Consumer[T]) -> None:
    ...
  • Call consumer.put once for each item of producer.items, in order.
  • Leave the producer unchanged, and keep any items already in the consumer.
  • An empty producer makes no calls. Piping the same producer twice puts its items twice.

Why the starter's Producer is invariant

The starter keeps a public list[T] in Producer.items. If mypy let a Producer[Dog] stand in for a Producer[Animal], a caller could append a plain Animal to that list of dogs. So mypy infers invariance. A read-only property that returns a tuple leaves callers no way to add items.

mypy treats an attribute whose name starts with an underscore as read-only when it infers variance. It still counts the attribute's type, so _items: list[T] would make Producer invariant again, and a private list[T] would do the same to Consumer. Python still lets any code reach _items at runtime.

mypy also leaves __init__ out, so a constructor that takes T does not stop Producer from being covariant. A read-only property that returns list[T] still makes the class invariant, because callers can change the list it returns.

Keep the solve driver

The starter defines Animal and its subclass Dog. str(Dog("rex")) is "Dog(rex)".

solve(names) makes one Dog per name, in order and with duplicates, and pipes them into a consumer. It then creates a Cell[Animal] that holds Animal("origin"), and sets its value to Dog("swapped"). It returns the consumer's log, the number of producer items, and the string of the cell's value. Empty and non-ASCII names are valid. Leave the input list unchanged.

solve(["rex", "fido"])   # (["Dog(rex)", "Dog(fido)"], 2, "Dog(swapped)")
solve([])                # ([], 0, "Dog(swapped)")
solve(["", "ré", "ré"])  # (["Dog()", "Dog(ré)", "Dog(ré)"], 3, "Dog(swapped)")

mypy checks your classes through these annotated assignments in the driver:

dog_producer: Producer[Dog] = Producer(dogs)
producer: Producer[Animal] = dog_producer
sink: Consumer[Dog] = Consumer[Animal]()

Keep them as they are, with no cast and no # type: ignore. The separate dog_producer variable matters. Suppose the driver wrote producer: Producer[Animal] = Producer(dogs) instead. mypy would infer Producer[Animal] from the annotation, so that line passes even when Producer is invariant.

To see that Cell is invariant, check in a scratch file that mypy rejects each of these lines. Do not put them in your submission, because they fail mypy on purpose:

a: Cell[Animal] = Cell[Dog](Dog("d"))
b: Cell[Dog] = Cell[Animal](Animal("a"))

See mypy's guide to variance for more examples.