4

I'm trying to implement a generic Protocol. My intent is to have a Widget[key_type, value_type] protocol with a simple getter. Mypy complained about Protocol[K, T] so that became Protocol[K_co, T_co]. I've already stripped out all the other constraints, but I can't even get the most basic situation, widg0: Widget[Any, Any] = ActualWidget(), to work. ActualWidget.get should be totally compatible with get(self, key: K) -> Any, which makes me think I'm using the generics/protocol wrong in some way, or mypy just can't handle this.

command/error from mypy:

$ mypy cat_example.py
cat_example.py:34: error: Argument 1 to "takes_widget" has incompatible type "ActualWidget"; expected "Widget[Any, Any]"
cat_example.py:34: note: Following member(s) of "ActualWidget" have conflicts:
cat_example.py:34: note:     Expected:
cat_example.py:34: note:         def [K] get(self, key: K) -> Any
cat_example.py:34: note:     Got:
cat_example.py:34: note:         def get(self, key: str) -> Cat
Found 1 error in 1 file (checked 1 source file)

or alternatively, if I try to force the assignment with widg0: Widget[Any, Any] = ActualWidget():

error: Incompatible types in assignment (expression has type "ActualWidget", variable has type "Widget[Any, Any]")

The full code:

from typing import Any, TypeVar
from typing_extensions import Protocol, runtime_checkable

K = TypeVar("K")  # ID/Key Type
T = TypeVar("T")  # General type
K_co = TypeVar("K_co", covariant=True)  # ID/Key Type or subclass
T_co = TypeVar("T_co", covariant=True)  # General type or subclass
K_contra = TypeVar("K_contra", contravariant=True)  # ID/Key Type or supertype
T_contra = TypeVar("T_contra", contravariant=True)  # General type or supertype

class Animal(object): ...

class Cat(Animal): ...


@runtime_checkable
class Widget(Protocol[K_co, T_co]):
    def get(self, key: K) -> T_co: ...

class ActualWidget(object):
    def get(self, key: str) -> Cat:
        return Cat()

def takes_widget(widg: Widget):
    return widg

if __name__ == '__main__':
    widg0 = ActualWidget()
    #widg0: Widget[str, Cat] = ActualWidget()
    #widg0: Widget[Any, Any] = ActualWidget()

    print(isinstance(widg0, Widget))
    print(isinstance({}, Widget))
    takes_widget(widg0)
DeusXMachina
  • 1,239
  • 1
  • 18
  • 26
  • I think all you need to do is change the typehint in the Widget protocol to `str`. No? – Marcel Wilson Jul 09 '21 at 16:40
  • Nope, Widget is generic. – DeusXMachina Jul 09 '21 at 18:27
  • I didn't think changing the type for `key` would make Widget be non-generic. What types can `key` be? – Marcel Wilson Jul 09 '21 at 19:07
  • Ignoring co/contra/variance, Widget is generic, `Widget[K, T]`. `Widget.get(key: K) -> T`. `key` is any `K`, aka `Any` but is so named to differentiate from `T` (also Any). Look at the signature for `dict`, it's `class dict(MutableMapping[_KT, _VT], Generic[_KT, _VT]` (they call it _KT, I just use K) – DeusXMachina Jul 09 '21 at 20:32
  • Oh.. so you're looking to use the `Protocol` but in a similar way that `MutableMapping` is used? so sorta like this `class C(Protocol[MutableMapping[K,T]])` (if that were possible) – Marcel Wilson Jul 09 '21 at 20:44
  • No, I'm defining my own protocol, which in this case also happens to be a Mapping (an object can implement multiple Protocols under structural subtyping) – DeusXMachina Jul 09 '21 at 22:08
  • 2
    Can you confirm that this is what you want? https://mypy-play.net/?mypy=latest&python=3.10&gist=ee819280358de3ef3ecc7537c6cd928e. All of it type checks, with `take_widget` effectively taking `Widget[Any, Any]` (your example's main error was that you used `K` for key but `K_co` as an argument to `Protocol`). I'll write an answer if the playground snippet is the behavior you want. – Mario Ishac Jul 09 '21 at 22:47
  • 1
    @MarioIshac this looks very promising! Certainly further than I got. I noticed that with protocols, mypy "wants" argument types to be "more contra" and return types to be "more covariant". I had tried using `K_co` for both but that seemed to cause more errors overall. This at least gets me a protocol and a function taking it which I can tighten with the same type! – DeusXMachina Jul 10 '21 at 00:25

1 Answers1

3

Putting what I had in the comments here.

To make your question's example work, you need to make the input parameter contravariant and the output parameter covariant like so:

from typing import TypeVar
from typing_extensions import Protocol, runtime_checkable

T_co = TypeVar("T_co", covariant=True)  # General type or subclass
K_contra = TypeVar("K_contra", contravariant=True)  # ID/Key Type or supertype

class Animal: ...

class Cat(Animal): ...

@runtime_checkable
class Widget(Protocol[K_contra, T_co]):
    def get(self, key: K_contra) -> T_co: ...

class ActualWidget:
    def get(self, key: str) -> Cat:
        return Cat()

def takes_widget(widg: Widget):
    return widg

class StrSub(str):
    pass

if __name__ == '__main__':
    widget_0: Widget[str, Cat] = ActualWidget()
    widget_1: Widget[StrSub, Cat] = ActualWidget()
    widget_2: Widget[str, object] = ActualWidget()
    widget_3: Widget[StrSub, object] = ActualWidget()

    takes_widget(widget_0)
    takes_widget(widget_1)
    takes_widget(widget_2)
    takes_widget(widget_3)

ActualWidget(), which is a Widget[str, Cat], is then assignable to Widget[SubStr, object] for widget_3, meaning that Widget[str, Cat] is a subclass of Widget[SubStr, object].

Widget[str, Cat] can take all SubStrs plus other str subtypes (the input type in the sublcass relationship can be less specific, hence contravariance) and can have an output that is atleast an object, plus having str properties (the output type in the subclass relationship can be more specific, hence covariance). See also Wikipedia - Function Types, which formalizes this observation:

For example, functions of type Animal -> Cat, Cat -> Cat, and Animal -> Animal can be used wherever a Cat -> Animal was expected.

In other words, the → type constructor is contravariant in the parameter (input) type and covariant in the return (output) type.

Mario Ishac
  • 5,060
  • 3
  • 21
  • 52
  • `In other words, the → type constructor is contravariant in the parameter (input) type and covariant in the return (output) type.` a HA! Ok now it's starting to actually click a bit. Since any (immutable) mapping basically boils down to a closure `F[K] -> T`, its type constructor behaves exactly like such a function. Hence, the Mapping corollary of "Reynold's Law" is "contravariant in the key and covariant in the value type", yes? – DeusXMachina Jul 13 '21 at 23:08
  • 1
    Exactly, forget about all the class abstractions, think only in functions for now. An immutable `Mapping` is an `F[K] -> V`. Cause type params as input types are contra and type params as output types are co, `Mapping` can be contra over `K` and co over `V`. `MutableMapping` is a `F[K] -> V` (getting) and `F[K, V] -> None` (setting). `K` still remains as an input in both cases, but because `V` appears as both an input and output, it needs to have invariance. So `MutableMapping` is contra over `K` but invariant over `V`. – Mario Ishac Jul 14 '21 at 00:12
  • 1
    @DeusXMachina Summary: Iterate over all functions of a class in your head. If a type parameter only appears as inputs, it can have contravariance. If only as outputs, then covariance. If as both (and one of the functions influences the results of the other, like how setting influences getting in above example), then must be invariant. This is a watered down way of thinking about it, but it gets you 99% of the way there. – Mario Ishac Jul 14 '21 at 00:15
  • 1
    One more bit I want to clarify: I say watered down because of that last part regarding "and one of the functions influences the results of the other." If a map exposes a contains method, such as `F[V] -> bool`, does that mean that `V` being an input here could force invariance if it was an output elsewhere? Not necessarily, because a contains method doesn't affect the underlying data. It gets complicated when you are analyzing all the dependencies / relationships of influence between functions. – Mario Ishac Jul 14 '21 at 00:27
  • This is the best, thank you! So if I understand correctly, if I want to add a `.keys() -> Iter[K]` method, now I have to make K invariant? So practically speaking, an interface compatible with `Dict` has to be invariant on both `K` and `T`, correct? – DeusXMachina Jul 14 '21 at 13:28
  • 1
    @DeusXMachina Yes, see https://pastebin.com/f1CAu5uC, which would type check but be faulty if `K` was not invariant. Also look at https://github.com/python/typeshed/blob/f527e96dc30ed42a59f0a73ace2001561ce3b96c/stdlib/typing.pyi#L437, where you can see that `MutableMapping` is declared with both `K` and `V` invariant (compared `Mapping` slightly above, which has `V` covariant). – Mario Ishac Jul 14 '21 at 21:46
  • 1
    Also see this question (while written in Java, still useful): https://stackoverflow.com/questions/2723397/what-is-pecs-producer-extends-consumer-super, first paragraph of accepted answer is like my middle comment above. `extends` in Java is covariance, `super` is contravariance. – Mario Ishac Jul 14 '21 at 21:51
  • 1
    You know, I've actually stared at this block of code for far too long, wondering why `Mapping` has `_VT_co` while `MutableMapping` has `_VT`. Now I am starting to get why. The comment is amusing: "We wish the key type could also be covariant, but that doesn't work. " That Java question is phenomenally useful, by the way! Typed Python has some ways to go with cultural knowledge, but I look forward to it. – DeusXMachina Jul 15 '21 at 12:08
  • 1
    Linking for my own edification: https://stackoverflow.com/questions/61467673/how-do-i-create-a-generic-interface-in-python – DeusXMachina Jul 15 '21 at 12:08
  • 1
    Also, fun fact, `Protocol` became part of `stdlib.typing` in 3.8 (was in typing_extensions before), so the standard `Collection`s are bonafide `Protocol`s in 3.8 (and runtime checkable in 3.7, but you have to `pip install typing_extensions` first) – DeusXMachina Jul 15 '21 at 12:26