Some data naturally travels with the operations that act on it: a weather station has a name, an elevation, and a growing list of readings, and the things you do with it — add a reading, compute its mean — belong to that station. A class bundles such state and behaviour into one object. This subchapter builds a WeatherStation, composes stations into a network, contrasts a lightweight dataclass record, and is careful about the opposite lesson too: when a plain function is the better tool. It then turns from writing code to trusting it: assert for internal invariants versus exceptions for bad input, validating physical preconditions, logging instead of printing, and testing with pytest — the concrete habits for checking code you did not write yourself, including code an AI assistant generated for you.
1.7.1 Classes and Instances¶
Every value used so far — a string, a list, a numpy array, a DataFrame — is an object: data bundled together with the operations that work on it, reached with dot notation (text.upper(), data.mean()). Until now those objects were always ones Python or a library already defined. A class is the blueprint that defines a new kind of object: what data it carries (its attributes) and what it can do (its methods). An instance is one specific object built from that blueprint — jungfraujoch and basel below are two different instances of the same WeatherStation class, each with its own name, elevation, and list of readings.
__init__ is the constructor: the method Python calls automatically each time a new instance is created, and its job is to set up that instance’s starting state. Inside any method, self refers to the particular instance the method was called on — it is how jungfraujoch.add_reading(0.4) updates only jungfraujoch’s readings, and basel.add_reading(18.0) only basel’s.
# the built-in sum() adds up the elements of an iterable
print(sum([1.0, 2.0, 3.0]))6.0
class WeatherStation:
# class attribute: one value shared by every station (a physical constant)
lapse_rate_celsius_per_km = -6.5
def __init__(self, name: str, elevation_m: float):
self.name = name # instance attributes: per-object
self.elevation_m = elevation_m
self.readings_celsius: list[float] = [] # each station owns its own list
def add_reading(self, temp_celsius: float) -> None:
# a method that MUTATES state
self.readings_celsius.append(temp_celsius)
def mean_temperature(self) -> float:
# a method that READS state
if not self.readings_celsius:
raise ValueError(f"{self.name} has no readings")
return sum(self.readings_celsius) / len(self.readings_celsius)
def sea_level_temperature(self, temp_celsius: float) -> float:
# uses the shared class attribute together with instance state
return temp_celsius - self.lapse_rate_celsius_per_km * (self.elevation_m / 1000)
def __repr__(self) -> str:
return f"WeatherStation({self.name!r}, {self.elevation_m} m, n={len(self.readings_celsius)})"jungfraujoch = WeatherStation("Jungfraujoch", 3571)
basel = WeatherStation("Basel", 316)
for t in [-2.3, -1.1, 0.4]:
jungfraujoch.add_reading(t)
for t in [18.0, 19.2, 17.5]:
basel.add_reading(t)
print(jungfraujoch) # __repr__ in action
print(basel)
print("JFJ mean:", round(jungfraujoch.mean_temperature(), 2), "°C")
print("JFJ at sea level:", round(jungfraujoch.sea_level_temperature(0.0), 1), "°C")WeatherStation('Jungfraujoch', 3571 m, n=3)
WeatherStation('Basel', 316 m, n=3)
JFJ mean: -1.0 °C
JFJ at sea level: 23.2 °C
1.7.2 Instance versus Class Attributes¶
An instance attribute belongs to one object (each station’s own readings_celsius). A class attribute is shared by every instance (the single lapse_rate_celsius_per_km). Shared constants are a good use of class attributes; shared mutable state can lead to bugs.
# the lapse rate is one shared value, reachable via the class or any instance
print(WeatherStation.lapse_rate_celsius_per_km,
jungfraujoch.lapse_rate_celsius_per_km,
basel.lapse_rate_celsius_per_km)
# the readings are independent per instance
print("JFJ readings:", jungfraujoch.readings_celsius)
print("Basel readings:", basel.readings_celsius)-6.5 -6.5 -6.5
JFJ readings: [-2.3, -1.1, 0.4]
Basel readings: [18.0, 19.2, 17.5]
1.7.3 dataclasses: Records with Less Ceremony¶
When an object is mostly a bundle of fields, @dataclass generates __init__, __repr__, and __eq__ for you from type-annotated attributes.
from dataclasses import dataclass
@dataclass
class Reading:
timestamp: str
temp_celsius: float
r = Reading("2024-06-01", 18.2)
print(r) # auto __repr__
print(r.temp_celsius)
print(Reading("2024-06-01", 18.2) == Reading("2024-06-01", 18.2)) # auto __eq__Reading(timestamp='2024-06-01', temp_celsius=18.2)
18.2
True
1.7.4 Composition: Build Larger Objects from Smaller Ones¶
Composition is a has-a relationship: a StationNetwork has stations. The container delegates work to the objects it holds rather than re-implementing it.
class StationNetwork:
def __init__(self, name: str):
self.name = name
self.stations: dict[str, WeatherStation] = {}
def add_station(self, station: WeatherStation) -> None:
self.stations[station.name] = station
def coldest_station(self) -> WeatherStation:
# delegate to each station's own mean_temperature method
coldest = None
for station in self.stations.values():
if coldest is None or station.mean_temperature() < coldest.mean_temperature():
coldest = station
return coldest
def __repr__(self) -> str:
return f"StationNetwork({self.name!r}, {len(self.stations)} stations)"
network = StationNetwork("Switzerland")
network.add_station(jungfraujoch)
network.add_station(basel)
print(network)
print("coldest:", network.coldest_station().name)StationNetwork('Switzerland', 2 stations)
coldest: Jungfraujoch
1.7.5 When Not to Use a Class¶
A stateless transformation needs no object. Wrapping a one-line conversion in a class adds boilerplate and hides a simple function behind a constructor.
# a pure function is the right tool here: input in, output out, no state
def celsius_to_kelvin(temp_celsius: float) -> float:
return temp_celsius + 273.15
print(celsius_to_kelvin(18.2))
# a class with no attributes and a single method would only add ceremony291.34999999999997
1.7.6 Inheritance: Extending a Class¶
A subclass inherits a parent’s attributes and methods — an is-a relationship, as opposed to composition’s has-a. Calling super().__init__ reuses the parent’s constructor, so the subclass only has to set up what is new.
class RiverGauge(WeatherStation):
def __init__(self, name: str, elevation_m: float):
super().__init__(name, elevation_m) # reuse the parent's constructor
self.discharge_m3s: list[float] = [] # extra state specific to a gauge
def add_discharge(self, q_m3s: float) -> None:
self.discharge_m3s.append(q_m3s)
rhone = RiverGauge("Rhone at Porte du Scex", 375)
rhone.add_reading(12.4) # inherited from WeatherStation
rhone.add_discharge(180.0)
print(rhone.name, rhone.readings_celsius, rhone.discharge_m3s)
print("mean temperature:", round(rhone.mean_temperature(), 2)) # inherited methodRhone at Porte du Scex [12.4] [180.0]
mean temperature: 12.4
Prefer composition to deep inheritance hierarchies; inherit only for a genuine is-a relationship.
Going deeper: property validation
A @property lets an attribute run validation on assignment while still being accessed like plain data.
class Station:
def __init__(self, elevation_m):
self.elevation_m = elevation_m # goes through the setter
@property
def elevation_m(self):
return self._elevation_m
@elevation_m.setter
def elevation_m(self, value):
if value < -500:
raise ValueError("elevation below -500 m is implausible")
self._elevation_m = valueGoing deeper: abstract base classes
An abstract base class defines an interface that subclasses must implement, enforced at instantiation.
from abc import ABC, abstractmethod
class Sensor(ABC):
@abstractmethod
def read(self) -> float:
...
# a subclass that does not implement read() cannot be instantiatedABCs document the contract a family of objects must satisfy.
Going deeper: the scikit-learn estimator-as-object pattern
scikit-learn models are objects that learn state in fit (stored on attributes with a trailing underscore) and use it in predict — the bridge to the machine-learning subchapter.
class MeanRegressor:
def fit(self, X, y):
self.mean_ = sum(y) / len(y) # learned state
return self
def predict(self, X):
return [self.mean_ for _ in X]Every estimator you will meet follows this fit/predict object shape.
1.7.7 From Designing Objects to Trusting Code¶
The first half of this subchapter was about building your own objects. The second is a different skill: reading and stress-testing code you did not write yourself — including code generated by an AI assistant, which can run and look plausible while still being wrong on exactly the input you didn’t try. assert, exceptions, explicit validation, logging, and tests are the concrete tools for that: ways to make a piece of code state its own assumptions and get caught the moment those assumptions break, rather than trusting that code runs correctly just because it runs.
1.7.8 assert for Invariants¶
An assert documents and checks a condition that should always be true if the code is correct. It is a statement about the program’s own logic, not about external input — and it is removed when Python runs with the -O flag.
def normalise(weights: list[float]) -> list[float]:
total = sum(weights)
result = []
for w in weights:
result.append(w / total)
# invariant: normalised weights sum to 1 (a check on our own arithmetic)
assert abs(sum(result) - 1.0) < 1e-9, "normalisation failed"
return result
print(normalise([1.0, 3.0]))[0.25, 0.75]
1.7.9 Exceptions: raise, Custom Types, and try/except/else/finally¶
An exception signals a runtime problem. raise triggers one; a custom exception subclass names a specific failure; try/except/else/finally handles it — else runs when no exception occurred, finally always runs.
class PhysicalRangeError(ValueError):
# a named failure mode, more specific than a bare ValueError
pass
def to_kelvin(temp_celsius: float) -> float:
if temp_celsius < -273.15:
raise PhysicalRangeError(f"{temp_celsius} °C is below absolute zero")
return temp_celsius + 273.15
for value in [25.0, -300.0]:
try:
kelvin = to_kelvin(value)
except PhysicalRangeError as err:
print("rejected:", err)
else:
print("ok:", round(kelvin, 2), "K") # runs only when no exception was raised
finally:
print("checked", value) # always runsok: 298.15 K
checked 25.0
rejected: -300.0 °C is below absolute zero
checked -300.0
1.7.10 Validating Physical Preconditions¶
External input — a file, a user value, a network response — must be validated with exceptions, not asserts, because it can be wrong even when the code is correct.
def validate_observation(temp_celsius: float, discharge_m3s: float) -> None:
if temp_celsius < -273.15:
raise PhysicalRangeError("temperature below absolute zero")
if discharge_m3s < 0.0:
raise ValueError("discharge cannot be negative")
validate_observation(18.0, 45.0) # valid input passes silently
try:
validate_observation(18.0, -5.0)
except ValueError as err:
print("caught:", err)caught: discharge cannot be negative
1.7.11 Logging over print¶
print writes unconditionally to stdout. logging attaches a severity level to each message, so the same code can be verbose while debugging and quiet in production, and can route messages to files or services without edits.
import logging
import sys
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s",
stream=sys.stdout, force=True)
logger = logging.getLogger("mlees")
logger.debug("suppressed: below the INFO threshold")
logger.info("loaded 45 readings")
logger.warning("3 temperature values missing")INFO: loaded 45 readings
WARNING: 3 temperature values missing
1.7.12 Testing with pytest¶
pytest discovers functions named test_*, runs them, and reports failures with readable assertion output. A fixture supplies reusable setup; parametrisation runs one test over many cases. Here we write a small module and its test file to disk, then run the suite.
from pathlib import Path
Path("_files").mkdir(exist_ok=True)
module_src = """
def to_kelvin(temp_celsius):
if temp_celsius < -273.15:
raise ValueError("below absolute zero")
return temp_celsius + 273.15
"""
Path("_files/thermo.py").write_text(module_src, encoding="utf-8")
test_src = """
import pytest
from thermo import to_kelvin
def test_freezing_point():
assert to_kelvin(0.0) == 273.15
@pytest.fixture
def boiling_celsius():
return 100.0
def test_with_fixture(boiling_celsius):
assert to_kelvin(boiling_celsius) == 373.15
@pytest.mark.parametrize("celsius, kelvin", [(0.0, 273.15), (-273.15, 0.0), (25.0, 298.15)])
def test_conversions(celsius, kelvin):
assert to_kelvin(celsius) == pytest.approx(kelvin)
def test_below_absolute_zero_raises():
with pytest.raises(ValueError):
to_kelvin(-300.0)
"""
Path("_files/test_thermo.py").write_text(test_src, encoding="utf-8")
print("wrote thermo.py and test_thermo.py")wrote thermo.py and test_thermo.py
import subprocess
import sys
result = subprocess.run(
[sys.executable, "-m", "pytest", "test_thermo.py", "-q", "--color=no"],
capture_output=True, text=True, cwd="_files",
)
print(result.stdout.strip())
When generated code lies: the shared class-level list¶
Asked for a station class, an assistant declares the readings list at the class level. That single list is then shared by every instance — the object-oriented version of the mutable-default-argument bug.
class WeatherStationBuggy:
readings_celsius = [] # class attribute: ONE list shared by all instances
def __init__(self, name):
self.name = name
def add_reading(self, temp_celsius):
self.readings_celsius.append(temp_celsius)
a = WeatherStationBuggy("A")
b = WeatherStationBuggy("B")
a.add_reading(10.0)
b.add_reading(20.0)
print("A readings:", a.readings_celsius) # expected [10.0]
print("B readings:", b.readings_celsius) # expected [20.0]A readings: [10.0, 20.0]
B readings: [10.0, 20.0]
class WeatherStationFixed:
def __init__(self, name):
self.name = name
self.readings_celsius = [] # per-instance list, created on construction
def add_reading(self, temp_celsius):
self.readings_celsius.append(temp_celsius)
a = WeatherStationFixed("A")
b = WeatherStationFixed("B")
a.add_reading(10.0)
b.add_reading(20.0)
print("A readings:", a.readings_celsius, "| B readings:", b.readings_celsius)A readings: [10.0] | B readings: [20.0]
Summary¶
| Concept | Rule to remember |
|---|---|
| Classes | Bundle state (attributes) with behaviour (methods); __init__ stores data on self. |
| Attributes | Instance attributes are per-object, class attributes are shared — never put mutable state on the class body. |
| Records | Use a dataclass for plain records, and composition to assemble larger objects from smaller ones. |
| Inheritance | A subclass extends a parent; super().__init__ reuses its constructor. |
| When not to | A stateless transformation should be a function, not a class. |
assert | For internal invariants only — python -O strips it, so never validate external input with it. |
| Exceptions | raise for bad input, try/except/else/finally to handle it; fail early and loudly. |
| Logging | Prefer logging to print: messages gain a severity level and can be filtered or redirected. |
| Testing | pytest — plain tests, fixtures for setup, parametrisation for many cases. |
Resources¶
Object-Oriented Programming (OOP) in Python — classes, instances, attributes, methods, and inheritance, with worked examples.
Python Classes: The Power of Object-Oriented Programming — instance vs class attributes, dataclasses, abstract base classes, and explicit guidance on when not to use a class.
pytest documentation — writing tests, fixtures, parametrisation, and assertions.