Skip to content

Protocols

A runtime_checkable protocol, introduced by PEP 544, describes a shape a value can have, rather than a class it must inherit from. Dispatch matches a value against a protocol by checking whether the value has that shape, and a protocol can require a data member as well as methods:

>>> import typing_extensions as tx
>>> from bagof.dispatchers import dispatch
>>> @tx.runtime_checkable
... class Named(tx.Protocol):
...     name: str
>>> @dispatch
... def greet(x: Named) -> str:
...     return "hello, " + x.name
>>> @dispatch
... def greet(x: object) -> str:
...     return "hello, stranger"
>>> class Guest:
...     pass
>>> guest = Guest()
>>> greet(guest)
'hello, stranger'
>>> guest.name = "Ada"
>>> greet(guest)
'hello, Ada'

A data member counts as present when the value sets it on itself, or when the value's class declares it through an annotation, a class attribute, a property, or a slot. A method counts as present when the value's class defines it. Dispatch looks up these members without running any of the value's own code: it never calls a property, and it never falls through to __getattr__. A class that lists the protocol among its bases always matches it, the same as isinstance would find.

A class is more specific than a protocol it declares

When a class declares every member a protocol requires, an overload written for the class wins over one written for the protocol. A class declares a member the same way a type checker reads it, through an annotation (name: str), a class attribute, a property, a slot, or a dataclass field:

>>> class User:
...     name: str
...     def __init__(self, name: str) -> None:
...         self.name = name
>>> @dispatch
... def greet(x: User) -> str:
...     return "welcome back, " + x.name
>>> greet(User("Grace"))
'welcome back, Grace'
>>> greet(guest)
'hello, Ada'

An annotation declares a member whether or not __init__ actually sets the corresponding attribute, and whether the annotation is written on the class itself or inherited from one of its bases. An instance of User therefore matches Named even before name is set on it, which is where dispatch differs from isinstance: isinstance looks for the attribute to actually be present, while dispatch reads the declaration.

A read-only member also counts as declared: a property without a setter, or an annotation such as name: Final = "x". A type checker would reject either of these as satisfying Named, since Named.name can be assigned to, but dispatch only ever reads the member, so it accepts both.

A sub-protocol is more specific than the protocols it extends.

Class variables

When a protocol declares a member as a ClassVar, dispatch reads that member off the class rather than off the instance. Only a ClassVar annotation declares a member this way, whether or not the annotation carries a value. A plain class attribute does not declare a class variable; it declares an instance variable's default, and dispatch reads it accordingly. An attribute set on the instance does not count as a class variable either:

>>> from typing import ClassVar
>>> @tx.runtime_checkable
... class Kinded(tx.Protocol):
...     kind: ClassVar[str]
>>> @dispatch
... def describe(x: Kinded) -> str:
...     return "a " + x.kind
>>> @dispatch
... def describe(x: object) -> str:
...     return "something"
>>> class Cat:
...     kind: ClassVar[str] = "cat"
>>> describe(Cat())
'a cat'
>>> class Dog:
...     kind = "dog"
>>> describe(Dog())
'something'

The relationship holds in the other direction too. A ClassVar declaration never declares an ordinary instance member such as name, even when it carries a value, and neither does a dataclass InitVar. Both rules match how the typing specification and static type checkers read class variables and instance variables.

When a call matches both

A class that declares none of a protocol's members is neither more specific nor less specific than the protocol itself. When an instance of such a class is given the member some other way, it satisfies both the protocol and the class, and a call matching both overloads is ambiguous:

>>> from bagof.dispatchers import AmbiguousMethodError
>>> @dispatch
... def badge(x: Named) -> str:
...     return "named"
>>> @dispatch
... def badge(x: Guest) -> str:
...     return "guest"
>>> try:
...     badge(guest)
... except AmbiguousMethodError:
...     print("ambiguous")
ambiguous

ambiguities() does not list the two overloads as a pair. The clash depends on what a particular instance happens to have, and only shows up at the call. To resolve it, annotate the member on the class (name: str) so its overload becomes the more specific one, or give one overload a higher priority.

Note

A protocol without @runtime_checkable cannot be checked against a value at all, so an overload written for one never matches.