Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve protocols documentation #14577

Merged
merged 3 commits into from
Feb 2, 2023
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 76 additions & 13 deletions docs/source/protocols.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,17 @@ Protocols and structural subtyping
==================================

Mypy supports two ways of deciding whether two classes are compatible
as types: nominal subtyping and structural subtyping. *Nominal*
subtyping is strictly based on the class hierarchy. If class ``D``
as types: nominal subtyping and structural subtyping.

*Nominal* subtyping is strictly based on the class hierarchy. If class ``D``
inherits class ``C``, it's also a subtype of ``C``, and instances of
``D`` can be used when ``C`` instances are expected. This form of
subtyping is used by default in mypy, since it's easy to understand
and produces clear and concise error messages, and since it matches
how the native :py:func:`isinstance <isinstance>` check works -- based on class
hierarchy. *Structural* subtyping can also be useful. Class ``D`` is
hierarchy.

*Structural* subtyping is based on the operations that can be performed with an object. Class ``D`` is
a structural subtype of class ``C`` if the former has all attributes
and methods of the latter, and with compatible types.

Expand Down Expand Up @@ -72,15 +75,16 @@ class:
from typing_extensions import Protocol

class SupportsClose(Protocol):
def close(self) -> None:
... # Empty method body (explicit '...')
# Empty method body (explicit '...')
def close(self) -> None: ...

class Resource: # No SupportsClose base class!
# ... some methods ...

def close(self) -> None:
self.resource.release()

# ... other methods ...

def close_all(items: Iterable[SupportsClose]) -> None:
for item in items:
item.close()
Expand Down Expand Up @@ -146,7 +150,9 @@ present if you are defining a protocol:

You can also include default implementations of methods in
protocols. If you explicitly subclass these protocols you can inherit
these default implementations. Explicitly including a protocol as a
these default implementations.

Explicitly including a protocol as a
base class is also a way of documenting that your class implements a
particular protocol, and it forces mypy to verify that your class
implementation is actually compatible with the protocol. In particular,
Expand All @@ -157,12 +163,62 @@ abstract:

class SomeProto(Protocol):
attr: int # Note, no right hand side
def method(self) -> str: ... # Literal ... here
def method(self) -> str: ... # Literally just ... here

class ExplicitSubclass(SomeProto):
pass

ExplicitSubclass() # error: Cannot instantiate abstract class 'ExplicitSubclass'
# with abstract attributes 'attr' and 'method'

Invariance of protocol attributes
*********************************

A common issue with protocols is that protocol attributes are invariant.
For example:

.. code-block:: python

class Box(Protocol):
content: object

class IntBox:
content: int

def takes_box(box: Box) -> None: ...

takes_box(IntBox()) # error: Argument 1 to "takes_box" has incompatible type "IntBox"; expected "Box"
# note: Following member(s) of "IntBox" have conflicts:
# note: content: expected "object", got "int"

This is because ``Box`` defines ``content`` as a mutable attribute.
Here's why this is problematic:

.. code-block:: python

def takes_box_evil(box: Box) -> None:
box.content = "asdf" # This is bad, since box.content is supposed to be an object

my_int_box = IntBox()
takes_box_evil(my_int_box)
my_int_box.content + 1 # Oops, TypeError!

This can be fixed by declaring ``content`` to be read-only in the ``Box``
protocol using ``@property``:

.. code-block:: python

class Box(Protocol):
@property
def content(self) -> object: ...

class IntBox:
content: int

def takes_box(box: Box) -> None: ...

takes_box(IntBox(42)) # OK

Recursive protocols
*******************

Expand Down Expand Up @@ -197,7 +253,7 @@ Using isinstance() with protocols

You can use a protocol class with :py:func:`isinstance` if you decorate it
with the ``@runtime_checkable`` class decorator. The decorator adds
support for basic runtime structural checks:
rudimentary support for runtime structural checks:

.. code-block:: python

Expand All @@ -214,16 +270,23 @@ support for basic runtime structural checks:
def use(handles: int) -> None: ...

mug = Mug()
if isinstance(mug, Portable):
use(mug.handles) # Works statically and at runtime
if isinstance(mug, Portable): # Works at runtime!
use(mug.handles)

:py:func:`isinstance` also works with the :ref:`predefined protocols <predefined_protocols>`
in :py:mod:`typing` such as :py:class:`~typing.Iterable`.

.. note::
.. warning::
:py:func:`isinstance` with protocols is not completely safe at runtime.
For example, signatures of methods are not checked. The runtime
implementation only checks that all protocol members are defined.
implementation only checks that all protocol members exist,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It also doesn't check attributes at all, only methods, if I recall correctly. That might be worth mentioning.

Copy link
Member

@ilevkivskyi ilevkivskyi Feb 2, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isinstance() checks both, but issubclass() checks only methods.

not that they have the correct type. :py:func:`issubclass` with protocols
will only check for the existence of methods.

.. note::
:py:func:`isinstance` with protocols can also be surprisingly slow.
In many cases, you're better served by using :py:func:`hasattr` to
check for the presence of attributes.

.. _callback_protocols:

Expand Down