Objects & classes: state and behaviour move in together
420-302-VA · WEEK 6 · FALL 2026

Stage 3 of 6 · Theory, REPL open · about 30 minutes

First class

Time to stand on the other side of the counter. You will build ReactionLog, a scorekeeper for the Week 5 reaction timer: pure Python, no hardware, runnable anywhere, and small enough to understand to the last character. It becomes part of this afternoon's build.

The goal: a scorekeeper

Week 5's stretch rung wanted best-of-five with a best and average report, and the loose-variable version was already awkward: a list here, a counter there, functions taking them all as parameters. Specify the capsule instead, Week 5 style, before writing it:

IDRequirement for ReactionLog
L1A new log shall start empty.
L2add(ms) shall record one reaction time.
L3count(), best() and average() shall report the number of times, the fastest, and the mean.
L4On an empty log, best() and average() shall return None rather than crash.

The class statement, annotated

# the keyword, then the class name in CapWords (the PEP 8 convention)
class ReactionLog:

    # the constructor: runs once, automatically, for each new instance.
    # Its job: give the instance its starting state. L1 lives here.
    def __init__(self):
        self.times = []

    # a method: an ordinary def, indented into the class,
    # whose first parameter is always the instance it acts on
    def add(self, ms):
        self.times.append(ms)
  • class ReactionLog: opens a block, exactly as def and if do; everything indented under it belongs to the class. Names in CapWords (ReactionLog, NightLight) mark classes; lowercase marks instances and variables, per PEP 8, the style guide the whole Python world shares.
  • __init__ (two underscores each side, "dunder init") is the constructor: Python calls it for you whenever the class is called. log = ReactionLog() creates a blank instance, runs __init__ on it, and hands it back. Attributes are born here: self.times = [] gives this instance its own empty list.
  • Methods are defs inside the class, with the instance as first parameter. Nothing else about them is new: parameters, returns, bodies, all Week 4 material.

Use it exactly like a gpiozero class:

>>> log = ReactionLog()
>>> log.add(431)
>>> log.add(388)
>>> log.times
[431, 388]

The truth about self

self is the single biggest beginner hurdle in Python OOP, and one rewrite dissolves it. These two lines do the same thing:

log.add(431)                    # what you write
ReactionLog.add(log, 431)       # what Python actually does

A method is a function that lives on the class; calling it through an instance makes Python pass that instance as the first argument. So inside add, self is log, the specific object being operated on, and self.times is that object's own list. Three consequences, each a classic error when forgotten (Troubleshoot has the exact messages):

  • Every method's def lists self first, but no call ever passes it; Python supplies it.
  • Attributes are always reached through it: self.times, never bare times. Without the prefix, Python sees an ordinary (and undefined) local variable.
  • self is a convention, not a keyword; the first parameter could be named anything, and never is. Every Python programmer on Earth writes self, so you do too.

Aside: just enough lists

The five list facts this week needs

A list is an ordered collection, itself an object, from the tutorial's Data Structures chapter (read section 5.1 this week; the rest can wait). Today's working set: [] makes an empty one; times.append(x) adds to the end; len(times) counts; min(times) and sum(times) do what they say; for t in times: visits each item, the Week 4 for-loop pointed at your own data. That is everything ReactionLog requires.

Growing the class

L3, straight from the facts above, plus a constructor argument to name the player, showing how construction facts become attributes:

class ReactionLog:
    def __init__(self, player):
        self.player = player
        self.times = []

    def add(self, ms):
        self.times.append(ms)

    def count(self):
        return len(self.times)

    def best(self):
        return min(self.times)

    def average(self):
        return sum(self.times) / len(self.times)
>>> log = ReactionLog("Sam")
>>> log.add(431); log.add(388); log.add(512)
>>> log.player, log.count(), log.best(), round(log.average())
('Sam', 3, 388, 444)

Notice what __init__(self, player) did to construction: ReactionLog() now fails with a missing-argument error, because the blueprint declares that a log cannot exist without a player name. Constructors are where a class states its non-negotiables, exactly as LED(17) refuses to exist without a pin.

The empty log: a spec question in code

Run ReactionLog("Sam").best() and Python answers with a crash: ValueError: min() iterable argument is empty. Week 5 trained you to hear that as a specification question, and L4 already answered it: return None. The guard is two lines per method:

    def best(self):
        if not self.times:          # an empty list counts as False
            return None
        return min(self.times)

Same pattern for average(). Two things worth keeping: if not self.times: is the standard Pythonic emptiness test, and the deeper lesson, edge cases belong to the spec, and the class is where the spec's answer gets enforced once, for every future caller. That is encapsulation doing its job.

Many logs, one class

The payoff Week 5's loose variables could never give cheaply:

>>> sam, ana = ReactionLog("Sam"), ReactionLog("Ana")
>>> sam.add(431); ana.add(297)
>>> sam.count(), ana.count()
(1, 1)
>>> ana.best() < sam.best()
True

Each instance got its own times list because __init__ runs per instance and creates it fresh. One caution before the lab: attributes must be created inside __init__, not in the class body. A list written directly under class belongs to the blueprint itself and is shared by every instance, a legendary bug with its own row in Troubleshoot.

Tracing a method call

Week 5's trace tables work unchanged; the only new column is self. Trace ana.add(297) followed by ana.best():

StepLineself isself.timesReturns / output
1ana.add(297) → add(self=ana, ms=297)ana[]–
2self.times.append(ms)ana[297]–
3ana.best() → best(self=ana)ana[297]–
4if not self.times:ana[297]non-empty → skip
5return min(self.times)ana[297]297

Note that sam.times never appears: the whole trace lives on the instance the calls went through. Expect exactly this exercise, on paper, on the midterm.

Checklist for this stage

Check yourself

When does __init__ run, and what is its one job?
Automatically, once, each time the class is called to create an instance. Its job is to give that instance its starting state, creating every attribute the spec says a new object must have.
Inside add, what exactly is self when the call was sam.add(431)?
The instance sam. Python translated the call to ReactionLog.add(sam, 431), so self.times is Sam's list and no one else's.
A teammate writes times.append(ms) inside a method and gets NameError: name 'times' is not defined. Why?
Without the self. prefix, times is an ordinary local variable, and none exists. Attributes live on the instance and are always reached through self.
Why did L4 (return None when empty) belong in the spec rather than being left to the coder?
Because crash, return None, and return 0 are all implementable; which is correct is a behaviour decision, and behaviour decisions are requirements. The class then enforces the answer once for all callers, which is encapsulation's point.
What changes for callers when __init__(self) becomes __init__(self, player)?
Construction now demands the fact: ReactionLog() raises a missing-argument TypeError; ReactionLog("Sam") works and stores the name as an attribute. Constructor parameters are the facts an instance cannot exist without.
Write the class header and constructor for a Door that starts closed.
class Door: then def __init__(self): containing self.is_open = False. Methods like open() and close() would flip that attribute, and nothing outside the class should touch it directly.