DEV Community

Daniel Lin
Daniel Lin

Posted on

Python Type Hint: Contravariant, Covariant, Invariant

We all know the Liskov substitution principle. The type can be replaced by its subtype without breaking it. But how about the relationship of their generic types, C[subtype] and C[type]?

So, what is covariant?

If A<: B, you can replace B with A anywhere
We say C[T] where T bound to B is

  1. Contravariant, when A <: B => C[A] :> C[B]
    We can replace C[B] with C[A] anywhere.

  2. Covariant, when A <: B => C[A] <: C[B]
    We can replace C[A] with C[B] anywhere. But How to cause this situation? I will explain in the example code below.

  3. Invariant, If both circumstances do not suit. C[A] can not exchange with C[B], and vice versa.

Combine the concept with Sink / Source.

Accordingly to their behaviors, We have two kinds of objects defined.

  1. Source[T]: It produces T, is covariant to T.
  2. Sink[T]: It consumes T, is contravariant to T.

It is pretty abstract, so we directly move forward to the code below. Try to experiment by modifying according to comments.

import abc

from typing import Generic, TypeVar

class Base:
    def foo(self):
        print("foo")

class Derived(Base):
    def bar(self):
        print("bar")
Enter fullscreen mode Exit fullscreen mode

First, we have Base and Derived. Derived is inherent from Base, so we have Derived <: Base.

T_co = TypeVar('T_co', bound='Base', covariant=True)

class Source(Generic[T_co]):
    @abc.abstractmethod
    def generate(self) -> T_co: # Produce T_co!
        pass

class SourceBase(Source[Base]):
    def generate(self) -> Derived: # Produce T_co!
        return Derived() 

class SourceDerived(Source[Derived]):
    def generate(self) -> Derived:
        return Derived() 

source: Source[Base] = SourceDerived()
source.generate()

#Try to uncomment lines below.
#source_derived: Source[Derived] = SourceBase()
#source_derived.generate()
Enter fullscreen mode Exit fullscreen mode

Now, we have SourceDerived <: SourceBase. If we remove covariant=True, we will get this warning:

[Pyright reportGeneralTypeIssues] [E] Expression of type > "SourceDerived" cannot be assigned to declared type "Source[Base]"
  TypeVar "T_co@Source" is invariant
    "Derived" is incompatible with "Base"

If you modify covariant to contravariant, this is what happened. covariant tells the checker SourceDerived can be safely used anywhere we use SourceBase.

    def generate(self) -> T_co: <- warining
        pass
Enter fullscreen mode Exit fullscreen mode

warining: [Pyright reportGeneralTypeIssues] [E] Contravariant type variable cannot be used in return type
  TypeVar "T_co@Source" is contravariant

Look like covariant not only to check the place you use C[T_co] but also to check the method in C[T_co] return T_co.

Next, we take a look at the contravariant example.

T_contra = TypeVar('T_contra', bound='Base', contravariant=True)

class Sink(Generic[T_contra]):
    @abc.abstractmethod
    def consume(self, value: T_contra):
        pass

class SinkBase(Sink[Base]):
    def consume(self, value: Base):
        value.foo()

class SinkDerived(Sink[Derived]):
    def consume(self, value: Derived):
        value.bar()

    def other_func(self):
        pass

base = Base()
derived = Derived()
sink_derived: Sink[Derived] = SinkBase()
#we can safely consumer
sink_derived.consume(base)
sink_derived.consume(derived)
#Try to uncomment this line.
#sink_derived.other_func()
Enter fullscreen mode Exit fullscreen mode

We have SinkDerive <: SinkBase here. Removing contravariant=True will get warning:

[Pyright reportGeneralTypeIssues] [E] Expression of type "SinkBase" cannot be assigned to declared type > "Sink[Derived]"
   TypeVar "T_contra@Sink" is invariant
    "Base" is incompatible with "Derived"

contravariant=True tells the static checker that the Base can be safely consumed Base or Derive type. Although we have annotated T_contra is contravariant, we will get an error if we call a method of Sink[Derived], sink_derived.other_func() for example, of course. Nevertheless, it is pretty common to assume that contravariant is opposite to covariant.

I think in most situations, we don't need to add contravariant or covariant right away. Only when the checker complains, do we look closely at the relationship of these generic types if used properly. If it is, we consider adding these hints, then.

Top comments (2)

Collapse
 
isf profile image
ISF • Edited

Hi,
Thanks for taking the time to explain. Your explanation seemed clear to be, however mypy complains on sink_derived.consume(base) with message

error: Argument 1 to "consume" of "Sink" has incompatible type "Base"; expected "Derived" [arg-type]

Bug in mypy?

Collapse
 
isf profile image
ISF

I think I understand why mypy complains and it is correct to do so.

Co/contravariance define how much freedom we have to substitute a generic class with another generic class. Contravariance allows to assign SinkBase() to a Sink[Derived] variable.

Derived <: Base  ==>  Sink[Base] <: Sink[Derived]
Enter fullscreen mode Exit fullscreen mode

However, co/contravariance do not apply to method definitions. Contravariance does NOT say that we are allowed feed a Base object to a method that expects a Derived object!
sink_derived is typed as Sink[Derived]. So sink_derived.consume() has signature consume(self, value: Derived). We must still honor this signature and call consume() with a Derived or a subclass of Derived.