Python Part I · Module 16 · Object-Oriented Programming
Your First Class: Grouping Data and Behaviour
Chapter 16.1 established that every value you have used belongs to a class that decides what it can do. This chapter writes one. The syntax is small — a header line, a method with a peculiar name, and an equally peculiar first parameter — and the first useful thing it buys you is not the ability to architect anything. It is that the source of pathlib, subprocess and pytest stops looking like a foreign language, because all three are built from exactly the shape on this page.
Intermediate15 min readPythonPart IFundamentalsOOPClasses
Part I · Module 16 · Chapter 16.2 · Your First Class: Grouping Data and Behaviour
1. The Problem It Solves
A regression run produces four facts about every test: its name, its seed, whether it passed, and how long it took. Without a class you keep them in parallel lists:
names = ["smoke", "cdc", "burst"]
seeds = [1234, 5678, 9012]
passed = [True, False, True]
runtimes = [182.4, 204.1, 199.8]
for i in range(len(names)):
print(f"{names[i]:<7} seed={seeds[i]:<5} passed={passed[i]:<5} {runtimes[i]}s")smoke seed=1234 passed=1 182.4s
cdc seed=5678 passed=0 204.1s
burst seed=9012 passed=1 199.8sThat works, and it keeps working for exactly as long as nobody touches the
lists. The structure is held together by one unwritten rule — index i means
the same test in all four lists — which nothing in the program states, checks
or enforces. Section 6 shows what a single sort does to it.
There is also a smaller tell in that output: passed printed as 1 and 0
rather than True and False, because the width specifier formats a bool as
the integer it is — True is 1. Four loose lists make that easy to miss; a
named field makes it obvious.
2. The Shape
class RunResult:
def __init__(self, test, seed):
self.test = test
self.seed = seed
r = RunResult("smoke", 1234)
print(r.test, r.seed)smoke 1234Four lines of definition and one of use. Each piece:
class RunResult:— introduces a new class and names it. Class names areCapWordsby convention (Chapter 2.4), which is how you tellRunResultfrom a function at a glance.def __init__(self, test, seed):— a method Python calls automatically when you create an instance.self.test = test— stores a value on this object, under the nametest.RunResult("smoke", 1234)— creates one instance. Note you do not passself; Python supplies it.
__init__ and self are introduced here as the shape you type. Chapter 16.3
is about why they are shaped that way, including the executed proof that
self is nothing more than the instance arriving as the first argument.
3. Reading It Back
The attributes are ordinary names on an ordinary object, reachable with the dot from Chapter 16.1:
class RunResult:
def __init__(self, test, seed):
self.test = test
self.seed = seed
r = RunResult("smoke", 1234)
print("test :", r.test)
print("seed :", r.seed)
print("type :", type(r).__name__)
print("callable :", callable(r.test))
print("__dict__ :", r.__dict__)test : smoke
seed : 1234
type : RunResult
callable : False
__dict__ : {'test': 'smoke', 'seed': 1234}r.test is an attribute by Chapter 16.1's test — data, not callable.
__dict__ is the object's own storage, and seeing it demystifies a great deal:
an instance is, at bottom, a dictionary with a class attached.
4. A Class Statement Creates Nothing
The most useful early correction. The class block is a definition, and it
runs once, when Python reads the file:
print("before the class statement")
class RunResult:
print(" the class body runs once, now")
def __init__(self, test):
print(" __init__ runs per instance:", test)
self.test = test
print("after the class statement -- no instances exist yet")
a = RunResult("smoke")
b = RunResult("cdc")
print("two instances:", a.test, b.test)before the class statement
the class body runs once, now
after the class statement -- no instances exist yet
__init__ runs per instance: smoke
__init__ runs per instance: cdc
two instances: smoke cdcThe class body ran once, at definition time, before any instance existed.
__init__ ran twice, once per creation.
A class is a factory: a description of how to build objects of a kind, not an object of that kind itself. That distinction is what Chapter 16.4 is built on.
5. Instances Are Separate
Each instance gets its own storage, which is the whole point:
class RunResult:
def __init__(self, test, seed):
self.test = test
self.seed = seed
a = RunResult("smoke", 1234)
b = RunResult("cdc", 5678)
a.seed = 9999
print("a:", a.test, a.seed)
print("b:", b.test, b.seed)
print("same object:", a is b)
print("a.__dict__:", a.__dict__)
print("b.__dict__:", b.__dict__)a: smoke 9999
b: cdc 5678
same object: False
a.__dict__: {'test': 'smoke', 'seed': 9999}
b.__dict__: {'test': 'cdc', 'seed': 5678}Changing a.seed left b untouched. Two calls to RunResult(...) produce two
independent objects — unlike Chapter 5.8's list aliasing, where two names could
refer to one value.
6. Why Bother: the Records Stay Together
Back to Section 1. Parallel lists are fine until something reorders one of them:
names = ["smoke", "cdc", "burst"]
runtimes = [182.4, 204.1, 199.8]
runtimes.sort()
for i in range(len(names)):
print(f"{names[i]:<7} {runtimes[i]}s")smoke 182.4s
cdc 199.8s
burst 204.1scdc now claims burst's runtime. One sort on one list, and every record
is wrong. No exception, no warning — the lists are still the same length, which
is the only invariant anything was checking.
With a class the fields travel together and cannot be separated:
class RunResult:
def __init__(self, test, runtime_s):
self.test = test
self.runtime_s = runtime_s
results = [
RunResult("smoke", 182.4),
RunResult("cdc", 204.1),
RunResult("burst", 199.8),
]
results.sort(key=lambda r: r.runtime_s)
for r in results:
print(f"{r.test:<7} {r.runtime_s}s")smoke 182.4s
burst 199.8s
cdc 204.1sSorted by runtime, and every name still has its own number. The grouping is
the feature. (key=lambda r: r.runtime_s is Chapter 11.14's lambda, now
reaching into an attribute.)
7. Reading a Real Class
The canonical purpose of this chapter is that library source stops being
opaque. pathlib.Path is a class you have used since Chapter 15.8:
from pathlib import Path
p = Path("runs/seed_41/sim.log")
print("class :", type(p).__name__)
print("is a class:", isinstance(Path, type))
print("attribute :", p.name)
print("method :", p.is_absolute())
print("storage :", "name" in dir(p), "exists" in dir(p))class : PosixPath
is a class: True
attribute : sim.log
method : False
storage : True TruePath is a class; p is an instance of PosixPath, a class built on Path
for this operating system — which is Chapter 16.9's inheritance, visible in the
wild before it has been taught.
Everything you have been doing with p is what Section 3 did with RunResult:
look up a name on an instance, get either data or something callable.
8. Predict, Run, Observe
class Job:
def __init__(self, name):
self.name = name
a = Job("smoke")
b = Job("smoke")
print("A:", a.name == b.name)
print("B:", a is b)
print("C:", a == b)
print("D:", type(a) is type(b))
a.extra = "added later"
print("E:", a.__dict__)
print("F:", b.__dict__)
print("G:", hasattr(b, "extra"))A: True
B: False
C: False
D: True
E: {'name': 'smoke', 'extra': 'added later'}
F: {'name': 'smoke'}
G: FalseA and B — equal contents, different objects.
C is False, and it surprises people: two objects with identical attributes
are not equal by default. Python compares identity unless the class says
otherwise, which is Chapter 16.8's __eq__ and, more practically, Chapter
16.15's dataclass — where equality by field value comes for free.
D — both are the same class, even though they are not the same object.
E, F and G show that an attribute can be attached to one instance after
creation, and that doing so affects no other instance. a now has a field b
does not. It is legal, it is occasionally useful, and it is almost always a sign
that the class should have declared the field in __init__ from the start —
because a reader of the class body now has no way to know extra exists.
9. Exercises
Exercise 1 — the shape. Write a class with __init__ taking three values and
storing them. Create two instances and print all six attributes.
Exercise 2 — the factory. Put a print in the class body and one in
__init__. Create three instances and count how many times each fires.
Exercise 3 — separate storage. Create two instances, mutate one, and show
with __dict__ that the other is untouched.
Exercise 4 — parallel lists. Reproduce Section 6's sort bug with four parallel lists. Then fix it with a class and confirm the records hold together.
Exercise 5 — read pathlib. For a Path object, find three attributes and
three methods using dir() and callable(). Say which are data and which are
behaviour.
Exercise 6 — equality. Create two instances with identical attributes and
compare them with == and is. Explain both results.
Exercise 7 — late attributes. Add an attribute to one instance after creation. Show it is absent from another, and say why you would avoid this.
Exercise 8 — read a real one. Open the source of a standard-library class
(from pathlib import Path; Path.__module__ will tell you where). Find its
__init__ or equivalent and identify what it stores.
10. Summary
- A class groups data that belongs together so it cannot drift apart.
class Name:defines a factory, not an object; the class body runs once.__init__runs per instance, and stores values withself.x = ....- You do not pass
self— Python supplies it. - Class names use
CapWords, which distinguishes them from functions. - An instance's storage is a dictionary, visible as
__dict__. - Instances are independent: mutating one does not affect another.
- Parallel lists come apart silently the moment one is reordered; grouped fields cannot.
- Two instances with equal attributes are not
==by default — Python compares identity unless told otherwise. - The first payoff is reading library source, where
pathlib,subprocessandpytestare built from exactly this shape.
The next chapter explains the two pieces of syntax this one asked you to accept.
Where this fits
Part of the Python curriculum.
