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 behavior

  • Understand 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 Forest class 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 @property

  • Distinguish 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 (abc module)

  • 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: Population contains Individual objects

  • Class attributes: next_id shared across all individuals

  • Properties: size and total_biomass computed on-the-fly

  • Encapsulation: 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 or TypeErrors

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 annotations and 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.