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, and 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, with the kernel
Python (attractor 3.13) that setup_env.sh installs:
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¶
class Particle:
"""Yo"""
print("defining particles")
species = "photon"
charge = -1
def describe(self):
return self.species
defining particles
p = Particle()
# p.species = "electron"
print(type(p)) # <class '__main__.Particle'>
print(isinstance(p, Particle)) # True
<class '__main__.Particle'> True
p = int()
p2 = int()
p is p2
True
p = int(-30)
p2 = int(-30)
p is p2
False
p = Particle()
p2 = Particle()
p is p2
False
Classes and instances¶
A class is a type created by a class statement.
The body of the statement runs once: it is executed when the class is created (at the class statement).
In the class body, names are bound to objects that become class attributes.
Class attributes are stored in the class's namespace, accessible through its __dict__ attribute.
p.__dict__ # this is **not** the class namespace! it is the instance namespace
{}
Particle.__dict__
mappingproxy({'__module__': '__main__',
'__firstlineno__': 1,
'__doc__': 'Yo',
'species': 'photon',
'charge': -1,
'describe': <function __main__.Particle.describe(self)>,
'__static_attributes__': (),
'__dict__': <attribute '__dict__' of 'Particle' objects>,
'__weakref__': <attribute '__weakref__' of 'Particle' objects>})
An instance is an object of the class type, created by calling the class.
type(p)
__main__.Particle
type(Particle),type(int)
(type, type)
a = int()
type(a)
int
a
0
dir(int)
['__abs__', '__add__', '__and__', '__bool__', '__ceil__', '__class__', '__delattr__', '__dir__', '__divmod__', '__doc__', '__eq__', '__float__', '__floor__', '__floordiv__', '__format__', '__ge__', '__getattribute__', '__getnewargs__', '__getstate__', '__gt__', '__hash__', '__index__', '__init__', '__init_subclass__', '__int__', '__invert__', '__le__', '__lshift__', '__lt__', '__mod__', '__mul__', '__ne__', '__neg__', '__new__', '__or__', '__pos__', '__pow__', '__radd__', '__rand__', '__rdivmod__', '__reduce__', '__reduce_ex__', '__repr__', '__rfloordiv__', '__rlshift__', '__rmod__', '__rmul__', '__ror__', '__round__', '__rpow__', '__rrshift__', '__rshift__', '__rsub__', '__rtruediv__', '__rxor__', '__setattr__', '__sizeof__', '__str__', '__sub__', '__subclasshook__', '__truediv__', '__trunc__', '__xor__', 'as_integer_ratio', 'bit_count', 'bit_length', 'conjugate', 'denominator', 'from_bytes', 'imag', 'is_integer', 'numerator', 'real', 'to_bytes']
dir(Particle)
['__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__firstlineno__', '__format__', '__ge__', '__getattribute__', '__getstate__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__static_attributes__', '__str__', '__subclasshook__', '__weakref__', 'charge', 'describe', 'species']
p.describe(),Particle.describe(Particle)
('photon', 'photon')
vars(p) # returns dictionary of instance attributes of p, the instance namespace
{}
p.__dict__
{}
p.species = "electron"
p.__dict__
{'species': 'electron'}
Subtelty: There are now two distinct attributes named species, one in the class namespace and one in the instance namespace.
Each instance has a dictionary of its own, vars(obj), which holds its instance attributes, returning the instance attribute namespace.
Right after an instance is created, the method __init__ runs on it and fills that dictionary. This method initialises the instance, by assigning the instance attributes.
A method is a function stored in the class. Its first parameter, named self by convention,
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)
p2 = Point(3, 5)
vars(p), p.unit, p.norm(), p2.norm()
({'x': 3, 'y': 4}, 'cm', 5.0, 5.830951894845301)
p.norm(p) # bound method (bound to instance p)
--------------------------------------------------------------------------- TypeError Traceback (most recent call last) Cell In[144], line 1 ----> 1 p.norm(p) # bound method (bound to instance p) TypeError: Point.norm() takes 1 positional argument but 2 were given
Point.norm(p) # ordinary function
5.0
"unit" in vars(p), "unit" in vars(Point), "norm" in vars(Point)
(False, True, True)
p.unit # this uses attribute lookup, see mro. Not LEGB.
'cm'
Subtelty: class body is not an enclosing lexical scope for methods defined inside it. Consider:
class Point:
unit = "cm"
def __init__(self, x, y):
self.x, self.y = x, y
def norm(self):
return (self.x**2 + self.y**2)**0.5, unit
p = Point(3, 4)
p.norm()
The built-in types are classes too. int is a class and 3 an instance of it.
type(int), type(3), isinstance(3, int)
(type, int, True)
Note that a class is a type, and type is also an object, whose type is type.
print(type.__mro__)
(<class 'type'>, <class 'object'>)
This tells us that:
- type is a class.
- type inherits directly from
object. - Python searches
typefirst, thenobject, when looking up inherited attributes of instances of type.
object is a built-in Python class, just like int, float, or our Particle class.
What makes object special is that it is the root of Python's inheritance hierarchy. It isthe most fundamental base class.
type is the fundamental metaclass, responsible for creating most classes, while object provides the fundamental behaviour shared by Python objects
class Particle:
pass
print(Particle.__bases__)
(<class 'object'>,)
type(object) is type
True
issubclass(type, object)
True
type(object) is object
False
type(type) is type
True
type provides the special methods:
type.__new__Constructs a new class objecttype.__init__Initialises the class objecttype.__call__Coordinates creation and initialisation of instances when a class is calledtype.__getattribute__Handles attribute lookup on class objectstype.__setattr__Handles assignment of class attributestype.__delattr__Handles deletion of class attributes
print(Particle.__mro__)
(<class '__main__.Particle'>, <class 'object'>)
This tells us the order in which Python searches classes when resolving inherited attributes and methods.
Attribute lookup and the method resolution order¶
class Pixel(Point) makes Pixel a subclass of the base class Point.
The method resolution order is the tuple Pixel.__mro__: the class, then its bases, ending with object.
Consider obj.name:
objis an object, here an instance of Pixel.nameis the name of an attribute being accessed. That attribute could refer to data (such as an integer or string), a function (method), or another object.
It is evaluated in three steps:
- Python searches the instance dictionary
vars(obj). - It then searches the dictionary of each class of the method resolution order, in turn.
- It returns the value from the first dictionary (namespace) that holds
name.
(Attribute name lookup is different from lexical name lookup, distinct mechanism)
class Pixel(Point):
unit = "px"
q = Pixel(3, 4)
[c.__name__ for c in Pixel.__mro__]
['Pixel', 'Point', 'object']
vars(q), q.unit
({'x': 3, 'y': 4}, 'px')
q.unit, q.norm(), "unit" in vars(q), "norm" in vars(Pixel) # unit found in Pixel, norm one class up, in Point
('px', 5.0, False, False)
A function found in a class comes back as a bound method, a callable that remembers the instance
and passes it as the first argument. q.norm() is therefore Point.norm(q).
(The key difference between a function object and a bound method object is the association with a particular instance of a class.)
type(Point.norm).__name__, type(q.norm).__name__
('function', 'method')
q.norm.__func__ is Point.norm, Point.norm(q)
(False, 5.0)
The built-in types follow the same search. bool is a subclass of int and defines no __add__,
so True + True runs the method 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: it writes the instance dictionary.
The class value is shadowed for that one instance. del removes the instance entry and uncovers the class value.
q.unit = "mm"
q.unit, Pixel.unit, "unit" in vars(q)
('mm', 'px', True)
del q.unit
q.unit
'px'
"unit" in vars(q)
False
Class and instance attributes¶
The class body runs once, so a value created there exists once. For a list it is a trap. The call
a.add(1) runs in two steps:
self.seenis a read. It finds no entry invars(a)and reaches the one list of the class.appendchanges that list in place.
b.seen reads the same list, stored in the class namespace.
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)
State 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 has a name that begins and ends with two underscores.
The interpreter calls it when an operator or a built-in function is applied to an instance. len(obj) calls
type(obj).__len__(obj), obj[i] calls __getitem__, repr(obj) calls __repr__.
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])
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),repr(v) # list(v) calls __getitem__ with 0, 1, 2 until IndexError
(Vector(3, 4), 2, 4, [3, 4], 'Vector(3, 4)')
len("abc"), type("abc").__len__("abc")
(3, 3)
"abc".__len__()
3
class Vector:
def norm(self):
return 5
v = Vector()
v.__call__
--------------------------------------------------------------------------- AttributeError Traceback (most recent call last) Cell In[176], line 6 2 def norm(self): 3 return 5 4 5 v = Vector() ----> 6 v.__call__ AttributeError: 'Vector' object has no attribute '__call__'
Vector.__call__
<method-wrapper '__call__' of type object at 0x115707eb0>
print(type(Vector)), print(type(v))
<class 'type'> <class '__main__.Vector'>
(None, None)
callable(v)
False
class Bare:
pass
len(Bare())
--------------------------------------------------------------------------- TypeError Traceback (most recent call last) Cell In[180], line 4 1 class Bare: 2 pass 3 ----> 4 len(Bare()) TypeError: object of type 'Bare' has no len()
Objects that can be called¶
An instance whose class defines __call__ is callable,
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)
(map is actually also a built-in type, like list, not a buil-in function)
double(21) runs type(double).__call__(double, 21).
The object keeps its values in named attributes (where a closure from Lecture 9 and 10 keeps them in cells).
type(double)
__main__.Scale
Equality with a tolerance¶
Without __eq__, a == b tests a is b.
__eq__ returns NotImplemented for an operand of another type: Python then tries the other operand's method, and falls back to identity comparison.
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 use hashes to find objects efficiently (hash(obj)).
Two objects that compare equal must have the same hash.
The hash inherited from object is based on identity, so it would differ for two equal Approx objects.
Python therefore sets __hash__ to None in a class that defines __eq__ and no __hash__: its instances are not hashable.
Approx.__hash__ is None
True
{Approx(1.0): "one"}
--------------------------------------------------------------------------- TypeError Traceback (most recent call last) Cell In[190], line 1 ----> 1 {Approx(1.0): "one"} TypeError: unhashable type: 'Approx'
Special methods in the course project¶
The models of models.py are classes. Their base class DynamicalSystem defines __call__ as a
call of rhs, the time derivative of the state, so a loop written for a function accepts a model.
RK4(h).run returns a Trajectory of trajectory.py, the times and states of one run, which
defines __repr__, __len__, __getitem__ and an __eq__ 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]
lv(0.0, np.array([1.5, 8.0])) # __call__ runs lv.rhs
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
other = Trajectory(t, y + 1e-9, lv.names)
tr == other, tr == other.every(2), Trajectory.__hash__ is None
3 Copies of objects holding arrays¶
Shallow and deep copies¶
copy.copy(x) makes a shallow copy: a new object whose instance dictionary is a copy of vars(x).
The keys are copied and the values are not. In other words, a shallow copy creates a new instance, but does not create new copies of the objects referenced by its attributes.
copy.deepcopy(x) makes a deep copy: creates a new object and recursively copies the objects it contains, rather than preserving references to the originals.
grid = [[0, 0], [0, 0]]
sh = copy.copy(grid)
sh[0][0] = 1
grid
[[1, 0], [0, 0]]
sh is grid
False
copy.copy(grid)[0] is grid[0], copy.deepcopy(grid)[0] is grid[0]
(True, False)
moved works in two steps. It makes the shallow copy new, whose x and y are the arrays of p.
It then binds a new array, self.x + dx, to new.x. new.y is still the array of p.
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
q.y[0] = 5.0
p.y
Views inside copies¶
A basic slice, start:stop:step, is a view: a new array object on the memory of the original. A view
bound to an attribute of a copy shares the memory of the original. Element 1 of p.x[::2] is element 2
of p.x.
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)
s.x[1] = -1.0
p.x
A boolean mask copies: the result owns its memory, and its .base is None. For a view of a view,
.base names the first array, the one that owns the memory.
m = p.x[p.x > 0]
m.base is None, np.shares_memory(m, p.x)
v = p.x[1:]
v[::2].base is p.x, v[::2].base is v
experiments/copies.py prints, for the methods of Trajectory that return a changed run, which
attributes are shared, then writes through each copy:
%run -m experiments.copies
4 Interfaces, dataclasses and composition¶
Abstract base classes and Protocols¶
The interface of a class is the set of methods that its callers (code that uses instances of the class) rely on.
An abstract base class marks them with @abstractmethod.
A subclass that leaves one undefined cannot be instantiated, and the error names the missing method.
from abc import ABC, abstractmethod
from typing import Protocol, runtime_checkable
class Shape(ABC):
@abstractmethod # @abstractmethod declares that subclasses must implement area() to be instantiable.
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[198], 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'
A Protocol declares an interface by its shape alone: a class satisfies it by defining the methods, without inheriting from it. A runtime-checkable Protocol checks the 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)
class Fake:
area = 3 # a number, not a method
isinstance(Fake(), HasArea)
Fake().area()
from collections.abc import Sized
isinstance("abc", Sized), isinstance(3, Sized)
- ABC: A class normally has to inherit from the ABC to satisfy its abstract-class contract.
- Protocol: A class can satisfy the protocol simply by providing the required interface, without explicitly inheriting from it.
Dataclasses¶
@dataclass receives a class and writes __init__, __repr__ and __eq__ from the annotated names
of the body, the fields. With frozen=True an assignment to a field raises, and __hash__ is
generated from the fields.
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))
p.x = 2.0
replace(p, x=2.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)
field(default_factory=list) gives each instance its own list. A bare list as default is refused
when the decorator builds the class.
@dataclass
class Path:
points: list = field(default_factory=list)
Path().points is Path().points
@dataclass
class Bad:
points: list = []
Composition over inheritance¶
Inheritance says "is a": the subclass inherits every attribute of its base, wanted or not.
Composition says "has a": an object holds another and forwards calls to it. A stack returns the
last element it received first. Stack holds a list and offers only push and pop; a subclass of
list would also inherit insert and sort, which break that order.
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")
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
One interface, several classes¶
Code that calls a method by name runs with any object whose class defines that method:
polymorphism. total_area adds the areas of squares, rectangles, and any class written later
that defines area.
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)])
--------------------------------------------------------------------------- NameError Traceback (most recent call last) Cell In[114], line 8 4 class Rectangle: 5 def __init__(self, w, h): self.w, self.h = w, h 6 def area(self): return self.w * self.h 7 ----> 8 total_area([Square(2), Rectangle(2, 3)]) NameError: name 'Square' is not defined
Polymorphism allows the same operation to behave differently depending on the type of object involved.
[len(x) for x in ("abc", [1, 2], {"k": 0})]
In the project, RK4.run calls system(t, y) and 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}")
The exercises, with their solutions, are at the end of the notes; their scripts are in
exercises/lecture11/.