We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← The Type System as a Design Tool step 22 of 24
runtime_checkable is presence, not a signature
Engineers reach for hasattr believing it gives isinstance-grade safety. It
gives them Any.
The hasattr hole, verified
def f(x: object) -> None:
if hasattr(x, "quack"):
reveal_type(x) # object
reveal_type(x.quack) # Any
x.quack(1, 2, 3, "nonsense", keyword=object()) # type-checks fine
Inside the branch the call is allowed, but x itself is still object and
the attribute is Any. So every call through it is unchecked, with any
arguments, in any arity. This is a bigger hole than most people assume — it is
not “narrower typing”, it is no typing, in the one place you thought you had
added a safety check.
@runtime_checkable is a smaller hole, but a hole
@runtime_checkable
class SupportsClose(Protocol):
def close(self) -> None: ...
Now isinstance(x, SupportsClose) works and narrows x to SupportsClose, so
x.close() is checked against the declared signature. Much better. But
understand exactly what the runtime check does:
-
it checks member presence only — not signatures, not parameter types, not
return types. A
closetaking a required argument passes. Aclosethat is the integer3passes. -
since Python 3.12 it uses
inspect.getattr_static, which does not invoke the descriptor protocol. Two consequences: a__getattr__catch-all no longer satisfies the protocol (it did before 3.12), and a@propertythat raises is no longer triggered by the check. -
it is
O(members)per call and noticeably slower than a nominalisinstance; it is not something to put in a hot loop. -
it cannot be used with non-method members at all — a
@runtime_checkableprotocol with data attributes raisesTypeErroronisinstance.
The one-sentence version: @runtime_checkable + isinstance is not interface
validation. It is a hasattr loop with better static typing bolted on.
The task
@runtime_checkable
class SupportsClose(Protocol):
def close(self) -> None: ...
def close_all(objects: Iterable[object]) -> int: ...
def solve(kinds: list[str]) -> tuple[int, list[str]]: ...
close_all calls close() on everything that satisfies the protocol, counts
the successes, and swallows whatever close() raises. A failure is not a
success.
Six classes are provided along with a FACTORIES table keyed by name:
| kind | what it is |
isinstance? |
close() |
|---|---|---|---|
plain |
an ordinary closer | yes | works |
raising |
close raises OSError |
yes | raises |
arity |
close(self, force: bool) |
yes |
TypeError — missing argument |
notcallable |
close = 3 |
yes |
TypeError — int is not callable |
dynamic |
serves close from __getattr__ |
no (3.12+) | never called |
noclose |
no such member | no | never called |
solve constructs one object per kind, in order, and returns
(close_all(objects), [type(o).__name__ for o in objects if isinstance(o, SupportsClose)]).
The second element is the teaching artefact: it is the set the runtime check accepted, and comparing it to the count shows you exactly how many of those acceptances were worthless.
What this forces you to write
close_all must be defensive in a way that looks paranoid until you read the
table: the exception handler has to catch TypeError from a badly shaped
close, not only the OSError from a legitimately failing one. A structural
check told you the member exists. It told you nothing about whether calling it
is meaningful.
That is the general shape of duck typing at a boundary: presence is cheap to verify, and behaviour is not verifiable at all. If you need behaviour, you need either a nominal type you control or a call wrapped in a handler — and usually both.
Loading visualization…
Stuck?
Python reference solution
Sign in to attempt this problem and reveal the reference solution.