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)
# The normal way: you pass the already-clean id
buoy = Buoy("north-sea-1")
buoy.buoy_id # "north-sea-1"
# The alternative constructor: you start from a line of text
same_buoy = Buoy.from_log_line("id=north-sea-1")
same_buoy.buoy_id # "north-sea-1"
Notice the two ways of creating the buoy. Buoy("north-sea-1") receives the buoy_id directly; Buoy.from_log_line("id=north-sea-1") extracts it from a line of text. Both end up calling __init__ (that is why the method does return cls(buoy_id)), but each one starts from a different piece of data.
When would you use it? When there is more than one way to build the object. You let __init__ keep the canonical path (it receives the already-clean data) and each @classmethod adds an alternative from another format: a log line, a dictionary, a JSON, a CSV row. That way you don't fill __init__ with conditionals to guess what you were passed. It is the same pattern as in the standard library: datetime.fromisoformat, which you used in the paths and dates lesson, is exactly this, an alternative constructor that builds a datetime from text.
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
Two things are happening here. The @property on wave_limit_m turns it into a getter: when someone reads limit.wave_limit_m, that method runs under the hood, but it is written without parentheses, as if it were a normal attribute. And @wave_limit_m.setter defines the setter: the method that runs when someone assigns with limit.wave_limit_m = 3.0, which here we use to validate before storing. The two methods share the name on purpose, wave_limit_m: one governs reading and the other writing of the same attribute. The real data lives in _wave_limit_m (with an underscore, internal), and the property is the controlled door to reach it.
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 solve "a buoy with these sensors" by creating a subclass for each combination:
# With inheritance: one subclass per combination of sensors
class Buoy:
def __init__(self, buoy_id: str):
self.buoy_id = buoy_id
class BuoyWithGPS(Buoy):
def read_gps(self) -> tuple[float, float]:
...
class BuoyWithGPSAndRadar(BuoyWithGPS):
def read_radar(self) -> float:
...
# To have GPS and radar you must instantiate exactly that subclass
buoy = BuoyWithGPSAndRadar("north-sea-1")
buoy.read_gps() # inherited from BuoyWithGPS
buoy.read_radar() # its own
And a buoy with radar but no GPS? Another subclass. With GPS and a backup battery? Another one. Each new combination multiplies the tree, 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.
# With composition: a single class, the sensors come in through the list
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}
# You choose the sensors when creating the buoy, without touching the class
buoy = Buoy("north-sea-1", [WaveSensor("wave-1"), WindSensor("wind-1")])
buoy.read_all() # {"wave-1": 2.3, "wind-1": 14.0}
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.
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.