5. Object-oriented programming
This course prefers functions, and it defends that choice. But object orientation is part of the language, it shows up in almost all the code you are going to read, and later on we are going to use it for real. Skipping it would leave you with a gap. So here you see it properly: what it is, how it is written in modern Python and, above all, when a class earns its place and when a function is better.
You have already come across classes without giving it a second thought: the dataclass that groups fields and the Enum of closed values. Those were classes used as data records. Now we take the full step: a class that joins state (the data) and behavior (the functions that operate on that data) into a single piece.
Classes and objects: state and behavior together
A class is the mold; an object is each piece made with that mold. The class defines what data it holds and what it knows how to do; each object, each instance, has its own data.
class Buoy:
"""A buoy identified by a code that stores its wave readings."""
def __init__(self, buoy_id: str):
self.buoy_id = buoy_id
self.readings: list[float] = []
def add_reading(self, wave_height_m: float) -> None:
"""Store a new wave-height reading, in meters."""
self.readings.append(wave_height_m)
north_sea = Buoy("north-sea-1")
north_sea.add_reading(2.1)
north_sea.add_reading(3.4)
north_sea.buoy_id # "north-sea-1"
Three things to grasp at once:
__init__is the constructor: it runs when the object is created and prepares its initial state.selfis the object itself. It is the first parameter of every method, and Python passes it to you automatically when you callnorth_sea.add_reading(...). It is not a reserved word, it is a convention, but respect it religiously.- The attributes (
self.buoy_id,self.readings) are the data of that specific instance. Two different buoys have their own readings, they don't step on each other.
Notice why a class truly adds value here: Buoy has identity (its buoy_id) and state that changes over time (it keeps accumulating readings). That is exactly what a pure function doesn't model well, and the first clue about when to reach for objects.
Instance and class attributes
A piece of data can live in two places. An instance attribute (self.something) belongs to each object. A class attribute is declared in the class body and is shared by all instances:
class Buoy:
manufacturer = "Datawell" # class attribute, shared by all
def __init__(self, buoy_id: str):
self.buoy_id = buoy_id # instance attribute, unique to each one
And here a trap you already saw with mutable default arguments reappears: never use a mutable container as a class attribute if you meant it to belong to each object.
# BAD: the list is a class attribute, shared across ALL buoys
class Buoy:
readings: list[float] = []
# GOOD: each buoy creates its own list in the constructor
class Buoy:
def __init__(self):
self.readings: list[float] = []
It is the same mistake as the default list from the lesson on functions, in a different disguise. The rule is identical: mutable per-instance data is created inside __init__.
Instance, class and static methods
An instance method receives self and works with the object's data. It is the normal case, the one you have already seen. But there are two variants worth recognizing.
A class method (@classmethod) receives the class (cls) instead of the instance. Its star use is the alternative constructor: building an object from another format.
class Buoy:
def __init__(self, buoy_id: str):
self.buoy_id = buoy_id
@classmethod
def from_log_line(cls, line: str) -> "Buoy":
"""Build a buoy from a log line like 'id=north-sea-1'."""
buoy_id = line.strip().removeprefix("id=")
return cls(buoy_id)
A static method (@staticmethod) receives neither self nor cls: it is a plain function that lives inside the class out of thematic affinity. If it doesn't touch the object's state, consider whether it wouldn't be clearer to pull it out into a loose module function, which is easier to find and to test.
Encapsulation: nothing is private in Python
OOP is usually summarized in four pillars: encapsulation, inheritance, polymorphism and abstraction. Let's start with the first.
Encapsulation is grouping data and behavior into one piece and controlling what gets touched from outside. In many languages there is a real private. In Python there isn't: everything is accessible. What there is are conventions, and they work because the community respects them.
- A single leading underscore (
_cache) says "this is internal, don't touch it from outside". Nobody stops you, but it is a contract between consenting adults. - Two leading underscores (
__token) trigger name mangling: Python renames the attribute to_Class__tokento avoid name clashes when subclassing. It is not security, it is collision prevention.
When you want to validate a value before assigning it, or expose a computed one, use @property. It turns a method into something you read and write as if it were an attribute:
class OperationalLimit:
def __init__(self, wave_limit_m: float):
self._wave_limit_m = wave_limit_m
@property
def wave_limit_m(self) -> float:
return self._wave_limit_m
@wave_limit_m.setter
def wave_limit_m(self, value: float) -> None:
if value <= 0:
raise ValueError("wave limit must be positive")
self._wave_limit_m = value
limit = OperationalLimit(2.5)
limit.wave_limit_m = 3.0 # goes through the setter and gets validated
limit.wave_limit_m = -1.0 # ValueError: wave limit must be positive
From outside you use it as limit.wave_limit_m, no parentheses. You gain control without cluttering the interface with get_ and set_ everywhere.
Inheritance, polymorphism and abstraction
The other three pillars are best understood together, with an example. Imagine a buoy carries several types of sensor and all of them share the idea of "giving a reading", but each obtains it in its own way.
from abc import ABC, abstractmethod
class Sensor(ABC):
"""Common contract for all the buoy's sensors."""
def __init__(self, sensor_id: str):
self.sensor_id = sensor_id
@abstractmethod
def read(self) -> float:
"""Return the sensor's latest measurement."""
class WaveSensor(Sensor):
def read(self) -> float:
# Talk to the hardware and return the wave height, in meters
return 2.3
class WindSensor(Sensor):
def read(self) -> float:
# Return the wind speed, in knots
return 14.0
- Abstraction.
Sensorinherits fromABCand marksreadwith@abstractmethod. With that you cannot instantiateSensoron its own and you force each child to implementread. You define the contract, not the how. - Inheritance.
WaveSensorandWindSensorderive fromSensorand keep its__init__. If a child needed to extend the constructor, it would call the parent's withsuper().__init__(...)so as not to repeat the initialization. - Polymorphism. The consuming code doesn't distinguish the concrete type:
sensors: list[Sensor] = [WaveSensor("wave-1"), WindSensor("wind-1")]
for sensor in sensors:
print(sensor.sensor_id, sensor.read())
Each object responds to read() in its own way and the loop doesn't need to know which is which. This links directly to the Protocol in the external dependencies lesson: there we will get the same polymorphism without inheritance, just by having the method with the correct signature (the famous duck typing). ABC forces it through inheritance; Protocol trusts the shape. Two roads to the same place.
Composition over inheritance
Inheritance is addictive, and that is its danger. It is tempting to build trees: BuoyWithGPS(Buoy), BuoyWithGPSAndRadar(BuoyWithGPS)... and in two steps you have a rigid hierarchy you can't touch without breaking something below.
There is almost always a better option: composition. Instead of inheriting from something in order to be that something, you keep that something inside as an attribute and use it. A buoy is not a sensor: a buoy has sensors.
class Buoy:
def __init__(self, buoy_id: str, sensors: list[Sensor]):
self.buoy_id = buoy_id
self._sensors = sensors
def read_all(self) -> dict[str, float]:
"""Read all the sensors and return sensor_id -> measurement."""
return {sensor.sensor_id: sensor.read() for sensor in self._sensors}
Adding a new sensor type doesn't touch the Buoy class at all: you pass it another object in the list and that's it. The practical rule that saves you grief: "has a" (composition) almost always ages better than "is a" (inheritance).
Special methods and the gift of the dataclass
The special methods (or dunder, for the double underscore that wraps them) hook your object into the language's syntax. The ones you will always see:
__init__: build the object.__repr__: how the object is displayed, key for debugging.__eq__: when two objects are considered equal.
Writing them by hand is tedious and easy to get wrong. That is why, when the class is mostly a data record, the dataclass you already know gives them to you for free:
from dataclasses import dataclass
@dataclass(frozen=True)
class Coordinate:
latitude: float
longitude: float
With frozen=True the object is immutable: once created, it cannot be changed. It is OOP moving closer to functional style, an object that behaves like a value. You gain __init__, __repr__ and __eq__ without writing a line, and along the way the immutability we defended so much in the functional lesson.
When a class earns its place
You now have both toolboxes. The good question is not "OOP or functional?", but "which one models this problem better?". A practical guide:
Use a class when:
- There is state with identity and a life cycle that changes over time: a buoy that accumulates readings, an open connection.
- A piece of data and the operations that use it go always together and you want to pass them as a single piece (we will see this with the
Protocolin the external dependencies lesson). - You need polymorphism: several interchangeable implementations behind the same contract.
Prefer functions (and dataclass as a mere record) when:
- You only transform data: something comes in, something comes out.
- The "object" has no real behavior, it only holds fields. There a
dataclassis enough and a class with logic gets in the way. - You want maximum purity and ease of testing.
The classic trap is the class that only has an __init__ and a single method: it is almost always a function in disguise. If you catch yourself writing configurator.configure(), ask yourself whether it wasn't just configure(...).
Python is multi-paradigm on purpose. The professional move is not to enlist in a camp, it is to choose consciously the tool that leaves the code clearest.
Guided example: a buoy that aggregates its readings
Let's bring the important parts together into an object that does earn its place: a buoy with identity that accumulates readings and knows how to summarize them. Real state, its own behavior and a calculation that stays clean. In src/metocean_tools/buoy.py:
from dataclasses import dataclass
from metocean_tools.reader import SensorReadError
@dataclass(frozen=True)
class WaveReading:
"""An immutable wave-height reading."""
timestamp: str
wave_height_m: float
class Buoy:
"""A buoy with identity that accumulates readings and summarizes them."""
def __init__(self, buoy_id: str):
self.buoy_id = buoy_id
self._readings: list[WaveReading] = []
def add_reading(self, reading: WaveReading) -> None:
"""Store a new reading."""
self._readings.append(reading)
@property
def reading_count(self) -> int:
"""How many readings the buoy holds."""
return len(self._readings)
def average_wave_height(self) -> float:
"""Average wave height across all readings, in meters.
Raises:
SensorReadError: If the buoy has no readings.
"""
if not self._readings:
raise SensorReadError("no readings to average")
heights = [reading.wave_height_m for reading in self._readings]
return sum(heights) / len(heights)
Notice the division of labor: the class only adds what a function doesn't do well, identity and accumulated state, while the average calculation is still a clean, effect-free operation, exactly like the pure function from the first lesson. We haven't thrown away what we learned, we have combined it. And we reuse the SensorReadError exception from the reader, because a meaningful error is worth it in any lesson.
And the test, which comes out neatly because the dataclass gives WaveReading its == for free:
from metocean_tools.buoy import Buoy, WaveReading
def test_buoy_averages_its_readings():
# Given
buoy = Buoy("north-sea-1")
buoy.add_reading(WaveReading("2026-01-15T10:00", 2.0))
buoy.add_reading(WaveReading("2026-01-15T11:00", 4.0))
# When
result = buoy.average_wave_height()
# Then
assert result == 3.0
assert buoy.reading_count == 2
Testing a class with state is like testing a function, with one extra step: you prepare the object (Given), exercise its behavior (When) and check both what it returns and the state it is left in (Then).
This work is under a Attribution-NonCommercial-NoDerivatives 4.0 International license.
Building SPAs with Django and HTML Over the Wire: Learn to build real-time single page applications with Python
The HTML over WebSockets approach simplifies single-page application (SPA) development and lets you bypass learning a JavaScript rendering framework such as React, Vue, or Angular, moving the logic to Python. This web application development book provides you with all the Django tools you need to simplify your developments with real-time results.
Buy the bookHelp me keep writing
Every coffee gives me a push toward the next article.
Sure, it's on me!
Comments
There are no comments yet.