First steps
Understanding why pysmo is structured the way it is requires a brief look at how Python thinks about types. This section covers type hints, duck typing, and structural subtyping: the three ideas that together make protocol-based design possible. The concepts are not specific to pysmo and are worth understanding in their own right. Those already comfortable with typing in Python can skip ahead to installation.
Use a modern editor
Python's type system only pays off in full when the editor understands it too. A modern editor or IDE such as VSCode, PyCharm, or Neovim flags type errors as the code is written, turning hints into immediate feedback.
Type hints
Python is a dynamically typed language: the type (float, str,
etc.) of a variable is not fixed until a value is assigned at runtime. This is
convenient, but it means type errors only surface when the offending code runs.
Consider this simple function:
With numeric arguments it works as expected(1):
- In Python, dividing two integers always creates a float.
Passing strings instead:
>>> division("hello", "world")
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 2, in division
TypeError: unsupported operand type(s) for /: 'str' and 'str'
>>>
There is nothing wrong syntactically. Python accepts the call without
complaint. The error only appears at runtime, when the / operator is applied
to strings. To catch these issues earlier, Python allows adding type
annotations:
def division(a: float, b: float) -> float: # (1)!
return a / b
division("a", "b") # <- produces editor warning
division(1, 2) # <- OK
- The return type annotation matters too. If the output of
divisionis used elsewhere, downstream code knows what type to expect.
Running mypy over the file reports the bad call, without executing anything:
division_annotated.py:5: error: Argument 1 to "division" has incompatible type "str"; expected "float" [arg-type]
division_annotated.py:5: error: Argument 2 to "division" has incompatible type "str"; expected "float" [arg-type]
Found 2 errors in 1 file (checked 1 source file)
A type-aware editor shows the same errors inline, as the code is written (1).
- typically with squiggly red underlines and error messages on hover.
The hints are not enforced at runtime. Python still runs division("a", "b")
and raises the same TypeError as before. Their job is to surface the
mistake earlier: in the editor, in review, in CI.
Duck typing
The hints above name built-in types like float and str. Most code also
passes around objects, often instances of purpose-built classes. A function can
name such a class in its signature, for example thing: Duck. That works, but
it is often stricter than needed. If the function only calls thing.quack(),
any object with a quack() method would serve.
Focusing on what an object can do, rather than what it is, is duck typing. The name comes from calling something a duck when it walks and quacks like one. The following example defines two classes and a function that accepts either. It checks the behaviour, not the type:
class Duck: # (1)!
def quack(self):
return "quack, quack!"
def waddle(self):
return "waddle, waddle!"
class Human: # (2)!
def quack(self):
return "quack, quack!"
def waddle(self):
return "waddle, waddle!"
def is_a_duck(thing): # (3)!
try:
thing.quack()
thing.waddle()
print("I must be a duck!")
except AttributeError:
print("I'm unable to walk and talk like a duck.")
- Two methods:
quackandwaddle. - A human can also quack and waddle.
- Accepts anything that can
quackandwaddle, not justDuckinstances.
>>> from snippets.duck import Duck, Human, is_a_duck
>>> donald = Duck()
>>> joe = Human()
>>> is_a_duck(donald)
I must be a duck!
>>> is_a_duck(joe)
I must be a duck!
>>>
is_a_duck never checks the type of its argument, only whether it has quack
and waddle. Sometimes that is exactly what is needed.
Duck typing in the wild
A real-world example of duck typing in Python is the built-in
len() function:
>>> my_string = "hello world"
>>> len(my_string) # the len() function works with a string (1)!
11
>>> my_list = [1, 2, 3]
>>> len(my_list) # and with a list (2)!
3
>>> my_int = 42
>>> len(my_int) # but not with an integer (3)!
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: object of type 'int' has no len()
>>>
- The
len()function works with a string, where it returns the number of characters in the string ... - ... and with a list, where it returns the number of items in the list.
- But not with an integer.
Behind the scenes, len() doesn't check the input type. It checks whether the
object has the __len__() method that len() looks for:
Without a type signature, is_a_duck() is fragile. Changes to Duck or
Human that break the function would only surface at runtime. Adding one helps:
This is safer, but now tightly coupled to both Duck and Human. Adding a
third compatible class means updating the function. Changes to either class
become potential edits everywhere it is used. Type hints used this way scale
poorly. Protocol classes offer a better approach.
Structural subtyping (static duck typing)
A Protocol class defines a structure: the attributes and methods a conforming
class must provide. No inheritance is required. Any class that matches is
implicitly a subtype. The match is checked statically, by mypy or the editor,
rather than at runtime. This is
structural subtyping:
duck typing with static checking. Revisiting the duck example with an additional
Robot class:
from typing import Protocol
class Ducklike(Protocol): # (1)!
def quack(self) -> str: ... # (2)!
def waddle(self) -> str: ...
class Duck: # (3)!
def quack(self) -> str:
return "quack, quack!"
def waddle(self) -> str:
return "waddle, waddle!"
class Human: # (4)!
def quack(self) -> str:
return "quack, quack!"
def waddle(self) -> str:
return "waddle, waddle!"
def dance(self) -> str:
return "shaking those hips!"
class Robot: # (5)!
def quack(self) -> bytes:
return bytes("beep, quack!", encoding="utf-8")
def waddle(self) -> str:
return "waddle, waddle!"
def is_a_duck(thing: Ducklike) -> None: # (6)!
try:
thing.quack()
thing.waddle()
print("I must be a duck!")
except AttributeError:
print("I'm unable to walk and talk like a duck.")
- Defines the
Ducklikeprotocol: any class with matchingquackandwaddlesignatures satisfies it, no inheritance required. - Ellipses (
...) are preferred overpasshere. - Implicitly
Ducklike: the structure matches, so no explicit declaration is needed. - Also
Ducklikedespite having an extradancemethod; the protocol only requires what it defines. Robot.quack()returnsbytes, notstr. Close, but notDucklike.- Typed against the protocol rather than specific classes.
Robotwill be flagged by mypy or the editor, whileDuckandHumanpass.
The runtime behaviour is the same as before:
>>> from snippets.duck_protocol import Duck, Human, Robot, is_a_duck
>>>
>>> donald = Duck()
>>> joe = Human()
>>> robert = Robot()
>>> is_a_duck(donald)
I must be a duck!
>>> is_a_duck(joe)
I must be a duck!
>>> is_a_duck(robert)
I must be a duck!
>>>
Python does not enforce type hints at runtime, so all three calls succeed. The
difference only shows up statically. Robot.quack() returns bytes instead
of str, which does not satisfy the Ducklike signature, so mypy or the editor
flags the is_a_duck(robert) call before the code runs.
Two properties of Protocol classes matter here:
- A function typed against a protocol is decoupled from any particular implementation. It works with any class that satisfies the structure, including ones written long afterwards.
- Conforming classes must match all protocol attributes, but may have others.
is_a_duck()works withDuckandHumandespite methods it never touches.
Protocol classes are typically much simpler than the classes they describe(1).
They contain only what a function needs to know. Think of them as a contract. A
class that satisfies a protocol guarantees that interface regardless of what
else it does. Functions written against it are free to ignore everything else.
In pysmo, these contracts are the types, covered in depth in the
Usage chapter.
- Unlike a regular class, a
Protocolclass contains only structural information: no data, no implementation.
Next steps
- Learn more about type hinting and static analysis with mypy.
- Switch to an editor that checks code as it is written, if not already using one.
- Continue to the next chapter and install pysmo.