Course outline

Python / Maintainable systems / Lesson 13

Protocols, special methods, and type contracts

Design interfaces around required behavior, distinguish static promises from runtime validation, and implement special methods with consistent semantics.

About 4 min readContent reviewed
On this page

Lesson goals

  • Behavior defines a usable interface
  • Preserve substitutability

Behavior defines a usable interface

Python protocols describe operations an object supports. A for loop needs iteration behavior; len needs length behavior. This allows user-defined objects to cooperate with language constructs without inheriting one universal application base class. Special methods such as __len__ and __iter__ are discovered by the interpreter according to the data model. Implement a small coherent set rather than adding every method that seems convenient. A length must be a nonnegative integer and should agree with the object’s iteration semantics when it models a finite collection. An apparently compatible method name is insufficient when its behavior violates the caller’s assumptions.

Annotations communicate, not enforce

Type annotations document expected shapes and support static analysis, editor assistance, and review. The Python runtime does not generally enforce them. A function annotated with an integer parameter may still receive a string until an operation fails or explicit validation rejects it. Type hints therefore complement boundary validation instead of replacing it. Use standard collection generics such as list[str] and unions such as int | None, both available on Python 3.11. Keep this course’s syntax compatible with that baseline; later type-parameter declaration syntax belongs to explicitly newer-version material. A checked contract is valuable only when the project actually runs an appropriate checker.

Implement a finite collection

The first example snapshots an input iterable into a tuple, so each later iteration gets its own iterator over stable outer contents. Implementing __iter__ by returning iter(self._items) differs from returning one permanently stored iterator: the latter would exhaust the whole object after its first traversal. __len__ reports the snapshot’s size, and sum consumes its iteration protocol. This object intentionally exposes only what the demonstration needs. It does not claim list-style mutation or indexing. Narrow interfaces reduce the chance that callers rely on accidental features, while explicit ownership of the snapshot makes repeated traversal predictable.

python Implement a finite collection — worked example
class Scores:
    def __init__(self, values):
        self._items = tuple(values)
    def __len__(self):
        return len(self._items)
    def __iter__(self):
        return iter(self._items)

scores = Scores([4, 6, 8])
print(len(scores), sum(scores))
print(list(scores))
Expected output
3 18
[4, 6, 8]

Length and iteration agree, and a second consumer gets a fresh iterator rather than an exhausted stored iterator.

Structural typing with Protocol

typing.Protocol describes required members for structural type checking. A class can satisfy the interface by implementing those members without explicitly inheriting the protocol. This is useful for dependency injection: a formatter can require a read method rather than a particular storage implementation. The example supplies a tiny source and consumes its declared interface. It runs without a third-party checker, so its successful execution demonstrates behavior, not proof that a checker analyzed it. runtime_checkable, when used, provides limited runtime member checks rather than full signature or semantic verification; never treat it as validation of untrusted payloads or as proof of behavioral correctness.

python Structural typing with Protocol — worked example
from typing import Protocol

class TextSource(Protocol):
    def read(self) -> str:
        ...

class MemorySource:
    def read(self) -> str:
        return "ready"

def headline(source: TextSource) -> str:
    return source.read().upper()

print(headline(MemorySource()))
Expected output
READY

MemorySource supplies the required read behavior without inheriting TextSource. The protocol body is a declaration, while the concrete source provides the actual implementation.

Preserve substitutability

A substitute should accept the promised inputs, return compatible results, and preserve documented side effects and failure behavior. Static structural compatibility cannot prove those semantic properties. Use contract tests shared across implementations when several adapters promise the same interface. Prefer the smallest useful protocol so a simple fake can implement it without recreating an entire service. For operators, equality, and hashing, follow the data model’s consistency rules: equal hashable objects need equal hashes, and mutable value-based keys can corrupt lookup assumptions. When an operation does not support an operand type, the relevant special method may need NotImplemented rather than an unrelated exception or a fabricated answer.

Common errors and fixes

Annotated input still reaches invalid runtime code

Why it happens
Annotations were mistaken for automatic runtime guards.
How to fix it
Validate external data explicitly and run static checking separately.
Prevent it next time
Document where each guarantee is enforced.

A collection works only on its first traversal

Why it happens
__iter__ returns one stored iterator.
How to fix it
Return a fresh iterator over stored collection data.
Prevent it next time
Test two independent traversals of collection-like objects.

A mutable key can no longer be located

Why it happens
Its hash-related state changed after insertion.
How to fix it
Use stable immutable keys with consistent equality and hashing.
Prevent it next time
Avoid value-based hashes for mutable domain objects.

Check your understanding

1. Design the smallest interface for a function that only sends a text notification.

Show worked solution

Define a Protocol with send(message: str) -> None. The consumer accepts that protocol, while a real adapter and a recording fake implement it independently. Add contract tests for message meaning and failure behavior; requiring a whole mail-service class would introduce unused dependencies.

2. Why does successful isinstance checking against a runtime protocol not prove a safe adapter?

Show worked solution

Runtime protocol checks are limited and do not establish complete parameter types, return semantics, authorization, or exception policy. A member can exist and still behave incorrectly. Validate trust boundaries separately and use behavioral tests for the adapter’s documented promises.

Key takeaways

  • Protocols describe behavior required by a consumer.
  • Annotations support tools and do not replace validation.
  • Semantic contracts need behavioral tests.

Official references