Learning Goals
By the end of this notebook, you will be able to:
Define classes with attributes and methods
Instantiate objects and manipulate their properties
Use special methods (
__init__,__str__,__repr__) to customize class behaviorUnderstand the difference between class and instance attributes
Implement inheritance and create subclasses
Use composition to build complex objects from simpler ones
Apply OOP principles to structure biological data and simulations
Prerequisites
This notebook assumes you are comfortable with:
Python functions and arguments
Variables, lists, and dictionaries
Basic control flow (if/else, loops)
Where this fits
This chapter extends the foundational Python concepts from the main Python chapter. You should be comfortable with Python functions, variables, lists, and dictionaries before diving into object-oriented programming.
Prerequisites: Basic Python (functions, control flow, data structures)
What comes next: You’ll use OOP concepts when building simulations, organizing complex data structures, and developing reusable code for your analyses and models throughout the course.
Object-Oriented Programming#
Introduction#
After you have been coding for a little bit of time, you may come across some people talking about object-oriented programming or OOP for short. This could be accompanied by scary words like “inheritance”, “overloading”, and “children” too. But never fear, for not only is OOP a useful coding paradigm, but it is also incredibly easy to implement in Python.
Object-oriented programming is particularly valuable in scientific computing because it allows you to model real-world entities—organisms, experimental samples, ecological sites—as self-contained units with their own properties and behaviors. This makes your code more intuitive, easier to maintain, and better reflects the structure of biological systems.
What is OOP?#
Put simply, object-oriented programming is a way of thinking about programming outside of the classic “run this function on this variable” style. Instead we can think of the data we are considering as an object.
This object could have properties and things that the object can do. In Python we refer to an object as a class, with the properties called attributes and the things it can do referred to as methods.
You can think of a class as a template for an object, so if you consider the thing you are sitting on, it is a chair. More specifically it is that particular chair you are sitting on. In that vein, you are sitting on an instance of class chair.
Other people will have different instances of class chair, and each instance is unique, with its own property values. If you cut the leg off your chair, it does not remove a leg from everybody else’s chair, just decreases the leg count of your chair by 1.
Why is this useful in biology? Imagine you’re studying a population of organisms. Each organism is an instance of a Species class, with attributes like body_mass, age, and location, and methods like grow(), reproduce(), or move(). Instead of managing parallel lists of masses, ages, and locations (and keeping them synchronized), you bundle all the data for each organism together. This makes your code clearer, less error-prone, and more closely mirrors how we think about biological systems.
If that all sounds a bit abstract, don’t worry. It is. But the vocabulary above will be used throughout and should help you grasp the concepts as we go.
Our first class#
Let’s build our first class. First open a new file called OOP.py.
To make a class in Python we use the class keyword.
To define a method of a class, you simply make a function within the class definition, but it does need a special argument called self. We will come to why that is in a little bit.
To define attributes of a class we assign values to self.x for any given attribute x.
Defining a class is not good enough on its own though. We also need to define a method of the class called __init__(). This method is run when you create an instance of a class, and generates the properties of the instance.
Here is the skeleton for creating our first class, the class for a square:
class square: # Create class "square"
def __init__(self): # Define init method
self.side_length = 2 # Set side_length to the value 2
Now we have created our class, we can make an instance of the class square. Let’s call that square1.
square1 = square()
So now we have the instance assigned to the name square1, we can check that attribute side_length by writing square1.side_length.
square1.side_length
2
We can manipulate this attribute directly if we want like a normal variable.
square1.side_length = 4
square1.side_length
4
In fact it is like a normal variable in every way except that it is encapsulated in this class.
Notice how we cannot access attributes which are not there…
square1.area
---------------------------------------------------------------------------
AttributeError Traceback (most recent call last)
Cell In[5], line 1
----> 1 square1.area
AttributeError: 'square' object has no attribute 'area'
However we can assign into attributes that were not made when the instance was first defined.
square1.area = square1.side_length ** 2
square1.area
16
Rectangles and arguments#
Now let’s define a slightly more complicated class for a rectangle. This rectangle can be of arbitrary dimensions x, y. These dimensions will be specified when creating an instance of the object (also known as instantiating the object).
To handle this, we need to add a few arguments to the __init__() method.
class rectangle:
def __init__(self, x, y): # x and y are arguments of the init method
self.x = x
self.y = y
Now let’s create a 4x3 rectangle:
rectangle1 = rectangle(4,3)
rectangle1.x
4
rectangle1.y
3
Now to find the area, we can just calculate it outside the instance, and assign it to a new attribute as follows:
rectangle1.area = rectangle1.x * rectangle1.y
rectangle1.area
12
But this is an operation we might want to do to each rectangle. So what do we do when something is repeatedly needed? We turn it into a function. Specifically here we are going to make a method of the object.
A method is special in that it takes the argument self as its first argument. This value self refers to the instance that the method belongs to.
So let’s define a method to calculate the area of an object of class rectangle:
class rectangle:
def __init__(self, x, y):
self.x = x
self.y = y
self.area = 0 # It is usually safest to create the attribute first with a dummy value.
def calc_area(self):
self.area = self.x * self.y
So now we can instantiate the new rectangle as before, and then use the method to calculate the area automatically by calling rectangle.calc_area(). The brackets here are important as remember calc_area() is basically just a function.
rectangle2 = rectangle(4,3)
rectangle2.area
0
Oops, we forgot to run calc_area(), so the area value is just the default we set. Let’s change that.
rectangle2.calc_area()
rectangle2.area
12
Perfect. But that was annoying. We know what the area will be once we instantiate the rectangle with values for the sides, so is there some way to save some time?
As a little trick, we can call other methods in the __init__() method to make them run as soon as we instantiate the object, like so:
class rectangle:
def __init__(self, x, y):
self.x = x
self.y = y
self.calc_area() # run the method calc_area() of the object immediately
def calc_area(self):
self.area = self.x * self.y
rectangle3 = rectangle(4,3)
rectangle3.area
12
Challenge 1#
Make a class circle that takes the radius of a circle as an argument at instantiation, and calculates its area and circumference automatically.
Extension: Add a method is_larger_than(other_circle) that returns True if this circle is larger than another circle (compare by area).
A more useful example#
Tip
Use @property for:
Computed values that depend on other attributes (area, BMI, ratios)
Values that should always be current (no risk of forgetting to update)
Read-only attributes that shouldn’t be directly modified
Avoid @property for:
Expensive computations you don’t want to repeat
Operations with side effects (use a regular method instead)
rect.x = 10
print(f"New area: {rect.area}") # Automatically recalculated!
The magic: if we change the dimensions, the area updates automatically:
rect = rectangle(4, 3)
print(f"Area: {rect.area}") # Note: no parentheses!
print(f"Perimeter: {rect.perimeter}")
class rectangle:
def __init__(self, x, y):
self.x = x
self.y = y
@property
def area(self):
"""Area is computed on-the-fly whenever accessed."""
return self.x * self.y
@property
def perimeter(self):
return 2 * (self.x + self.y)
Properties and automatic calculation#
So far we’ve been manually calling methods like calc_area() to update attributes. Python provides a more elegant solution: property decorators. These allow you to define methods that behave like attributes, automatically computing values when accessed.
To understand OOP, it is useful to have a more concrete example to work with, so let us think about chairs.
Here’s a chair class:
class chair:
def __init__(self, legs, height, colour):
self.legs = legs
self.height = height
self.colour = colour
def paint(self, newcolour): # A new method! Notice how it can take extra arguments aside from self
self.colour = newcolour
chair1 is a very typical chair with 4 legs, is 0.8m tall, and is coloured green.
chair1 = chair(4, 0.8, "green")
chair1.legs
4
chair1.height
0.8
chair1.colour
'green'
Now let’s use the paint() method of the chair class to repaint the chair a different colour
chair1.paint("purple")
chair1.colour
'purple'
Challenge 2#
Modify the chair class to make the height adjustable by certain amounts using the raise_chair(amount) and lower_chair(amount) methods.
Important considerations:
Heights below 0.2m or above 2.0m are unreasonable
Check that the new height is within bounds before changing it
If the change would exceed bounds, either raise an error or set to the boundary value
Extension: Add a __str__() method that reports whether the chair is currently “too low”, “comfortable”, or “too high” based on ergonomic ranges (0.4-0.5m).
Passing around objects#
An object can be passed around just like any other data. You can pack it into lists, put it in dictionaries, even pass it into functions.
Let’s create a function that works on chair objects.
def saw_leg(c):
if c.legs > 0: # A chair cannot have fewer than 0 legs
c.legs = c.legs - 1
print(chair1.legs)
saw_leg(chair1)
print(chair1.legs)
4
3
Notice how even though we didn’t return the chair object at the end of the function, it still affected the chair object.
This is something to be very careful of!
In Python, objects are passed by reference, meaning the function receives a reference to the same object in memory, not a copy. Changes made inside the function affect the original object. This is different from simple types like integers or strings, which are immutable.
# Compare: immutable vs mutableIn general it is good practice when working with functions that manipulate objects to return the object at the end. This makes the function's side effects explicit and behaves as expected—predictability is what we should always be striving for when programming.
def change_number(x):
x = x + 1```
return xprint(chair1.legs) # Now 3 - objects are mutable!
change_object(chair1)
def change_object(obj):chair1 = chair(4, 0.8, "green")
obj.legs = obj.legs - 1 # Modifies the original!
print(n) # Still 5 - integers are immutable
n = 5change_number(n)
def saw_leg(c):
if c.legs > 0:
c.legs = c.legs - 1
return c
chair1 = saw_leg(chair1)
chair1.legs
2
As stated above we could make a list of chairs too.
import random
chair_stack = []
for x in range(10):
# Make 10 green chairs with random numbers of legs and random heights
chair_stack.append(chair(random.randint(1, 10), random.random()*2, "green"))
for c in chair_stack:
print(c.legs)
7
1
10
6
1
1
10
8
2
4
We can manipulate this as we would a list of any other thing. Here’s a list comprehension that saws a leg off each chair if we can.
chair_stack = [saw_leg(c) for c in chair_stack]
for c in chair_stack:
print(c.legs)
6
0
9
5
0
0
9
7
1
3
OOP in biology#
Warning
Common pitfall: Mutable class attributes!
class Species:
individuals = [] # DANGER! Shared by ALL instances
def __init__(self, name):
self.name = name
Species.individuals.append(self) # All species share same list!
If you need a list/dict per instance, initialize it in __init__:
class Species:
def __init__(self, name):
self.name = name
self.individuals = [] # Each instance gets its own list
class Organism:
# Class attributes - shared by all instances
kingdom = "Animalia"
count = 0 # Track how many organisms we've created
def __init__(self, species, mass):
# Instance attributes - unique to each instance
self.species = species
self.mass = mass
Organism.count += 1 # Increment class attribute
org1 = Organism("Homo sapiens", 70)
org2 = Organism("Canis familiaris", 25)
print(f"Kingdom (shared): {org1.kingdom} == {org2.kingdom}")
print(f"Species (unique): {org1.species} != {org2.species}")
print(f"Total organisms created: {Organism.count}")
Class vs Instance Attributes#
So far, all our attributes have been instance attributes—each instance has its own copy. But sometimes you want attributes shared by all instances of a class. These are class attributes.
In biology, our data are often comprised of samples of individuals. If we know the attributes of the individuals that we are measuring, we can write a class that will handle each individual as a seperate instance.
from math import pi
class spider:
"""Represents a spider with web architecture measurements.
The capture area is the effective web area available for prey interception,
calculated as the elliptical web area minus the hub (where the spider sits).
This is an important functional trait for understanding foraging efficiency.
"""
def __init__(self, body_weight, web_diameter_horizontal, web_diameter_vertical, hub_diameter, mesh_width):
self.bw = body_weight # mg
self.dh = web_diameter_horizontal # cm
self.dv = web_diameter_vertical # cm
self.h = hub_diameter # cm
self.mw = mesh_width # cm
self.calc_ca()
def calc_ca(self):print(f"Capture area: {spidey1.ca:.2f} cm²")
# Calculate capture area using Ellipse-Hub formulaspidey1 = spider(1, 10, 8, 3, 1.4)
# Assumes elliptical web shape with circular hub
self.ca = ((self.dv/2)*(self.dh/2)*pi) - ((self.h/2)**2*pi)
58.119464091411174
As we’re building a project, it can be useful to have a defined data structure that is easy to access and interrogate. This is where using custom classes really shines. Why have to do something like data[14][2] * data[14][3] when you can say spider.dh * spider.dv? It’s easier to understand, easier to manage, and easier to work with overall.
Notice how the class definition serves as documentation—we can see at a glance what measurements are needed and what they represent. This self-documenting aspect of OOP is particularly valuable in scientific code.
Challenge 3 (optional)#
Create a Tree class that stores tree height, diameter, and species. Add methods to:
Calculate approximate volume (assuming a cylindrical trunk)Bonus: Create a
Forestclass that contains multiple trees and can calculate total biomass.Compare two trees by size
Represent the tree as a string showing its key measurements
This design is more flexible than making temperature and pH separate attributes. You can add any number of measurements without modifying the class structure. This is especially useful for scientific data where you might measure different variables across experiments.
class Measurement:
"""Represents a single measurement with units."""
def __init__(self, value, unit, variable_name):
self.value = value
self.unit = unit
self.name = variable_name
def __str__(self):
return f"{self.name}: {self.value} {self.unit}"
class ExperimentalUnit:
"""Represents one replicate in an experiment."""
def __init__(self, unit_id, treatment):
self.id = unit_id
self.treatment = treatment
self.measurements = [] # Composition: unit HAS measurements
def add_measurement(self, measurement):
self.measurements.append(measurement)
def __str__(self):
meas_str = ', '.join(str(m) for m in self.measurements)
return f"Unit {self.id} ({self.treatment}): {meas_str}"
# Create experimental units
unit1 = ExperimentalUnit("A1", "control")
unit1.add_measurement(Measurement(23.5, "°C", "temperature"))
unit1.add_measurement(Measurement(7.2, "pH", "acidity"))
unit2 = ExperimentalUnit("A2", "treatment")
unit2.add_measurement(Measurement(28.1, "°C", "temperature"))
unit2.add_measurement(Measurement(6.8, "pH", "acidity"))
print(unit1)
print(unit2)
Composition: Building complex objects#
Inheritance (is-a): A dog is a mammal. Composition (has-a): A forest has trees.
Composition is often more flexible than inheritance. Instead of creating deep inheritance hierarchies, you build complex objects from simpler ones.
organisms = [dog, mouse, cat]
sorted_orgs = sorted(organisms)
for org in sorted_orgs:
print(org)
Once you define __eq__ and __lt__, Python automatically provides !=, >, <=, >=. You can also sort lists of objects:
class Organism:
def __init__(self, species, mass):
self.species = species
self.mass = mass
def __eq__(self, other):
"""Check if two organisms are equal (same species and mass)."""
return self.species == other.species and self.mass == other.mass
def __lt__(self, other):
"""Check if this organism is smaller than another (by mass)."""
return self.mass < other.mass
def __str__(self):
return f"{self.species} ({self.mass}kg)"
mouse = Organism("Mus musculus", 0.02)
cat = Organism("Felis catus", 4.5)
dog = Organism("Canis familiaris", 25)
print(f"{mouse} < {cat}: {mouse < cat}")
print(f"{dog} < {cat}: {dog < cat}")
Comparing objects#
Often we want to compare objects—which organism is larger? Which treatment had better results? Python provides special methods for comparisons.
Practical Example: Population Simulation#
Let’s put it all together with a realistic biological simulation that uses multiple OOP concepts.
Summary and Next Steps#
You’ve learned how to:
Define classes with attributes and methods
Use special methods (
__init__,__str__,__repr__,__eq__,__lt__,__call__)Create properties with
@propertyDistinguish class vs instance attributes
Build complex objects through composition
Use inheritance to create specialized classes
Create custom exceptions
Avoid common OOP pitfalls
When to use OOP:
You have data with associated behaviors (organisms with methods like
grow(),reproduce())Multiple instances of similar entities (samples, sites, individuals)
Complex systems that benefit from modular design
Building reusable code/packages
When NOT to use OOP:
Simple scripts with no repeated structure
Pure data analysis pipelines (pandas DataFrames often better)
When functions alone are clearer
Further learning:
Abstract base classes (
abcmodule)Multiple inheritance and method resolution order (MRO)
Properties with setters and deleters
Class methods (
@classmethod) and static methods (@staticmethod)Type hints with classes
Design patterns (Factory, Singleton, Observer, etc.)
The best way to learn OOP is to use it. Start small: next time you have parallel lists or dictionaries with related data, try making a class instead!
This example demonstrates:
Composition:
PopulationcontainsIndividualobjectsClass attributes:
next_idshared across all individualsProperties:
sizeandtotal_biomasscomputed on-the-flyEncapsulation: Population logic is contained within the class
Clear structure: Each class has a single, well-defined responsibility
You could easily extend this to:
Add genetics (traits, inheritance)
Model spatial structure (territories, migration)
Track population statistics over time
Add multiple interacting species (predator-prey)
import random
class Individual:
"""Represents one organism in a population."""
next_id = 1 # Class attribute to assign unique IDs
def __init__(self, mass, age=0):
self.id = Individual.next_id
Individual.next_id += 1
self.mass = mass
self.age = age
self.alive = True
def grow(self, food_available):
"""Organisms grow if food is available."""
if food_available:
self.mass += random.uniform(0.1, 0.5)
def age_one_year(self):
"""Age and check for mortality."""
self.age += 1
# Simple mortality: probability increases with age
if random.random() < (self.age * 0.05):
self.alive = False
def __str__(self):
status = "alive" if self.alive else "dead"
return f"Individual {self.id}: {self.mass:.1f}kg, {self.age}y ({status})"
class Population:
"""Manages a collection of individuals."""
def __init__(self, species, initial_size=10):
self.species = species
self.individuals = []
# Create initial population
for _ in range(initial_size):
self.individuals.append(Individual(mass=random.uniform(1, 5)))
@property
def size(self):
"""Current population size (alive individuals only)."""
return sum(1 for ind in self.individuals if ind.alive)
@property
def total_biomass(self):
"""Total mass of all living individuals."""
return sum(ind.mass for ind in self.individuals if ind.alive)
def simulate_year(self, food_available=True):
"""Simulate one year: growth, aging, reproduction."""
# Age all individuals
for ind in self.individuals:
if ind.alive:
ind.grow(food_available)
ind.age_one_year()
# Reproduction: each adult has 10% chance
new_individuals = []
for ind in self.individuals:
if ind.alive and ind.age >= 2 and random.random() < 0.1:
new_individuals.append(Individual(mass=0.5, age=0))
self.individuals.extend(new_individuals)
def __str__(self):
return f"{self.species}: {self.size} individuals, {self.total_biomass:.1f}kg total"
# Run a simple simulation
pop = Population("Oryctolagus cuniculus", initial_size=10)
print(f"Year 0: {pop}")
for year in range(1, 6):
pop.simulate_year(food_available=(year % 2 == 0)) # Food every other year
print(f"Year {year}: {pop}")
Advanced OOP in Python#
This section continues from the basics above and introduces a few powerful Python features that make classes more usable and expressive.
In the previous section we touched on the basics of OOP in python. This should be enough to get you going with programming in an object-oriented manner, however objects in Python can be MUCH more powerful than demonstrated there. Using various bits of magic that are either general properties of OOP, or specific to the Python implementation, we can do a lot!
“Dunder” methods#
Making our classes more usable#
Let’s start with the chair class we created in the last session:
class chair:
def __init__(self, legs, height, colour):
self.legs = legs
self.height = height
self.colour = colour
def paint(self, newcolour):
self.colour = newcolour
my_chair = chair(6, 0.8, "black")
Now usually for an object, if we want to know what is in it, we can print it, like so:
testlist = [1,2,3]
print(testlist)
[1, 2, 3]
Let’s see what happens if we print our chair instance.
print(my_chair)
<__main__.chair object at 0x758b4c2fe150>
Obviously this is not very useful. It tells us that the object is of the class chair and the memory address at which it decides. Wouldn’t it be nice if instead it told us about the chair itself? We can do that by implementing another special method called __str__().
class chair:
def __init__(self, legs, height, colour):
self.legs = legs
self.height = height
self.colour = colour
def __str__(self):
return "A {} legged, {}m tall, {} chair.".format(self.legs, self.height, self.colour)
def paint(self, newcolour):
self.colour = newcolour
my_chair = chair(6, 0.8, "black")
Now let’s see what happens if we print this new chair:
print(my_chair)
A 6 legged, 0.8m tall, black chair.
Cool! Now whenever we want to get an idea of what chairs we have, we can just print them!
Note that the __str__() method must return a string, though it can do other things in the function before it returns that.
These magical methods that do strange things are known as dunder methods, as they start and end with a double underscore. There are many of these (see here for more) and they do a wide variety of different things.
__repr__()#
Sometimes it can be nice to have a way to get the code which can be used to generate an object. This is where the __repr__() dunder method comes in. It allows you to create a representation of an object in its current state. Let’s implement __repr__() for our chair class
class chair:
def __init__(self, legs, height, colour):
self.legs = legs
self.height = height
self.colour = colour
def __repr__(self):
return 'chair({}, {}, "{}")'.format(self.legs, self.height, self.colour)
def __str__(self):
return "A {} legged, {}m tall, {} chair.".format(self.legs, self.height, self.colour)
def paint(self, newcolour):
self.colour = newcolour
Now let’s reinstantiate my_chair with the new class definition
my_chair = chair(6, 0.8, "black")
We can use the repr() function to check the representation of this object:
repr(my_chair)
'chair(6, 0.8, "black")'
As you can see it returns the code that we wrote to generate the object in the first place!
Something else important to note is that if you have not implemented the __str__() dunder method, when you call print() on an object it will fall back on __repr__() if that exists, and then only as a last resort will it print the unhelpful <__main__.chair object at 0x7f70903cc550> that we got earlier.
It is generally good practice if you’re going to be using a class for any serious work, to implement these methods, especially __repr__().
Making an iterable#
One major use of classes is as a container for data. You can think of these as structures that hold data and metadata in a standard form, kind of like a list or dictionary, but with MOrE FUNCTIONALITY.
Let’s make a simple class to hold a stack of chairs:
class chairstack:
def __init__(self, chairs, location="lab"):
self.chairs = chairs
self.location = location
def __repr__(self):
return "chairstack({})".format(self.chairs)
def __str__(self):
return "Chair stack containing {} chair/s.".format(len(self.chairs))
import random
chair_stack = []
for x in range(10):
# Make 10 green chairs with random numbers of legs and random heights
chair_stack.append(chair(random.randint(1, 10), random.random()*2, "green"))
stack = chairstack(chair_stack)
print(stack)
Chair stack containing 10 chair/s.
repr(stack)
'chairstack([chair(3, 1.769448546869566, "green"), chair(8, 0.5064408569591434, "green"), chair(2, 1.7235454887699924, "green"), chair(4, 0.49848348480071314, "green"), chair(9, 0.2203557317580227, "green"), chair(1, 1.370163669679231, "green"), chair(9, 1.2030246812940097, "green"), chair(10, 0.3260746760576183, "green"), chair(3, 1.1635929734812964, "green"), chair(6, 0.48796006249702173, "green")])'
Now, what if we wanted to get height of the second chair in the stack? We can retrieve it as a property of the stack as follows…
print(stack.chairs[1].height)
0.5064408569591434
However that feels a bit ungainly. When we are talking about the stack, really we want to be able to just say stack[1].height as when we’re indexing the stack we know we just want to be getting an individual chair, i.e. when we say stack[1] we will always want the entry at stack.chairs[1].
Luckily there are extra methods that we can write which will allow this behaviour.
class chairstack:
def __init__(self, chairs, location="lab"):
self.chairs = chairs
self.location = location
def __repr__(self):
return "chairstack({})".format(self.chairs)
def __str__(self):
return "Chair stack containing {} chair/s.".format(len(self.chairs))
def __len__(self):
return len(self.chairs)
def __getitem__(self, chair_num):
return self.chairs[chair_num]
stack = chairstack(chair_stack)
for x in stack:
print(x)
A 3 legged, 1.769448546869566m tall, green chair.
A 8 legged, 0.5064408569591434m tall, green chair.
A 2 legged, 1.7235454887699924m tall, green chair.
A 4 legged, 0.49848348480071314m tall, green chair.
A 9 legged, 0.2203557317580227m tall, green chair.
A 1 legged, 1.370163669679231m tall, green chair.
A 9 legged, 1.2030246812940097m tall, green chair.
A 10 legged, 0.3260746760576183m tall, green chair.
A 3 legged, 1.1635929734812964m tall, green chair.
A 6 legged, 0.48796006249702173m tall, green chair.
print(stack[1])
A 8 legged, 0.5064408569591434m tall, green chair.
Here, the __len__() method is called when you request len(object), something you could either do just from the terminal, or this is also used internally when defining the parameters of an iteration.
The __getitem__() method allows you to return an entry at a selected offset.
There are also another two functions [__iter__() and __next__()] that allow more efficient iteration of these sorts of classes, however they don’t improve usability as such, just make things quicker.
This pattern is common in scientific computing: the object stores model parameters, and calling it runs the model.
class PopulationModel:
"""Simple exponential growth model."""
def __init__(self, r, K):
self.r = r # growth rate
self.K = K # carrying capacity
def __call__(self, N, t):
"""Calculate population at time t."""
# Logistic growth: dN/dt = r*N*(1 - N/K)
return self.K / (1 + ((self.K - N) / N) * (2.71828 ** (-self.r * t)))
def __str__(self):
return f"Logistic model (r={self.r}, K={self.K})"
# Create a model instance
model = PopulationModel(r=0.5, K=1000)
print(model)
# Now use it like a function!
N0 = 10
for t in [0, 5, 10, 20]:
print(f"t={t}: N={model(N0, t):.1f}")
Making objects callable with __call__#
The __call__ method makes an instance behave like a function. This is useful for objects that represent operations or models.
There’s so much more#
Dunder methods are widely varied, and defining them allows us to customise nearly every aspect of how a class operates in the wider context of a Python program. It is definitely worthwhile at least becoming familiar with the fact that they exist, because you can end up making your life a whole chunk easier.
Subclassing#
Subclassing is powerful, but consider whether composition (above) or simple inheritance is actually needed. Deep inheritance hierarchies can become difficult to maintain. A good rule: “inherit when you truly have an is-a relationship.”
Here’s a better biological example than furniture:
Another major feature of classes in Python is the idea of subclassing.
A chair (as we have been talking about the whole time so far) is a type of seat. There are also other types of seat such as stools and beanbags.
Wouldn’t it be nice to have a different class for each of these types of seat? However it’s also a lot of work to implement 3 classes with almost exactly the same code inside.
We can solve this using the joint ideas of subclassing and inheritance. A subclass inherits the methods and attributes of the parent class (also called the superclass).
class Organism:
"""Base class for all organisms."""
def __init__(self, species, mass, age):
self.species = species
self.mass = mass
self.age = age
def grow(self, mass_increase):
self.mass += mass_increase
return f"{self.species} grew to {self.mass}kg"
def __str__(self):
return f"{self.species} ({self.mass}kg, {self.age}y)"
class Plant(Organism):
def __init__(self, species, mass, age, height):
super().__init__(species, mass, age)
self.height = height
def photosynthesize(self, light_hours):
biomass_gain = light_hours * 0.01
self.grow(biomass_gain)
return f"Photosynthesized {biomass_gain}kg biomass"
class Animal(Organism):
def __init__(self, species, mass, age, diet):
super().__init__(species, mass, age)
self.diet = diet
def eat(self, food_mass):
# Animals only convert part of food to biomass
self.grow(food_mass * 0.1)
return f"{self.species} ate {food_mass}kg of food"
oak = Plant("Quercus robur", 50, 20, 12)
print(oak)
print(oak.photosynthesize(8))
rabbit = Animal("Oryctolagus cuniculus", 2, 1, "herbivore")
print(rabbit)
print(rabbit.eat(0.5))
Both `Plant` and `Animal` inherit from `Organism`, so they share the `grow()` method, but each has unique methods appropriate to their biology. The `super().__init__()` call ensures the parent class's initialization runs first.
A 3 legged, 0.9m tall, Black seat.
Method overriding example#
Sometimes you want to completely replace a parent’s method. Here’s an example where we override grow() for a specific type of organism:
class Lichen(Organism):
"""Slow-growing organism with limited growth."""
def __init__(self, species, mass, age):
super().__init__(species, mass, age)
self.max_mass = mass * 2 # Can only double in size
def grow(self, mass_increase):
# Override parent method with growth limits
if self.mass + mass_increase > self.max_mass:
self.mass = self.max_mass
return f"{self.species} reached maximum size"
else:
self.mass += mass_increase
return f"{self.species} grew to {self.mass}kg"
lichen = Lichen("Xanthoria parietina", 0.01, 5)
print(lichen.grow(0.005)) # Normal growth
print(lichen.grow(0.1)) # Hits maximum
A 0 legged, 0.4m tall, Blue seat.
Notice how Lichen keeps the signature of grow(mass_increase) but changes the behavior. This is method overriding. The subclass method completely replaces the parent’s version.
Note: Overloading means having multiple methods with the same name but different arguments. Python doesn’t support true overloading, but you can achieve similar results with default arguments or *args/**kwargs.
Custom exceptions#
When to create custom exceptions#
Custom exceptions are most useful when:
You’re building a package or library others will use
You have domain-specific errors (e.g.,
NegativeBiomassError,InvalidSequenceError)You want to catch specific errors without catching all
ValueErrors orTypeErrors
You can also create exception hierarchies:
We use exceptions a lot when coding in python. They tell us when we screwed something up, and we can use them to control the path of execution.
The exceptions defined in Python as standard are very useful, but they don’t necessarily cover exactly what you need. Sometimes you might want an exception that only handles certain elements. This can be done using the wonderful power of subclassing!
Lets take a trivial function that makes animals say things.
class EcologyError(Exception):
"""Base exception for ecology package."""
pass
class InvalidPopulationSizeError(EcologyError):
"""Raised when population size is negative or non-numeric."""
pass
class CarryingCapacityExceededError(EcologyError):
"""Raised when population exceeds carrying capacity."""
pass
def update_population(N, r, K):
if N < 0:
raise InvalidPopulationSizeError(f"Population cannot be negative: {N}")
if N > K:
raise CarryingCapacityExceededError(f"N={N} exceeds K={K}")
return N * (1 + r * (1 - N/K))
# Now you can catch all ecology errors, or specific ones
try:
update_population(-5, 0.1, 100)
except InvalidPopulationSizeError as e:
print(f"Caught specific error: {e}")
except EcologyError as e:
print(f"Caught any ecology error: {e}")
def say_woof(subject):
subject = subject.lower()
if subject == "dog":
print("Woof")
else:
raise AttributeError("Only dogs can go 'woof', {}s cannot go 'woof'".format(subject))
say_woof("cat")
---------------------------------------------------------------------------
AttributeError Traceback (most recent call last)
Cell In[48], line 8
5 else:
6 raise AttributeError("Only dogs can go 'woof', {}s cannot go 'woof'".format(subject))
----> 8 say_woof("cat")
Cell In[48], line 6, in say_woof(subject)
4 print("Woof")
5 else:
----> 6 raise AttributeError("Only dogs can go 'woof', {}s cannot go 'woof'".format(subject))
AttributeError: Only dogs can go 'woof', cats cannot go 'woof'
This did the job very well, however it is quite broad. Anything that triggers an attribute error looks the same here, so if you try to set the subject to 1 then we will also get an attribute error.
say_woof(1)
---------------------------------------------------------------------------
AttributeError Traceback (most recent call last)
Cell In[49], line 1
----> 1 say_woof(1)
Cell In[48], line 2, in say_woof(subject)
1 def say_woof(subject):
----> 2 subject = subject.lower()
3 if subject == "dog":
4 print("Woof")
AttributeError: 'int' object has no attribute 'lower'
We may want to catch when the error is expected, but fail when it is unexpected, and at present we cannot differentiate between one of our own raised errors and an error that is triggered from some other operation failing. This is where a custom exception comes in. Let’s define a new one:
class WoofError(Exception): # Hey look, a subclass!!
pass
def say_woof(subject):
subject = subject.lower()
if subject == "dog":
print("Woof")
else:
raise WoofError("Only dogs can go 'woof', {0}s cannot go 'woof', they are {0}s...".format(subject))
say_woof("chair")
---------------------------------------------------------------------------
WoofError Traceback (most recent call last)
Cell In[50], line 11
8 else:
9 raise WoofError("Only dogs can go 'woof', {0}s cannot go 'woof', they are {0}s...".format(subject))
---> 11 say_woof("chair")
Cell In[50], line 9, in say_woof(subject)
7 print("Woof")
8 else:
----> 9 raise WoofError("Only dogs can go 'woof', {0}s cannot go 'woof', they are {0}s...".format(subject))
WoofError: Only dogs can go 'woof', chairs cannot go 'woof', they are chairs...
say_woof(1)
---------------------------------------------------------------------------
AttributeError Traceback (most recent call last)
Cell In[51], line 1
----> 1 say_woof(1)
Cell In[50], line 5, in say_woof(subject)
4 def say_woof(subject):
----> 5 subject = subject.lower()
6 if subject == "dog":
7 print("Woof")
AttributeError: 'int' object has no attribute 'lower'
Now we can catch WoofError exceptions without accidentally catching any exceptions that are not WoofErrors.
Your custom exceptions can do whatever you want them to do, and as such are very useful when writing larger scripts and packages.
5. Circular imports#
If species.py imports from population.py and vice versa, you get circular import errors.
Fix:
Restructure: move shared code to a third module
Import inside functions (not at module level)
Use
from __future__ import annotationsand type hints as strings
4. Not calling super().__init__() in subclasses#
class Animal:
def __init__(self, name):
self.name = name
self.alive = True
class Dog(Animal):
def __init__(self, name, breed):
self.breed = breed
# Forgot super().__init__(name)!
# Now self.name and self.alive don't exist!
Fix: Always call parent’s __init__:
def __init__(self, name, breed):
super().__init__(name)
self.breed = breed
3. Mutable default arguments#
class Experiment:
def __init__(self, samples=[]): # DANGER!
self.samples = samples
exp1 = Experiment()
exp1.samples.append("sample1")
exp2 = Experiment() # Expects empty list, but...
print(exp2.samples) # ['sample1'] - they share the default list!
Fix: Use None and create new list inside:
def __init__(self, samples=None):
self.samples = samples if samples is not None else []
2. Modifying class attributes when you meant instance attributes#
class Population:
individuals = [] # CLASS attribute - shared!
def __init__(self, name):
self.name = name
self.individuals.append(name) # Oops! All instances share this list!
pop1 = Population("Site A")
pop2 = Population("Site B")
print(pop1.individuals) # ['Site A', 'Site B'] - unexpected!
Fix: Initialize mutable containers in __init__:
def __init__(self, name):
self.name = name
self.individuals = [] # Each instance gets its own list
1. Forgetting self#
class Broken:
def __init__(self, value):
self.value = value
def get_double():
return self.value * 2 # ERROR! get_double() missing 'self' parameter
Fix: Every method needs self as first parameter (except @staticmethod and @classmethod).
Common Pitfalls#
Warning
Watch out for these common OOP mistakes!
Dataclasses are perfect for simple data containers. If you need complex behavior, use regular classes.
from dataclasses import dataclass
@dataclass
class Sample:
"""Experimental sample with automatic __init__, __repr__, etc."""
sample_id: str
treatment: str
measurement: float
replicate: int = 1 # Default value
# All this functionality for free!
s1 = Sample("A1", "control", 23.5, 1)
s2 = Sample("A2", "control", 24.1, 2)
print(s1) # Automatic __repr__
print(s1 == s2) # Automatic __eq__
print(s1.sample_id) # All attributes work as expected
Modern Python: dataclasses#
Tip
Python 3.7+ provides @dataclass decorator that reduces boilerplate for simple data-holding classes. It automatically generates __init__, __repr__, __eq__, and more.