Object-Oriented Programming in Python: 10 Code-Along Examples

Learn object-oriented Python by running it. Ten copy-and-run examples covering classes, self, instance and class attributes, all three method types, inheritance with super, dunder methods, custom exceptions, and composition.

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 = mileage
car = Vehicle("Toyota", "Corolla")
van = Vehicle("Ford", "Transit", mileage=15000)
print(car.make, car.model, car.mileage) # Toyota Corolla 0
print(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 makemodel 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 way
print(Vehicle.describe(car)) # exactly the same call, spelled out

Both lines print Honda Civicself 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 = mileage
car = Vehicle("Toyota")
bike = Vehicle("Harley")
bike.WHEELS = 2 # this creates an instance attribute that shadows the class one
print(car.WHEELS) # 4, reads through to the class attribute
print(bike.WHEELS) # 2, this instance now has its own WHEELS
print(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 miles
print(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

@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 constructor
car = Vehicle("Toyota", "Corolla") # the usual way
van = Vehicle.from_string("Ford-Transit") # an alternative way in
print(car.make, car.model) # Toyota Corolla
print(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

@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")) # True
print(Vehicle.is_valid_plate("AB12")) # False
car = 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 += distance
class 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 += weight
truck = Truck("Volvo", cargo_limit=1000)
truck.drive(50) # inherited from Vehicle, unchanged
truck.load(600)
truck.load(600) # Cannot load: over cargo limit
print(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: eqlen 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.model
class 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 model
print(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):
pass
class Engine: # a separate class, not a parent
def __init__(self, horsepower):
self.horsepower = horsepower
class 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.horsepower
car = Vehicle("Toyota", 500, Engine(132))
print(car.horsepower()) # 132
try:
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.

View Comments (1)

Leave a Reply

Prev Next

Subscribe to My Newsletter

Subscribe to my email newsletter to get the latest posts delivered right to your email. Pure inspiration, zero spam.

Discover more from Discuss Data Science, Machine Learning and Analytics

Subscribe now to keep reading and get access to the full archive.

Continue reading