Generics in python protocols - Covariance and Contravariance

1.3k Views Asked by At

after having read up on covariance and contravariance within python I still find myself struggling to understand why an Invariant has to be made a contravariant to be used within the context of a protocol and I was hoping someone could further explain this concept to me. For example.

Let's assume the following:

from typing import Literal, Protocol, TypeVar


MyType = Literal["literal_1"]

G = TypeVar("G")

class MyProtocol(
    Protocol[
        G
    ],
):
    @staticmethod
    def do_work(message: G):
        raise NotImplementedError


class CustomClass(
    MyProtocol[
        MyType
    ]
):
    @staticmethod
    def do_work(message: MyType):
        pass

literal_1: MyType = "literal_1"

CustomClass.do_work(literal_1)

This will yield The following error using pyright/mypy:

warning: Type variable "G" used in generic protocol "MyProtocol" should be contravariant (reportInvalidTypeVarUse) 

Changing the function to return a Generic of the same type:

def do_work(message: G) -> G:
    raise NotImplementedError

@staticmethod
def do_work(message: MyType) -> Mytype:
    return message

This error disappears.

I have read multiple sources that will paraphrase the following:

The short explanation is that your approach breaks subtype transitivity; see this section of PEP 544 for more information.

https://www.python.org/dev/peps/pep-0544/#overriding-inferred-variance-of-protocol-classes

I have read the section and am still confused as to why this error is being thrown for this particular example. Additionally I am confused as to why covariance is needed when a return type is given for a function defined in a protocol.

1

There are 1 best solutions below

4
logan20735 On

Imagine if you had a Base and Derived class, and an object conforming to MyProtocol[Base]. You should be allowed to pass that object to a function expecting a MyProtocol[Derived], since in that function, they would call the_object.do_work(Derived()), which is a valid thing to pass to the version that expects a Base, since Derived is a subclass of Base. Hence, the more generic MyProtocol[Base] is valid wherever the more specific MyProtocol[Derived] is, so the type parameter is contravariant (and should be designated as such).

Generally type parameters used as function parameters should be contravariant, since a function with more general parameter types is always callable with more specific parameter types.


On the other hand, type parameters in the return type are generally covariant, since a function returning Base is not generally usable where you need a function returning Derived, but the opposite is true: a function returning Derived can always be used where you need a function that returns Base.

Therefore, when you changed your signature to def do_work(message: G) -> G:, now you have G as both a parameter and a return type, so it should be both covariant and contravariant, but it also can't be either one; hence the default of invariant is correct.