The OOP article lays out the core ideas: classes as blueprints, instances as the objects built from them, and the toolkit of methods, inheritance and dunder methods that make Python’s object model work. This workbook builds a small vehicle fleet system step by step, from a first class through inheritance, magic methods, and custom exceptions. Run each example, read the output, then tweak a value and rerun it to see how the behaviour changes.
1. A class is a blueprint, an instance is the thing it builds
A class defines the shape of an object; an instance is one concrete object built from that shape. __init__ runs automatically when you create an instance, and it is where you set up the object’s starting state.
class Vehicle: def __init__(self, make, model, mileage=0): self.make = make # instance attribute: unique to this object self.model = model self.mileage = mileagecar = Vehicle("Toyota", "Corolla")van = Vehicle("Ford", "Transit", mileage=15000)print(car.make, car.model, car.mileage) # Toyota Corolla 0print(van.make, van.model, van.mileage) # Ford Transit 15000
Vehicle is the blueprint, and car and van are two separate instances built from it, each with its own make, model and mileage. Nothing about car affects van, because __init__ gave each of them its own independent set of attributes.
2. What self actually is
self is simply the instance the method was called on. Python fills it in automatically, so car.describe() is really shorthand for Vehicle.describe(car). Seeing both forms side by side removes the mystery.
class Vehicle: def __init__(self, make, model): self.make = make self.model = model def describe(self): return f"{self.make} {self.model}"car = Vehicle("Honda", "Civic")print(car.describe()) # the usual wayprint(Vehicle.describe(car)) # exactly the same call, spelled out
Both lines print Honda Civic. self inside describe is just car, passed in automatically by the dot syntax. Once this clicks, every method definition starting with self stops looking like boilerplate and starts looking like “the instance this method is running on.”
3. Instance attributes versus class attributes
An instance attribute belongs to one object, set inside __init__ with self.. A class attribute is defined directly in the class body and is shared by every instance, until an individual instance overrides it.
class Vehicle: WHEELS = 4 # class attribute: shared by every instance def __init__(self, make, mileage=0): self.make = make # instance attribute: unique per object self.mileage = mileagecar = Vehicle("Toyota")bike = Vehicle("Harley")bike.WHEELS = 2 # this creates an instance attribute that shadows the class oneprint(car.WHEELS) # 4, reads through to the class attributeprint(bike.WHEELS) # 2, this instance now has its own WHEELSprint(Vehicle.WHEELS) # 4, the class attribute itself is untouched
Reading car.WHEELS falls through to the class because car has no WHEELS of its own. Assigning bike.WHEELS = 2 does not change the class attribute at all; it creates a new instance attribute on bike that shadows it. This distinction, read falls through, write creates a shadow, is the single most useful mental model for class attributes.
4. Instance methods that change state
Instance methods are how an object’s state changes over its lifetime. They take self and typically read or update the instance’s own attributes, rather than returning a brand new object.
class Vehicle: def __init__(self, make, mileage=0): self.make = make self.mileage = mileage def drive(self, distance): self.mileage += distance # mutate this instance's own state def refuel(self): print(f"{self.make} refuelled at {self.mileage} miles")car = Vehicle("Toyota")car.drive(120)car.drive(45)car.refuel() # Toyota refuelled at 165 milesprint(car.mileage) # 165
Each call to drive adds to self.mileage, so the object accumulates state across calls, exactly like a real vehicle’s odometer. This is the everyday shape of most methods you will write: read self, do something, update self.
5. Class methods as alternative constructors
A @classmethod receives the class itself as cls instead of an instance. The most common use is an alternative constructor, a second way to build an instance from a different shape of input, such as parsing a string.
class Vehicle: def __init__(self, make, model): self.make = make self.model = model classmethod def from_string(cls, text): make, model = text.split("-") return cls(make, model) # cls() calls the normal constructorcar = Vehicle("Toyota", "Corolla") # the usual wayvan = Vehicle.from_string("Ford-Transit") # an alternative way inprint(car.make, car.model) # Toyota Corollaprint(van.make, van.model) # Ford Transit
from_string reshapes one string into the two arguments __init__ needs, then calls cls(make, model) to build the instance properly. Using cls rather than hardcoding Vehicle means the method still works correctly even if a subclass inherits it later.
6. Static methods for logic that belongs with the class but needs no instance
A @staticmethod takes neither self nor cls. It behaves like a plain function that just happens to live inside the class, because it is conceptually related even though it needs no instance data to do its job.
class Vehicle: def __init__(self, make, plate): self.make = make self.plate = plate staticmethod def is_valid_plate(plate): return len(plate) == 7 and plate[:3].isalpha() and plate[3:].isdigit()print(Vehicle.is_valid_plate("ABC1234")) # Trueprint(Vehicle.is_valid_plate("AB12")) # Falsecar = Vehicle("Toyota", "XYZ9876")print(car.is_valid_plate(car.plate)) # can still be called on an instance
is_valid_plate never touches self or cls, so it is a static method: a small utility that belongs conceptually with Vehicle without needing any particular vehicle to run. You can call it on the class directly or on an instance; either way it behaves identically.
7. Inheritance and overriding with super()
A subclass inherits everything from its parent and can override individual methods to specialise behaviour. super() lets the subclass call the parent’s version of a method, so you extend behaviour instead of duplicating it.
class Vehicle: def __init__(self, make, mileage=0): self.make = make self.mileage = mileage def drive(self, distance): self.mileage += distanceclass Truck(Vehicle): def __init__(self, make, cargo_limit, mileage=0): super().__init__(make, mileage) # let the parent set up make and mileage self.cargo_limit = cargo_limit self.cargo = 0 def load(self, weight): if self.cargo + weight > self.cargo_limit: print("Cannot load: over cargo limit") else: self.cargo += weighttruck = Truck("Volvo", cargo_limit=1000)truck.drive(50) # inherited from Vehicle, unchangedtruck.load(600)truck.load(600) # Cannot load: over cargo limitprint(truck.mileage, truck.cargo) # 50 600
Truck reuses Vehicle.__init__ through super().__init__(make, mileage) rather than repeating that logic, then adds its own cargo tracking on top. drive is inherited unchanged, while load is entirely new behaviour that only makes sense for a truck.
8. Dunder methods: repr versus str
Dunder, or magic, methods let your objects work with Python’s built-in behaviour. __repr__ gives an unambiguous, developer-facing representation, ideally one that looks like valid code, while __str__ gives a readable, user-facing description. print() prefers __str__; the console and debuggers fall back to __repr__.
class Vehicle: def __init__(self, make, model): self.make = make self.model = model def __repr__(self): return f"Vehicle('{self.make}', '{self.model}')" def __str__(self): return f"{self.make} {self.model}"car = Vehicle("Toyota", "Corolla")print(car) # Toyota Corolla (uses __str__)print(repr(car)) # Vehicle('Toyota', 'Corolla') (uses __repr__)car # in a REPL/notebook cell this also shows __repr__'s output
Notice __repr__ reads almost like the code you would type to recreate the object, which is exactly its purpose: precise and unambiguous for developers. __str__ is for humans, and that is what a plain print(car) uses.
9. Dunder methods: eq, len and contains
Beyond string representation, dunder methods let your objects support comparisons and container-like behaviour. __eq__defines what == means for your class, __len__ powers len(), and __contains__ powers the in keyword.
class Vehicle: def __init__(self, make, model): self.make = make self.model = model def __eq__(self, other): return self.make == other.make and self.model == other.modelclass Fleet: def __init__(self, vehicles): self.vehicles = vehicles def __len__(self): return len(self.vehicles) def __contains__(self, make): return any(v.make == make for v in self.vehicles)car1 = Vehicle("Toyota", "Corolla")car2 = Vehicle("Toyota", "Corolla")fleet = Fleet([car1, Vehicle("Ford", "Transit")])print(car1 == car2) # True, because __eq__ compares make and modelprint(len(fleet)) # 2, because __len__ returns len(self.vehicles)print("Ford" in fleet) # True, because __contains__ checks each vehicle's make
Without __eq__, two separately created Vehicle objects with identical data would compare as not equal, because the default equality checks identity, not content. Defining these three dunders is what makes Fleet behave like a natural container, supporting len() and in the way a list would.
10. Custom exceptions and composition over inheritance
A custom exception, built by inheriting from a built-in exception class, lets you fail fast with a clear, specific error rather than letting bad data silently corrupt an object. Composition, giving an object another object as an attribute, models a “has-a” relationship, as opposed to inheritance’s “is-a” relationship.
class InvalidMileageError(ValueError): passclass Engine: # a separate class, not a parent def __init__(self, horsepower): self.horsepower = horsepowerclass Vehicle: def __init__(self, make, mileage, engine): if mileage < 0: raise InvalidMileageError(f"Mileage cannot be negative: {mileage}") self.make = make self.mileage = mileage self.engine = engine # composition: Vehicle HAS AN Engine def horsepower(self): return self.engine.horsepowercar = Vehicle("Toyota", 500, Engine(132))print(car.horsepower()) # 132try: bad_car = Vehicle("Ford", -10, Engine(150))except InvalidMileageError as e: print(f"Could not create vehicle: {e}")
InvalidMileageError is caught exactly like any other ValueError, since that is what it inherits from, but it names the specific problem clearly in your code and your logs. Vehicle does not inherit from Engine, a truck is not a kind of engine, it simply holds one as an attribute, which is composition: reach for inheritance when one class genuinely is a specialised version of another, and composition when one class simply has another as a part.
Work through these and you will have covered the whole article: classes and instances, self, instance versus class attributes, the three method types, inheritance with super(), the key dunder methods, custom exceptions, and composition versus inheritance. The pattern worth keeping is the fail-fast instinct from the final example: validate in __init__ and raise a specific, well-named exception the moment something is wrong, rather than letting an invalid object exist silently and fail somewhere else later.
See you soon.
[…] Object-Oriented Programming in Python: 10 Code-Along Examples […]