I spent much of my career promoting objects. I wrote Thinking in C++ and Thinking in Java, served on the C++ Standards Committee for its first eight years, and toured the world giving object-oriented programming (OOP) presentations. When I say I have come to doubt that objects should be the default, it is not an outsider’s complaint.
This part of the book is about design patterns, most of which assume objects and inheritance. First, however, I want to question how much of that machinery we actually need. This chapter adapts my PyCon 2023 talk, Rethinking Objects, and my StrangeLoop presentation Polymorphism Unbound.
Languages evolve to fit their environment. A feature that looks strange now usually made sense for the problem, and the hardware, of its time. Consider the origin of OOP.
Simula introduced objects in the 1960s to model simulations: a system is a set of things that interact. Notably, not everything in Simula was an object. The language still had standalone functions. It was a compiled, statically typed language, so the discipline later named the Liskov Substitution Principle (LSP) fit naturally.
Smalltalk took the other path: everything is an object, and the only thing you do is send messages to objects, always late-bound. It was an emphatically dynamic, run-time world where you built programs by finding the closest existing object and inheriting from it to add behavior. That style makes no substitutability promises.
C++ drew from Simula. Objects were optional, and it brought object-oriented programming and exceptions into the mainstream.
Java drew from Smalltalk. Everything lives inside a class, even when all you need is a function. Java is statically compiled, so substitutability matters, yet it encouraged reusing code by inheriting implementation, which pulls in the other direction.
Newer languages backed away from inheritance. Rust, Swift,
Go, and Kotlin lean on data structures over deep class
hierarchies. They favor immutability. Rust makes bindings
immutable by default. Swift and Kotlin encourage immutability
through let and val (Go has no general
immutability). They compose data structures instead of
inheriting implementation, and they let code live outside
classes, reducing duplication. The industry has been quietly
walking back from “everything is an object” and from
implementation inheritance.
The Liskov Substitution Principle (LSP) says that an object of a subtype must work anywhere code expects an object of its base type. A subclass may add behavior, but it must honor the base class contract. It accepts the same arguments, returns the same kinds of results, and raises no surprising exceptions. When subclasses obey it, code written against the base class works unchanged on any of them. This is what makes polymorphism, and patterns like the Template Method, safe. A statically typed compiler can check that an override’s signature stays compatible. It cannot check whether the override actually behaves the way the base class promises. The base class calls a method and trusts every subclass to stand in for it.
Python has no compiler, but this is not the boundary you
might expect. A type checker reads an override’s signature and
reports one that no longer fits, especially when @override
marks the intent. What no tool reads is the behavior behind the
signature. Nothing stops a subclass from breaking the base class
contract while matching it perfectly. The interpreter runs code
that violates the LSP without objection. That code may or may
not fail at run time.
The first OOP promise is encapsulation: hide the data, expose it only through methods you control. Python hides a field with a leading underscore and a read-only property. This does not work as well as it looks. A getter that returns a mutable object hands the caller a reference to the underlying internals:
# leaky.py
from dataclasses import dataclass
@dataclass
class Bob:
name: str = "Bob"
class Leaky:
def __init__(self, numbers: list[int]) -> None:
self._numbers = numbers # "Private" by convention
self._bob = Bob()
@property
def numbers(self) -> list[int]:
return self._numbers
@property
def bob(self) -> Bob:
return self._bob
if __name__ == "__main__":
leaky = Leaky([1, 2])
print(leaky.numbers is leaky._numbers) # The same object
# Both mutate the "private" internals through the getters:
leaky.numbers.append(999)
leaky.bob.name = "Ralph"
print(leaky.numbers, leaky.bob)
#: True
#: [1, 2, 999] Bob(name='Ralph')Encapsulation with private fields and getters still leaks.
The is check shows the mechanism: the getter does
not return a view or a snapshot of the list, it returns a
reference to the list, the identical object the underscore was
hiding. Python’s return hands out references, never
copies. The property blocked reassigning numbers,
but it could not stop the caller from mutating the list it
returned. Mutating the returned list manipulates the internal
state.
You can stop the leak by copying everything a getter returns. It works, but every getter must remember to do it, and so does every getter in every subclass, forever:
# plugged.py
from copy import deepcopy
from dataclasses import dataclass
@dataclass
class Bob:
name: str = "Bob"
class Plugged:
def __init__(self, numbers: list[int]) -> None:
self._numbers = numbers
self._bob = Bob()
@property
def numbers(self) -> list[int]:
return self._numbers.copy() # Isolate by returning a copy
@property
def bob(self) -> Bob:
return deepcopy(self._bob)
if __name__ == "__main__":
plugged = Plugged([1, 2])
plugged.numbers.append(999) # Mutates the copy
plugged.bob.name = "Ralph" # Ditto
print(plugged.numbers, plugged.bob)
#: [1, 2] Bob(name='Bob')Now the internals are safe, but look at what we are doing. We add private fields, getters, and defensive copies, all to stop other code from changing our data. And these copies plug only the outbound leak. The constructor stored the caller’s own list, so the caller’s original reference still mutates the internals. A fully defensive class must copy on the way in as well.
A @dataclass version of Plugged
would trim the constructor, but its generated
__repr__ prints _numbers and
_bob directly, leaking the internals yet again.
The two getters also copy differently, and the difference is
its own trap. list.copy() is shallow: it
duplicates the container but shares the elements, which is safe
here only because the elements are immutable ints.
Bob gets deepcopy(), which recursively
copies everything the object references, the conservative choice
when a field’s own fields might be mutable. A shallow copy of a
list of Bobs would plug nothing: the caller’s copy
of the list would still hold your actual Bobs.
Testing confirms the defensive copy holds. Mutating the returned list leaves the original untouched:
# test_plugged.py
from plugged import Plugged
def test_defensive_copy_prevents_the_leak() -> None:
plugged = Plugged([1, 2])
plugged.numbers.append(999) # Mutates only a copy
assert plugged.numbers == [1, 2]Encapsulation exists only because of mutability. If the data cannot change, there is nothing to protect. Freeze it, and the whole apparatus disappears. The fields are public, there are no getters, and there are no copies:
# immutable.py
from dataclasses import dataclass
@dataclass(frozen=True)
class Bob:
name: str = "Bob"
@dataclass(frozen=True)
class Immutable:
numbers: tuple[int, ...]
bob: Bob
if __name__ == "__main__":
immutable = Immutable((1, 2), Bob())
print(immutable)
# immutable.numbers is a tuple, so it has no append.
# immutable.bob.name = "Ralph" raises FrozenInstanceError.
#: Immutable(numbers=(1, 2), bob=Bob(name='Bob'))The frozen object refuses mutation:
# test_immutable.py
import dataclasses
import pytest
from immutable import Bob, Immutable
def test_frozen_cannot_be_mutated() -> None:
immutable = Immutable((1, 2), Bob())
with pytest.raises(dataclasses.FrozenInstanceError):
# Frozen, so the assignment fails:
setattr(immutable.bob, "name", "Ralph")Two quiet changes in the listing do as much work as
frozen=True. frozen=True is shallow:
it stops assignment to the fields of Immutable
itself, but it cannot stop mutation inside a field that is
itself mutable. Declare the field as a list instead
and the leak reopens:
# frozen_leaky.py
from dataclasses import FrozenInstanceError, dataclass
@dataclass(frozen=True)
class FrozenLeaky:
numbers: list[int] # A mutable field in a frozen class
fl = FrozenLeaky([1, 2])
fl.numbers.append(999) # frozen=True does not stop this
print(fl.numbers)
#: [1, 2, 999]
try:
fl.numbers = [] # type: ignore
except FrozenInstanceError as e:
print(type(e).__name__)
#: FrozenInstanceErrorFrozen guards the binding, not the object.
fl.numbers must keep pointing at the same list, and
attempting to rebind it raises an exception. But nothing stops
that list from changing, the identical leak Leaky
had. That is why numbers became a
tuple and Bob was also frozen.
Immutability pays off only when it goes all the way down.
Data Classes as Types makes the fuller case for frozen data classes.
The second OOP promise is that behavior belongs inside the
object, as methods. But a method is only a function whose first
argument is the object. Compare a method
distance_to() to a function distance()
that does the same thing:
# point_distance.py
from dataclasses import dataclass
from math import sqrt
@dataclass(frozen=True)
class Point:
x: float
y: float
def distance_to(self, other: Point) -> float: # Method
return sqrt((other.x - self.x) ** 2 + (other.y - self.y) ** 2)
def distance(a: Point, b: Point) -> float: # Free function
return sqrt((b.x - a.x) ** 2 + (b.y - a.y) ** 2)
if __name__ == "__main__":
p1, p2 = Point(3, 0), Point(0, 4) # A 3-4-5 right triangle
print(p1.distance_to(p2))
print(Point.distance_to(p1, p2)) # The method, as a function
print(distance(p1, p2))
#: 5.0
#: 5.0
#: 5.0The middle call shows the method called as if it were a free
function. Fetched from the class instead of from an instance,
distance_to is an ordinary function, and
p1 is passed to it as the first argument.
p1.distance_to(p2) is shorthand for that call. The
dot fills in self, nothing more. The function reads
the same and computes the same. It is not worse, and it has an
advantage. It need not live inside Point.
When the function does not belong to a class, it can work on
anything shaped like a point. A Protocol describes
that shape, and any type with matching attributes satisfies it,
without inheritance. This is structural
typing. When someone hands you a type that does not fit, you
adapt it by composition, not inheritance:
# distance_protocol.py
from dataclasses import dataclass
from math import sqrt
from typing import Protocol
class Coord(Protocol):
@property
def x(self) -> float: ...
@property
def y(self) -> float: ...
def distance(a: Coord, b: Coord) -> float:
return sqrt((b.x - a.x) ** 2 + (b.y - a.y) ** 2)
@dataclass(frozen=True)
class Point:
x: float
y: float
@dataclass(frozen=True)
class Pair: # Suppose you are handed this, with no x or y
a: float
b: float
@dataclass(frozen=True)
class PairCoord: # Adapter: uses composition, not inheritance
pair: Pair
@property
def x(self) -> float:
return self.pair.a
@property
def y(self) -> float:
return self.pair.b
if __name__ == "__main__":
print(distance(Point(3, 0), Point(0, 4)))
print(distance(PairCoord(Pair(3, 0)), PairCoord(Pair(0, 4))))
#: 5.0
#: 5.0Point and PairCoord share no base
class. They both have x and y, which
is all distance() requires.
The third OOP promise is reuse through inheritance. In practice, implementation inheritance couples a subclass to its base in ways that are hard to undo.
Before inheritance, there was composition. A type holds other
types as fields. dataclasses.replace() gives you
the copy-with-changes that immutability needs, and frozen
instances compare by value and work as keys:
# composition.py
from dataclasses import dataclass, replace
@dataclass(frozen=True)
class Name:
first: str
last: str
@dataclass(frozen=True)
class Address:
city: str
postal: str
@dataclass(frozen=True)
class Contact: # A Contact has a Name and an Address
name: Name
address: Address
c = Contact(
Name("Gerald", "Spigot-Farthingale"),
Address("Sodding-on-the-Wold", "12345")
)
print(c.name)
#: Name(first='Gerald', last='Spigot-Farthingale')
print(c.address)
#: Address(city='Sodding-on-the-Wold', postal='12345')
# A copy with one nested field changed leaves c intact
moved = replace(c,
address=replace(c.address, city="Fenwick-under-Custard"))
print(c.address.city, "->", moved.address.city)
#: Sodding-on-the-Wold -> Fenwick-under-Custard
twin = Contact(
Name("Gerald", "Spigot-Farthingale"),
Address("Sodding-on-the-Wold", "12345")
)
print(c == twin) # Value equality, field by field
#: True
print({c: "value"}[c]) # Hashable, so it works as a dict key
#: valueContact inherits nothing, and gains no methods
it did not ask for. It holds a Name and an
Address, and those types stay usable on their own.
The nested replace() shows the cost of that
arrangement: changing a city means rebuilding the
Address and then the Contact. What you
buy is the last two lines. Two contacts built from equal parts
are equal, and the whole structure hashes, because value
equality and hashing follow from the fields rather than from a
base class.
The fourth OOP promise is polymorphism.
The classic object-oriented example uses an abstract base class:
# shapes_oo.py
import math
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import override
class Shape(ABC):
@abstractmethod
def area(self) -> float: ...
@dataclass(frozen=True)
class Rectangle(Shape):
length: float
width: float
@override
def area(self) -> float:
return self.length * self.width
@dataclass(frozen=True)
class Circle(Shape):
radius: float
@override
def area(self) -> float:
return math.pi * self.radius**2
if __name__ == "__main__":
for shape in [Circle(1.0), Rectangle(3.0, 4.0)]:
print(round(shape.area(), 4))
#: 3.1416
#: 12.0Inheriting from ABC makes Shape
abstract. You cannot instantiate it, and
@abstractmethod forces every subclass to define
area().
With dynamic typing, any type works as long as it has the necessary method(s). There’s no shared base class or declared set of types, and the only validity check happens at runtime, when the call runs:
# dynamic_typing.py
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True)
class Bicycle:
id: str
def display(self) -> str:
return f"Bicycle {self.id}"
@dataclass(frozen=True)
class Glider:
size: int
def display(self) -> str:
return f"Glider {self.size}"
def show(t: Any) -> str:
return t.display()
if __name__ == "__main__":
for item in (Bicycle("Bob"), Glider(65)):
print(show(item))
#: Bicycle Bob
#: Glider 65show() accepts anything: Any, which
is not object. Pass it something without a
display() method and you’ll get an exception when
the line runs. If we used t: object (the safe top
type), show() would fail the type checker because
object has no display() method.
Any switches the checker off for t,
and this permits any type. Each Any parameter moves
you back into dynamic typing.
Protocol
re-establishes static typing:
# protocols_typed.py
from typing import Protocol
from dynamic_typing import Bicycle, Glider
class Displayable(Protocol):
def display(self) -> str: ...
def show(t: Displayable) -> str:
return t.display()
if __name__ == "__main__":
for item in (Bicycle("Bob"), Glider(65)):
print(show(item))
#: Bicycle Bob
#: Glider 65A structural type describes the required shape, and the checker verifies it ahead of time. Dynamic typing and protocols are the same idea, checked at different times.
An abstract base class is nominal (named): a type joins by inheriting from it. Membership is declared in the subclass’s own source, and the base can carry shared implementation for its children. A protocol is structural: it works with any type that has matching members. This includes types in libraries you cannot edit. The type’s author never needs to hear that your protocol exists. That independence is why this chapter emphasizes protocols. They connect pieces without requiring any piece to change.
Because membership is structural, one class can satisfy any
number of protocols at once, with no inheritance graph
connecting them. Each protocol only names the shape it needs.
Nothing forces Invoice below to acknowledge
Priced, Serializable, or
Loggable.
Classic multiple inheritance has the diamond problem. If two base classes trace back to a common ancestor, the interpreter must pick which version of an overridden method to call. Protocols avoid that question. Satisfying three of them costs nothing more than having the three methods:
# multi_protocol.py
import json
from dataclasses import asdict, dataclass
from typing import Protocol
class Priced(Protocol):
def total(self) -> float: ...
class Serializable(Protocol):
def to_json(self) -> str: ...
class Loggable(Protocol):
def describe(self) -> str: ...
@dataclass(frozen=True)
class Invoice:
amount: float
customer: str
def total(self) -> float:
return self.amount
def to_json(self) -> str:
return json.dumps(asdict(self))
def describe(self) -> str:
return f"Invoice for {self.customer}"
def charge(item: Priced) -> float:
return item.total()
def persist(item: Serializable) -> str:
return item.to_json()
def audit(item: Loggable) -> str:
return item.describe()
if __name__ == "__main__":
invoice = Invoice(19.99, "Ada")
print(charge(invoice))
print(persist(invoice))
print(audit(invoice))
#: 19.99
#: {"amount": 19.99, "customer": "Ada"}
#: Invoice for AdaInvoice inherits from nothing but
object, yet charge(),
persist(), and audit() each accept it,
because each only checks the one method it needs.
That same structural check has a blind spot. Two unrelated protocols can share a method name and signature by coincidence, and nothing distinguishes them:
# protocol_collision.py
from dataclasses import dataclass
from typing import Protocol
class Priced(Protocol):
def total(self) -> float: ...
class Weighted(Protocol):
def total(self) -> float: ...
@dataclass(frozen=True)
class Package:
weight_kg: float
def total(self) -> float:
return self.weight_kg # Weight, not a price
def charge(item: Priced) -> float:
return item.total()
if __name__ == "__main__":
package = Package(4.5)
print(charge(package)) # Silently treated as a price
#: 4.5ty accepts package as a
Priced argument without complaint, and
charge() returns 4.5, silently
treating a weight as a price. The checker matched the shape
correctly. The mismatch lives entirely in what the number means,
and no checker sees that.
A distinct type per concept could close this gap.
typing.NewType looks like the natural fix:
# newtype_boundary.py
from typing import NewType
UserId = NewType("UserId", int)
def lookup(uid: UserId) -> str:
return f"user-{uid}"
if __name__ == "__main__":
print(lookup(UserId(42)))
# ty: expected "UserId", found "int":
print(lookup(42)) # type: ignore
#: user-42
#: user-42Without the # type: ignore, ty
rejects the second call. UserId and
int are different types to the checker. The same
distinction would separate Priced and
Weighted in protocol_collision.py, if
total() returned a Price or a
Weight instead of a bare float.
NewType is only an aid during type checking. It
builds no wrapper object. At runtime it is the identity
function, so UserId(42) is 42:
# test_newtype_boundary.py
from newtype_boundary import UserId
def test_newtype_has_no_runtime_effect() -> None:
assert UserId(42) == 42
assert type(UserId(42)) is intNothing in that test can fail. The NewType
protection lives in the checker alone. A caller who passes a raw
int where UserId is expected raises no
exception. The value doesn’t change. There is nothing at runtime
for a test to catch. Only the checker sees it, and only at edit
time. Here, that diagnostic is silenced by the same
# type: ignore that lets this book’s build pass. Data
Classes as Types takes the other route. A frozen dataclass
with a validating __post_init__ enforces the
distinction at runtime too, at the cost of a constructor call
instead of a bare literal.
A protocol also sharpens what the Liskov Substitution Principle does and does not get you. Satisfying a protocol is a shape claim: the methods exist, with the correct signatures, and the checker verifies that half of the contract. The other half is semantic. A method must also behave the way callers expect. For example, return a description rather than raise an exception, finish rather than block, describe the object rather than change it. No checker sees that half. Substitution is safe only when both halves work, whether membership came from inheriting a base class or matching a protocol. The machine checks the signatures; you still own the behavior.
Another approach uses a union and a match,
introduced in Pattern
Matching. The shapes become immutable data, and one free
function handles all the cases. There is no base class or
overridden methods. The type checker ensures that the match
covers every shape. Here is shapes_oo.py modified
to use pattern matching:
# shapes_match.py
import math
from dataclasses import dataclass
from typing import assert_never
@dataclass(frozen=True)
class Rectangle:
length: float
width: float
@dataclass(frozen=True)
class Circle:
radius: float
type Shape = Rectangle | Circle
def area(shape: Shape) -> float:
match shape:
case Rectangle(length=length, width=width):
return length * width
case Circle(radius=radius):
return math.pi * radius**2
case _:
assert_never(shape)
if __name__ == "__main__":
shapes: list[Shape] = [Circle(1.0), Rectangle(3.0, 4.0)]
for shape in shapes:
print(round(area(shape), 4))
#: 3.1416
#: 12.0Adding a new shape is easier in the OOP version because you write one class. Adding a new operation over all shapes is easier in the pattern matching (functional) version. Modify one function, and the type checker tells you if you missed a case.
The OOP approach assumes you add types more often than operations, which is often not true. Multiple Dispatching and Visitor explore this trade-off.
Inheritance is only one expression of polymorphism. More broadly, polymorphism means that a function parameter accepts more than one type. The questions are which types it accepts and what the function may do with them.
Type theory defines three kinds of polymorphism. Christopher Strachey’s 1967 lecture notes, Fundamental Concepts in Programming Languages, named the first two, parametric and ad hoc. Cardelli and Wegner added the third, subtyping, in 19851.
Subtype polymorphism was demonstrated in Polymorphism Without
Inheritance. One function accepts any type that fits a
shape, whether that shape comes from inheriting an
ABC or matching a Protocol. The caller
writes one function. The type varies underneath it.
Parametric polymorphism is a single implementation
for multiple types. Static
Typing’s first[T] and Box[T] show
this. One body works for any T. The checker infers
the concrete type behind T at each call site.
With ad-hoc polymorphism (typically function
overloading), a different implementation handles each type,
chosen by that type. Python’s version of ad-hoc polymorphism is
@overload, which lets one function name have
multiple typed signatures, backed by a single implementation
that branches at run time:
# overload_example.py
from typing import overload
@overload
def stringify(value: int) -> str: ...
@overload
def stringify(value: list[int]) -> list[str]: ...
def stringify(value: int | list[int]) -> str | list[str]:
if isinstance(value, list):
return [str(v) for v in value]
return str(value)
if __name__ == "__main__":
print(stringify(42))
print(stringify([1, 2, 3]))
#: 42
#: ['1', '2', '3']Each @overload line is a promise to the type
checker, not a function that runs. Only the last, unmarked
definition executes. stringify(42) checks as
returning str, and
stringify([1, 2, 3]) checks as returning
list[str], even though both calls run the same
branching body.
singledispatch
is ad-hoc polymorphism’s other Python form. It uses genuinely
separate functions per type, instead of one function branching
internally.
Polymorphism removes the most common conditional, the
None check. A parameter typed T | None
reintroduces that check everywhere the parameter is used. Here a
logger is optional, so the function guards each logging
call:
# optional_logger.py
class ListLogger:
def __init__(self) -> None:
self.lines: list[str] = []
def log(self, message: str) -> None:
self.lines.append(message)
def total(prices: list[float],
logger: ListLogger | None = None) -> float:
result = 0.0
for price in prices:
result += price
if logger is not None:
logger.log(f"added {price}, total {result}")
return result
if __name__ == "__main__":
print(total([1.5, 2.5]))
logger = ListLogger()
print(total([1.5, 2.5], logger), len(logger.lines))
#: 4.0
#: 4.0 2The checker correctly insists on the guard. Without it, a
None eventually meets .log() and the
call fails. But look at what the None branch does:
nothing. Doing nothing is behavior, and behavior belongs in an
object.
The Null Object pattern replaces “absent” with an object whose behavior is neutral. Give the do-nothing case a class, and the optional parameter becomes required, with a default:
# null_logger.py
from typing import Final, Protocol
from optional_logger import ListLogger
class Logs(Protocol):
def log(self, message: str) -> None: ...
class NullLogger:
def log(self, message: str) -> None:
pass
SILENT: Final[NullLogger] = NullLogger()
def total(prices: list[float],
logger: Logs = SILENT) -> float:
result = 0.0
for price in prices:
result += price
logger.log(f"added {price}, total {result}")
return result
if __name__ == "__main__":
print(total([1.5, 2.5]))
logger = ListLogger()
print(total([1.5, 2.5], logger), len(logger.lines))
#: 4.0
#: 4.0 2total() decides nothing about logging.
NullLogger defines silence once, instead of every
call site defining it. The parameter’s type also improved.
Logs is a protocol, so any logger fits, and no
caller ever sees a | None. Because
NullLogger is stateless, one shared
SILENT instance serves the whole program, and it is
safe as a default argument value. The standard library ships
this idea as logging.NullHandler, and the maze in
Simulation points every
doorless direction at one shared EDGE room, so
movement code never checks for None.
Null objects stand in for “nothing to do,” not
None’s “nothing there.” Sometimes a caller must
notice absence, like a lookup that can fail or a required value
that may be missing. In these cases, use T | None,
or return Result,
so the type forces callers to face the missing case.
If every decision handles absence with the same neutral behavior, centralize that behavior in a null object. If any caller branches differently, then absence is information, and it belongs in the type.
None of this means objects are a mistake. A class is a clean namespace with dot-completion. A class guarantees initialization and, as a data class, generates equality, representation, and, when frozen, hashing.
OOP also normalized the crucial idea of types, as seen in Data Classes as Types. If you simply avoid implementation inheritance, the payoff for using types is tremendous.
Start with functions and data. When a program truly needs an object, it tells you: passing the same data into every function, or bundling behavior with state. OOP is useful, sometimes. But not everywhere, all the time.
Many of the design patterns in this part of the book arose to work around limitations of older object-oriented languages. Read them through the lens of this chapter. For each pattern, ask whether you need the objects and the inheritance, or whether immutable data, a function, and a protocol already solve the problem.
leaky.py, add a tags: list[str]
field to Leaky, exposed through a
@property the same way numbers is, and
demonstrate the same leak by mutating the list you get back.
Then plug the leak the way plugged.py plugs
numbers and bob.immutable.py, change
numbers: tuple[int, ...] to list[int]
and rerun ty check. Notice that it does not object:
the checker sees nothing wrong with a mutable field in a frozen
class. Demonstrate the leak with an append(), then
restore the tuple. Who, then, is responsible for
making immutability go all the way down?point_distance.py, add a third point
p3 = Point(6, 8) and confirm
distance(p1, p3) and
p1.distance_to(p3) still agree.distance_protocol.py, add a third class,
Triple, with fields a, b,
c (no x or y), and an
adapter TripleCoord that exposes x as
a and y as b, ignoring
c. Confirm distance() works on a
TripleCoord with no change to
distance() itself.shapes_match.py, add a new shape,
Square(side: float), to the Shape
union, add its case to area(), and
confirm ty check still passes. Then temporarily
comment out the new case and observe what
assert_never() causes the checker to report.null_logger.py, write a second null-object
style class, NullCache, whose get(key)
always returns None and whose
set(key, value) does nothing, following the same
shape as NullLogger.