Lecture 11 — Classes and object-oriented design¶
A class is a type defined in Python code. Each object of a class holds its own data. The class holds the functions that act on that data.
Notes: Lecture 11 ·
code: attractor at lecture-11
The notebook runs at the root of the attractor clone, at the tag lecture-11. The kernel is
Python (attractor 3.13). The script setup_env.sh installs the kernel:
cd ~/GitHub/attractor
git fetch --tags --force
git switch --detach lecture-11
source setup_env.sh
import copy, math, os, subprocess, sys, tempfile
from pathlib import Path
import numpy as np
TAG = "lecture-11"
if not Path("trajectory.py").exists(): # outside a clone (Colab, a download): fetch the project
root = Path(tempfile.mkdtemp()) / "attractor"
subprocess.run(["git", "-c", "advice.detachedHead=false", "clone", "-q", "--depth", "1", "--branch", TAG,
"https://github.com/borisbolliet/attractor.git", str(root)], check=True)
os.chdir(root)
sys.path.insert(0, os.getcwd())
subprocess.run(["git", "describe", "--tags"], capture_output=True, text=True).stdout.strip()
'lecture-11'
1 Objects, classes and attribute lookup¶
Classes and instances¶
A class is a type created by a class statement. The body of the statement runs once. The names
that the body binds are the class attributes. Class attributes can be functions or other values.
Python stores the class attributes in the dictionary of the class.
An instance is an object of the class. Calling the class creates an instance. Each instance has its
own dictionary, vars(obj). The dictionary of an instance holds the instance attributes. Right
after Python creates an instance, Python calls the method __init__ on the new instance. __init__
binds the instance attributes.
A method is a function defined in the class body. The first parameter of a method is named self
by convention. The parameter self receives the instance on which the method is called.
class Point:
unit = "cm" # class attribute
def __init__(self, x, y):
self.x, self.y = x, y # instance attributes
def norm(self):
return (self.x**2 + self.y**2) ** 0.5
p = Point(3, 4)
vars(p), p.unit, p.norm()
({'x': 3, 'y': 4}, 'cm', 5.0)
"unit" in vars(p), "unit" in vars(Point), "norm" in vars(Point)
(False, True, True)
The built-in types are classes too. int is a class and 3 an instance of it. A module such as
math has attributes, but it is not a class.
type(3), isinstance(3, int), int("12")
(int, True, 12)
type(math), isinstance(math, type)
(module, False)
Attribute lookup and the method resolution order¶
class Pixel(Point) makes Pixel a subclass of the base class Point. Pixel inherits
every attribute of Point.
The method resolution order (MRO) of a class is a tuple of classes. The tuple starts with the class
itself. The base classes follow. The tuple ends with object. For Pixel, the MRO is
Pixel.__mro__.
The expression obj.name is evaluated in three steps:
- Python searches the instance dictionary
vars(obj). - Python then searches the dictionary of each class of the MRO, in order.
- Python returns the value from the first dictionary that holds
name.
class Pixel(Point):
unit = "px"
q = Pixel(3, 4)
[c.__name__ for c in Pixel.__mro__]
['Pixel', 'Point', 'object']
q.unit, q.norm(), "norm" in vars(Pixel) # unit found in Pixel, norm one class up, in Point
('px', 5.0, False)
Python returns a function found in a class as a bound method. A bound method is a callable that
holds the instance. A call of the bound method passes the instance as the first argument. So the call
q.norm() is the call Point.norm(q).
type(Point.norm).__name__, type(q.norm).__name__
('function', 'method')
q.norm.__func__ is Point.norm, Point.norm(q)
(True, 5.0)
The built-in types follow the same search. bool is a subclass of int. bool defines no
__add__. So for the sum True + True, Python calls the __add__ of int.
[c.__name__ for c in bool.__mro__], True + True, "__add__" in vars(bool), "__add__" in vars(int)
(['bool', 'int', 'object'], 2, False, True)
An assignment obj.name = value does not search. The assignment writes to the instance dictionary.
The class value is shadowed for the instance q. del q.unit removes the instance entry. The read
q.unit then finds the class value again.
q.unit = "mm"
q.unit, Pixel.unit, "unit" in vars(q)
('mm', 'px', True)
del q.unit
q.unit
'px'
Class and instance attributes¶
The class body runs once. So a value created in the class body exists only once. For a list, a single
shared object is a trap. The call a.add(1) runs in two steps:
self.seenis a read. Python finds noseeninvars(a). So Python finds the list invars(Tally).appendchanges the list of the class in place.
b.seen reads the same list. The list now holds 1.
class Tally:
seen = []
def add(self, x):
self.seen.append(x)
a, b = Tally(), Tally()
a.add(1)
b.seen, "seen" in vars(a), a.seen is Tally.seen
([1], False, True)
Each run of an analysis should hold its own data. Data shared by accident between objects mixes the results of different runs.
Data that belongs to one instance is created in __init__:
class Tally:
def __init__(self):
self.seen = []
def add(self, x):
self.seen.append(x)
a, b = Tally(), Tally()
a.add(1)
a.seen, b.seen
([1], [])
2 Special methods¶
Representation, length and indexing¶
A special method is a method whose name begins and ends with two underscores. The interpreter calls a special method when an operator or a built-in function is applied to an instance. The interpreter looks up a special method on the type of the object.
len(obj)callstype(obj).__len__(obj).obj[i]callstype(obj).__getitem__(obj, i).repr(obj)callstype(obj).__repr__(obj).
class Vector:
def __init__(self, x, y): self.x, self.y = x, y
def __repr__(self): return f"Vector({self.x}, {self.y})"
def __len__(self): return 2
def __getitem__(self, i): return (self.x, self.y)[i]
v = Vector(3, 4)
v, len(v), v[1], list(v) # list(v) calls __getitem__ with 0, 1, 2 until IndexError
(Vector(3, 4), 2, 4, [3, 4])
A result is useful only if the setup that produced it is recorded. Objects that print their parameters turn logs and notebooks into a record of each run.
len("abc"), type("abc").__len__("abc")
(3, 3)
class Bare:
pass
len(Bare())
--------------------------------------------------------------------------- TypeError Traceback (most recent call last) Cell In[17], line 4 1 class Bare: 2 pass 3 ----> 4 len(Bare()) TypeError: object of type 'Bare' has no len()
Objects that can be called¶
Suppose a class defines __call__. The instances of the class can then be called like functions. An
object that can be called is callable. The call double(21) calls
type(double).__call__(double, 21). A callable object stores its values in named attributes, such as
double.k. A closure stores its values in cells.
class Scale:
def __init__(self, k): self.k = k
def __call__(self, x): return self.k * x
double = Scale(2)
double(21), callable(double), list(map(double, [1, 2])), double.k
(42, True, [2, 4], 2)
Equality with a tolerance¶
Without __eq__, a == b tests a is b. The __eq__ of Approx returns NotImplemented when the
other operand has another type. The value NotImplemented tells Python to call the __eq__ of the
other operand. If the other __eq__ also returns NotImplemented, Python compares identities.
class Approx:
def __init__(self, v): self.v = v
def __eq__(self, other):
if not isinstance(other, Approx):
return NotImplemented
return abs(self.v - other.v) <= 1e-9
Approx(1.0) == Approx(1.0 + 1e-12), Approx(1.0) == 1.0
(True, False)
A dictionary or a set stores an object by its hash, hash(obj), an integer. Two objects that compare
equal must have the same hash. The hash inherited from object is based on identity. With the
inherited hash, two equal Approx objects would have different hashes. To prevent different hashes
for equal objects, Python sets __hash__ to None in a class that defines __eq__ and no __hash__.
The instances of such a class are not hashable. Unhashable objects cannot be dictionary keys.
Approx.__hash__ is None
True
{Approx(1.0): "one"}
--------------------------------------------------------------------------- TypeError Traceback (most recent call last) Cell In[21], line 1 ----> 1 {Approx(1.0): "one"} TypeError: unhashable type: 'Approx'
For two arrays, a == b compares element by element. The comparison returns an array of booleans.
So if a == a: raises ValueError:
a = np.array([1.0, 2.0])
a == a, np.array_equal(a, a), np.allclose(a, a + 1e-12)
(array([ True, True]), True, True)
if a == a:
pass
--------------------------------------------------------------------------- ValueError Traceback (most recent call last) Cell In[23], line 1 ----> 1 if a == a: 2 pass ValueError: The truth value of an array with more than one element is ambiguous. Use a.any() or a.all()
Special methods in the course project¶
The models of models.py are classes. The base class of the model classes is
DynamicalSystem. DynamicalSystem defines __call__ to call the method rhs. The method rhs
returns the time derivative of a state. An integration loop written for a function rhs(t, y) only
calls rhs. So the loop accepts a model unchanged.
RK4(h).run returns a Trajectory of trajectory.py. A Trajectory holds the times t and
the states y of one simulation run. Trajectory defines __repr__, __len__ and __getitem__.
The __eq__ of Trajectory compares the times and the states with np.allclose.
from integrators import RK4, integrate, rk4_step
from models import LotkaVolterra
from trajectory import Trajectory
lv = LotkaVolterra()
tr = RK4(0.05).run(lv, [1.5, 8.0], 0.0, 40.0)
lv, tr, len(tr), tr[1]
(LotkaVolterra(a=0.8, b=0.08, c=0.77, d=0.77), Trajectory(801 points, t in [0, 40]), 801, (0.05, array([1.51157577, 8.1573318 ])))
lv(0.0, np.array([1.5, 8.0])) # __call__ runs lv.rhs
array([0.24, 3.08])
t, y = integrate(rk4_step, lv, np.array([1.5, 8.0]), 0.0, 40.0, 0.05)
np.array_equal(y, tr.y) # the function-based loop of lecture 9 accepts the model
True
other = Trajectory(t, y + 1e-9, lv.names)
tr == other, tr == other.every(2), Trajectory.__hash__ is None
(True, False, True)
3 Copies of objects holding arrays¶
Shallow and deep copies¶
copy.copy(x) makes a shallow copy. A shallow copy is a new object of the same type as x. The
instance dictionary of the shallow copy is a copy of vars(x). copy.copy copies the keys, but not
the values. So each attribute of the copy is bound to the same object as in x.
copy.deepcopy(x) makes a deep copy. A deep copy copies x. copy.deepcopy also copies,
recursively, every object reachable from x.
grid = [[0, 0], [0, 0]]
sh = copy.copy(grid)
sh[0][0] = 1
grid
[[1, 0], [0, 0]]
copy.copy(grid)[0] is grid[0], copy.deepcopy(grid)[0] is grid[0]
(True, False)
moved works in two steps.
movedmakes the shallow copynew. At this point,new.xandnew.yare the arrays ofp.movedbindsself.x + dx, a new array, tonew.x.
new.y is still the array of p. So a write through q.y changes p.y.
class Points:
def __init__(self, x, y): self.x, self.y = x, y
def moved(self, dx):
new = copy.copy(self)
new.x = self.x + dx # a new array; y is shared
return new
p = Points(np.zeros(3), np.zeros(3))
q = p.moved(1.0)
q.x is p.x, q.y is p.y, copy.deepcopy(p).y is p.y
(False, True, False)
q.y[0] = 5.0
p.y
array([5., 0., 0.])
A comparison of simulation runs is valid only if the runs are independent. Copies that share arrays can tie the runs together without raising an error.
Views inside copies¶
A basic slice of an array is written start:stop:step. A basic slice returns a view. A view is a
new array object that uses the memory of the original array. An attribute of a copy that is bound to a
view shares the memory of the original. Element 1 of p.x[::2] is element 2 of p.x. So the write
to s.x[1] changes p.x[2].
p = Points(np.arange(4.0), np.zeros(4))
s = copy.copy(p)
s.x = p.x[::2] # a view
s.x.base is p.x, np.shares_memory(s.x, p.x)
(True, True)
s.x[1] = -1.0
p.x
array([ 0., 1., -1., 3.])
A boolean mask is an array of True and False that selects elements. Indexing with a mask returns
a copy. The copy owns its memory. The .base of the copy is None.
The attribute .base is the array that owns the memory. For a view of a view, .base is the first
array.
m = p.x[p.x > 0]
m.base is None, np.shares_memory(m, p.x)
(True, False)
v = p.x[1:]
v[::2].base is p.x, v[::2].base is v
(True, False)
Some methods of Trajectory return a changed run. For each of these methods,
experiments/copies.py prints which attributes the result shares with the original. The script
then writes through each copy:
%run -m experiments.copies
Trajectory(6 points, t in [0, 5]) 6 (1.0, array([2., 3.])) method attribute is shares base is shifted t False False False shifted y True True False shifted names True - - shifted labels True - - every t False True True every y False True True every names True - - every labels True - - time_reversed t False False False time_reversed y False True True time_reversed names True - - time_reversed labels True - - copy t False False False copy y False False False copy names True - - copy labels False - - vars(sh) is vars(tr): False after sh.y[0, 0] = -1.0: tr.y[0] = [-1. 1.] sh.y is tr.y: True after ev.y[1, 0] = 0.0: tr.y[2] = [0. 5.] after rv.y[0, 0] = 99.0: tr.y[-1] = [99. 11.] after sh.labels.append: tr.labels = ['shifted'] dp.labels = [] after dp.y[0, 0] = 7.0: tr.y[0] = [-1. 1.] shares: False
4 Interfaces, dataclasses and composition¶
Abstract base classes and Protocols¶
The interface of a class is the set of methods that other code relies on when using its
instances. An abstract base class defines required methods using @abstractmethod. A subclass that
does not implement all these methods cannot be instantiated. Python raises an error identifying the
missing methods.
from abc import ABC, abstractmethod
from typing import Protocol, runtime_checkable
class Shape(ABC):
@abstractmethod
def area(self): ...
class Circle(Shape): # area not defined
def __init__(self, r): self.r = r
Circle(1.0)
--------------------------------------------------------------------------- TypeError Traceback (most recent call last) Cell In[37], line 11 7 8 class Circle(Shape): # area not defined 9 def __init__(self, r): self.r = r 10 ---> 11 Circle(1.0) TypeError: Can't instantiate abstract class Circle without an implementation for abstract method 'area'
Long computations are expensive to run again. A check of the interface when an object is created finds a missing method before the computation starts.
A Protocol is a class that lists the methods of an interface. A class satisfies a Protocol by
defining all these methods. The class does not need to inherit from the Protocol. The function
isinstance can test a Protocol decorated with @runtime_checkable. A runtime-checkable Protocol
checks names only.
@runtime_checkable
class HasArea(Protocol):
def area(self): ...
class Square: # no base class
def __init__(self, s): self.s = s
def area(self): return self.s ** 2
sq = Square(3)
isinstance(sq, HasArea), isinstance(sq, Shape), isinstance(3, HasArea)
(True, False, False)
class Fake:
area = 3 # a number, not a method
isinstance(Fake(), HasArea)
True
Fake().area()
--------------------------------------------------------------------------- TypeError Traceback (most recent call last) Cell In[40], line 1 ----> 1 Fake().area() TypeError: 'int' object is not callable
from collections.abc import Sized
isinstance("abc", Sized), isinstance(3, Sized)
(True, False)
Dataclasses¶
A dataclass is a class for which the decorator @dataclass generates the methods __init__,
__repr__ and __eq__. The decorator generates these methods from the fields. The fields are the
annotated names of the class body, in the order they are written. With frozen=True, an assignment to
a field after __init__ raises an error. With frozen=True, the decorator also generates __hash__
from the fields. The function dataclasses.replace returns a changed copy.
from dataclasses import dataclass, field, replace
@dataclass(frozen=True)
class Point:
x: float
y: float = 0.0
p = Point(1.0)
p, p == Point(1.0, 0.0), hash(p) == hash(Point(1.0))
(Point(x=1.0, y=0.0), True, True)
p.x = 2.0
--------------------------------------------------------------------------- FrozenInstanceError Traceback (most recent call last) Cell In[43], line 1 ----> 1 p.x = 2.0 File <string>:16, in __create_fn__.<locals>.__setattr__(self, name, value) 14 'Could not get source, probably due dynamically evaluated source code.' FrozenInstanceError: cannot assign to field 'x'
replace(p, x=2.0)
Point(x=2.0, y=0.0)
Without the decorator, a class with the same __init__ compares by identity:
class PlainPoint:
def __init__(self, x): self.x = x
PlainPoint(1.0) == PlainPoint(1.0)
False
The option default_factory=list of field defines a field whose default is a new list. The
generated __init__ calls list() for each new instance. For a bare list as a default, the decorator
raises ValueError when it builds the class.
@dataclass
class Path:
points: list = field(default_factory=list)
Path().points is Path().points
False
@dataclass
class Bad:
points: list = []
--------------------------------------------------------------------------- ValueError Traceback (most recent call last) Cell In[47], line 1 ----> 1 @dataclass 2 class Bad: 3 points: list = [] File /opt/homebrew/Cellar/python@3.13/3.13.11/Frameworks/Python.framework/Versions/3.13/lib/python3.13/dataclasses.py:1305, in dataclass(cls, init, repr, eq, order, unsafe_hash, frozen, match_args, kw_only, slots, weakref_slot) 1302 return wrap 1304 # We're called as @dataclass without parens. -> 1305 return wrap(cls) File /opt/homebrew/Cellar/python@3.13/3.13.11/Frameworks/Python.framework/Versions/3.13/lib/python3.13/dataclasses.py:1295, in dataclass.<locals>.wrap(cls) 1294 def wrap(cls): -> 1295 return _process_class(cls, init, repr, eq, order, unsafe_hash, 1296 frozen, match_args, kw_only, slots, 1297 weakref_slot) File /opt/homebrew/Cellar/python@3.13/3.13.11/Frameworks/Python.framework/Versions/3.13/lib/python3.13/dataclasses.py:1008, in _process_class(cls, init, repr, eq, order, unsafe_hash, frozen, match_args, kw_only, slots, weakref_slot) 1005 kw_only = True 1006 else: 1007 # Otherwise it's a field of some type. -> 1008 cls_fields.append(_get_field(cls, name, type, kw_only)) 1010 for f in cls_fields: 1011 fields[f.name] = f File /opt/homebrew/Cellar/python@3.13/3.13.11/Frameworks/Python.framework/Versions/3.13/lib/python3.13/dataclasses.py:860, in _get_field(cls, a_name, a_type, default_kw_only) 856 # For real fields, disallow mutable defaults. Use unhashable as a proxy 857 # indicator for mutability. Read the __hash__ attribute from the class, 858 # not the instance. 859 if f._field_type is _FIELD and f.default.__class__.__hash__ is None: --> 860 raise ValueError(f'mutable default {type(f.default)} for field ' 861 f'{f.name} is not allowed: use default_factory') 863 return f ValueError: mutable default <class 'list'> for field points is not allowed: use default_factory
Composition over inheritance¶
Inheritance expresses "is a". A subclass inherits every attribute of its base, wanted or not. Composition expresses "has a". In composition, an object holds another object as an attribute. The outer object forwards calls to the inner object.
A stack returns first the last element it received. The order of a stack is called last in, first out.
Stack holds a list. Stack defines only push and pop. A subclass of list would also inherit
insert and sort. The methods insert and sort break the order of a stack.
class Stack:
def __init__(self): self.items = [] # has a list
def push(self, x): self.items.append(x)
def pop(self): return self.items.pop()
s = Stack(); s.push(1); s.push(2)
s.pop(), hasattr(s, "insert"), hasattr(list, "insert")
(2, False, True)
class ListStack(list): # is a list
push = list.append
ls = ListStack(); ls.push(2); ls.push(1); ls.sort()
ls.pop() # 2, although 1 was pushed last
2
One interface, several classes¶
Code that calls a method by name works with any object whose class defines that method.
Polymorphism is the use of one piece of code with the objects of several classes that define the
same methods. total_area calls area() on each element. total_area returns the sum of the areas of
squares and rectangles. total_area also accepts the instances of any class written later that defines
area. The built-in len is another example of polymorphism.
def total_area(shapes):
return sum(s.area() for s in shapes)
class Rectangle:
def __init__(self, w, h): self.w, self.h = w, h
def area(self): return self.w * self.h
total_area([Square(2), Rectangle(2, 3)])
10
[len(x) for x in ("abc", [1, 2], {"k": 0})]
[3, 2, 1]
In the project, RK4.run calls system(t, y). RK4.run also reads system.names. So one
integrator runs the three model classes of models.py:
from models import Goodwin, RosenzweigMacArthur
runs = [(LotkaVolterra(), (1.5, 8.0)), (RosenzweigMacArthur(), (1.5, 0.5)),
(Goodwin(0.03, 0.05, 0.5, 0.45), (0.95, 0.65))]
for system, y0 in runs:
tr = RK4(0.05).run(system, y0, 0.0, 300.0)
print(f"{type(system).__name__:20} {tr.period():5.1f} {tr.names}")
LotkaVolterra 8.2 ('algae', 'rotifers')
RosenzweigMacArthur 13.9 ('prey', 'predator')
Goodwin 54.2 ('employment rate', 'wage share')
The exercises and their solutions are at the end of the notes. The scripts of the exercises are in
exercises/lecture11/.