The Observer pattern, a kind of callback, decouples the code that changes state from the code that reacts to the change. One object, the observer, registers interest in another, the observable, and receives a notification whenever the observable’s state changes. Of the callback patterns it is the most dynamic:
It underlies event handling, and the model-view split that keeps a display in step with the data behind it.
Use Observer if a group of objects must update themselves when some other object changes state. The classic example is Smalltalk’s MVC (model-view-controller), or the almost-equivalent Document-View architecture. You have some data, the document, and more than one view of it, say a plot and a table. When the data changes, every view must refresh. The Observer pattern arranges that, without the data having to know which views exist.
Python expresses this with far less machinery than the classic design. This chapter shows the Pythonic version first, then extends it to async for I/O-bound observers. It closes with a visual model-view example built on the same callable observers.
In Python an observer need not be an object
implementing an Observer interface. It is simply a
callable. An observable need not be a base class with a
changed flag. It is a list of callables and a way
to notify them. A @property setter is a natural
place to fire the notification when state changes:
# observers.py
from collections.abc import Callable
from typing import Any
type Observer = Callable[[Any], None]
class Observable:
def __init__(self) -> None:
self._observers: list[Observer] = []
def subscribe(self, observer: Observer) -> None:
self._observers.append(observer)
def unsubscribe(self, observer: Observer) -> None:
self._observers.remove(observer)
def notify(self, data: Any) -> None:
# Copy: observers may detach during notification
for observer in list(self._observers):
observer(data)
class Thermometer(Observable):
def __init__(self) -> None:
super().__init__()
self._celsius = 0.0
@property
def celsius(self) -> float:
return self._celsius
@celsius.setter
def celsius(self, value: float) -> None:
self._celsius = value
self.notify(value) # State changed; tell the observersUsing it, subscribed callables react to every temperature change:
# thermometer.py
from observers import Thermometer
t = Thermometer()
t.subscribe(lambda c: print(f"display: {c}C"))
t.subscribe(lambda c: print("alarm!" if c > 100 else "ok"))
t.celsius = 25
#: display: 25C
#: ok
t.celsius = 150
#: display: 150C
#: alarm!The observers here are lambdas, but any function or bound
method works. No Observer base class needs
inheriting, and no notification protocol needs implementing.
Assigning to celsius notifies everyone. For
event-heavy programs there are mature libraries (signal/slot
systems, asyncio events), but for most cases the
Observer pattern amounts to nothing more than a list of
callbacks.
An observer returns None. Notification runs one
way, from observable to observers, and nothing comes back.
Collecting a value from each observer is a different pattern,
such as Chain of
Responsibility for the first handler that answers.
Testing confirms that every subscriber receives the new
value, and a subscriber sees only the changes that happen after
it subscribes. It also verifies that an unsubscribed observer
stops hearing changes. Removal is by identity, so a detachable
observer needs a named reference, not an inline lambda. A list
whose append is the observer records what
arrived:
# test_observers.py
from observers import Observable, Thermometer
def test_notify_calls_every_subscriber() -> None:
received: list[tuple[str, object]] = []
obs = Observable()
obs.subscribe(lambda d: received.append(("a", d)))
obs.subscribe(lambda d: received.append(("b", d)))
obs.notify(42)
assert received == [("a", 42), ("b", 42)]
def test_no_subscribers_is_a_noop() -> None:
Observable().notify("anything") # Must not raise
def test_unsubscribe_stops_delivery() -> None:
received: list[object] = []
obs = Observable()
record = received.append # Named so it can be removed
obs.subscribe(record)
obs.notify(1)
obs.unsubscribe(record)
obs.notify(2)
assert received == [1]
def test_thermometer_pushes_new_value_on_set() -> None:
readings: list[float] = []
t = Thermometer()
t.subscribe(readings.append)
t.celsius = 25.0
t.celsius = 150.0
assert readings == [25.0, 150.0]
assert t.celsius == 150.0
def test_late_subscriber_misses_earlier_changes() -> None:
readings: list[float] = []
t = Thermometer()
t.celsius = 10.0 # No subscriber yet
t.subscribe(readings.append)
t.celsius = 20.0
assert readings == [20.0]The list() copy inside notify() is
a single word doing quiet work. An observer may react to a
notification by unsubscribing, a one-shot listener detaching
itself is the natural example, and that mutates
self._observers in the middle of the loop walking
it. Iterate the list directly and removing the current observer
shifts every later one left, so the next observer is silently
skipped, no error anywhere. The copy makes detaching during
notification safe, and a newcomer subscribing mid-notification
starts hearing from the next change:
# self_removing_observer.py
from observers import Observable
obs = Observable()
seen: list[str] = []
def once(data: object) -> None:
seen.append(f"once: {data}")
obs.unsubscribe(once) # Detaches itself mid-notification
obs.subscribe(once)
obs.subscribe(lambda d: seen.append(f"always: {d}"))
obs.notify(1)
obs.notify(2)
print(seen)
#: ['once: 1', 'always: 1', 'always: 2']once hears the first change and detaches;
always hears both. Under the naive loop,
always: 1 would be missing: once’s
self-removal would have skipped it.
Two more realities of Observer deserve a sentence each. An
observer that raises an exception stops the loop, and every
observer after it never hears the change; decide whether
notify() should catch, collect, and continue
(exercise 3 makes this concrete). And subscriptions are strong
references: an observable that outlives its observers keeps each
subscribed bound method’s instance alive, the classic lapsed
listener leak. Long-lived observables need disciplined
unsubscribe() calls, or weak references
(weakref.WeakMethod) doing the forgetting
automatically.
Until now, an observer only prints or appends to a list, then returns. If an observer calls a network service or writes to a database, notifying observers one at a time blocks on each. The list of callbacks becomes a line of waits.
If observers are coroutines, notify awaits them
together with asyncio.gather, so one state change
reaches every observer at once. A slow observer no longer holds
up the others. gather still waits for all of them,
so the change finishes only after every notification succeeds.
One limitation: a @property setter cannot be a
coroutine, so an assignment cannot be awaited. The state change
moves from t.celsius = value to an awaitable
method. Concurrency
covers the asyncio mechanics here
(async def, await,
gather, run). For this example, we
only need a coroutine to pause at await while
others run:
# async_observers.py
import asyncio
from collections.abc import Awaitable, Callable
type AsyncObserver = Callable[[float], Awaitable[None]]
class Observable:
def __init__(self) -> None:
self._observers: list[AsyncObserver] = []
def subscribe(self, observer: AsyncObserver) -> None:
self._observers.append(observer)
async def notify(self, data: float) -> None:
# Fan out to every observer at once, then wait for all
await asyncio.gather(*(obs(data) for obs in self._observers))
class Thermometer(Observable):
def __init__(self) -> None:
super().__init__()
self._celsius = 0.0
@property
def celsius(self) -> float:
return self._celsius
async def set_celsius(self, value: float) -> None:
# A property setter cannot be awaited
self._celsius = value
await self.notify(value)
async def alarm(celsius: float) -> None:
if celsius > 100:
await asyncio.sleep(0.05) # Slow network alert
print(f"alarm sent: {celsius}C")
async def log_reading(celsius: float) -> None:
await asyncio.sleep(0.01) # Faster local write
print(f"logged: {celsius}C")
async def main() -> None:
t = Thermometer()
t.subscribe(alarm)
t.subscribe(log_reading)
await t.set_celsius(20) # Below the alarm threshold
await t.set_celsius(150) # Triggers the alarm too
asyncio.run(main())
#: logged: 20C
#: logged: 150C
#: alarm sent: 150CThe AsyncObserver alias makes the checker reject
a plain function as an observer: an observer must return an
awaitable, which is exactly what calling an async
function produces.
The alarm is slower than the log, yet the log
prints first. Awaiting the observers in sequence would print in
subscribe order, alarm first. Concurrent fan-out lets each
finish on its own schedule, so the faster observer reports
first. The alarm also shows an observer that can decline to act.
Below its threshold it returns without sending anything.
Use this only when the observers are I/O-bound. For in-memory observers the synchronous list from earlier is simpler and needs no event loop. The type-keyed event bus is the same fan-out, routed by event type.
This is the model-view split from the chapter’s opening, made
visible with tkinter (in the standard library, so
there is nothing to install), and split across two files to make
the point. The model, box_observer.py, is
a grid of colored boxes and the rule for a click. It holds no
display code. The view, box_view.py, is
the only file that draws. Click a box and every box touching it,
diagonals included, repaints to the clicked box’s color.
The model is an Observable.
new_grid() builds a size x size grid banded into
three colors, adjacent() tests whether two distinct
cells touch, including diagonally, and recolored()
computes the grid that results from a click: values in, values
out. BoxModel.click() makes the next grid with
recolored() and announces it with
notify(). tkinter plays no part here,
so we can test the model without a GUI. It reuses the same
Observable as the thermometer, from
observers.py:
# box_observer.py
from typing import Final
from observers import Observable
COLORS: Final[tuple[str, str, str]] = (
"skyblue", "palegreen", "khaki")
type Coord = tuple[int, int] # (column, row)
type Grid = dict[Coord, str] # Cell -> color
def new_grid(size: int) -> Grid:
return {(x, y): COLORS[(x + y) % len(COLORS)]
for x in range(size) for y in range(size)}
def adjacent(a: Coord, b: Coord) -> bool:
return a != b and abs(a[0] - b[0]) <= 1 and abs(a[1] - b[1]) <= 1
def recolored(grid: Grid, clicked: Coord) -> Grid:
color = grid[clicked]
return {cell: color if adjacent(cell, clicked) else current
for cell, current in grid.items()}
class BoxModel(Observable):
def __init__(self, size: int) -> None:
super().__init__()
self.size = size
self.grid = new_grid(size)
def click(self, cell: Coord) -> None:
self.grid = recolored(self.grid, cell)
self.notify(self.grid)Because the model carries no display code, a test drives it without a GUI. Build a model, click a cell, and check that the neighbors took its color and that observers received the new grid:
# test_box_observer.py
from box_observer import BoxModel, Grid, adjacent, new_grid, recolored
def test_new_grid_size_and_banding() -> None:
grid = new_grid(3)
assert len(grid) == 9
assert grid[(0, 0)] == "skyblue" # COLORS[0]
assert grid[(0, 1)] == grid[(1, 0)] # Same (x + y) color band
def test_adjacent() -> None:
assert adjacent((1, 1), (2, 2)) # Diagonal
assert adjacent((1, 1), (1, 2)) # Edge
assert not adjacent((1, 1), (1, 1)) # Not its own neighbor
assert not adjacent((0, 0), (2, 0)) # Two away
def test_recolored_touches_only_neighbors() -> None:
grid = new_grid(5)
out = recolored(grid, (2, 2))
assert out[(1, 1)] == grid[(2, 2)] # Diagonal neighbor: changed
assert out[(2, 3)] == grid[(2, 2)] # Edge neighbor: changed
assert out[(0, 0)] == grid[(0, 0)] # Two away: unchanged
assert out is not grid # Pure: a new grid
def test_model_notifies_with_the_new_grid() -> None:
model = BoxModel(5)
seen: list[Grid] = []
model.subscribe(seen.append) # The observer is a callable
model.click((2, 2))
assert seen[-1] is model.grid # Observer got the new grid
assert model.grid[(1, 1)] == model.grid[(2, 2)]The view lives in its own file. It is the only code that
touches the screen. draw() paints the grid, and the
view subscribes it, so every change repaints. A click on the
canvas becomes a model click(), and the resulting
notification repaints the view. Run box_view.py to
play. It opens a window, so the example harness does not run it
(tools/data/norun.txt lists it).
# box_view.py
import tkinter as tk
from box_observer import BoxModel, Grid
def show(model: BoxModel, cell: int = 60) -> None:
root = tk.Tk()
root.title("ColorBoxes")
canvas = tk.Canvas(root, highlightthickness=0,
width=model.size * cell,
height=model.size * cell)
canvas.pack()
def draw(grid: Grid) -> None:
for (x, y), color in grid.items():
canvas.create_rectangle(
x * cell, y * cell, (x + 1) * cell, (y + 1) * cell,
fill=color, outline="white")
model.subscribe(draw) # Repaint on every model change
canvas.bind("<Button-1>",
lambda e: model.click((e.x // cell, e.y // cell)))
draw(model.grid)
root.mainloop()
if __name__ == "__main__":
show(BoxModel(8))The model and the view share only the subscribe-and-notify contract, so the test can exercise the model without a display. You can also attach a second view to the same model and keep both in step. Showing that the model is correct, separately from how it is drawn, is the model-view split made concrete.
Observable
and many Observers, and cause the
Observable to update the
Observers.box_observer.py to turn it into a simple
game. If any of the squares surrounding the one you clicked is
part of a contiguous patch of the same color, then all the
squares in that patch take on the color you clicked. You can
configure the game for competition between players or to keep
track of the number of clicks that a single player uses to turn
the field into a single color. You may also restrict a player’s
color to the first one they chose.Observable.notify() survive an observer
that raises an exception: every other observer still hears the
change, and the failures are re-raised afterward, together, as
an ExceptionGroup (the container Concurrency
introduced). Write a test in which the first observer raises an
exception and the second still records its notification.