14. Classes & Objects (OOP)

Classes & Objects (OOP)

So far we have organised code with functions. Object-oriented programming (OOP) offers another powerful way: bundle related data and the functions that act on it into a single unit called an object. A class is the blueprint; an object (or instance) is a specific thing built from that blueprint.

Defining a class

Use the keyword class and a capitalised name by convention. The special method __init__ runs automatically when you create a new object; it sets up the object's starting data. The first parameter, always called self, refers to the particular object being worked on:

PYTHON
class Dog:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    def bark(self):
        return f"{self.name} says woof!"

rex = Dog("Rex", 3)
print(rex.name)
print(rex.bark())
▸ OUTPUT
Rex
Rex says woof!

The variables self.name and self.age are attributes: data belonging to each object. bark is a method: a function that belongs to the class and can use the object's attributes through self.

Many objects from one class

From a single class you can create as many independent objects as you like. Each carries its own attribute values:

PYTHON
a = Dog("Rex", 3)
b = Dog("Bella", 5)
print(a.bark())
print(b.bark())
▸ OUTPUT
Rex says woof!
Bella says woof!
class Dog name, age, bark() rex name="Rex", age=3 bella name="Bella", age=5

A worked example: a bank account

Classes shine when data and behaviour belong together. A bank account has a balance (data) and operations like deposit and withdraw (behaviour) that must keep the balance sensible:

PYTHON
class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self.balance = balance

    def deposit(self, amount):
        self.balance += amount

    def withdraw(self, amount):
        if amount > self.balance:
            print("Insufficient funds")
        else:
            self.balance -= amount

    def show(self):
        return f"{self.owner}: ${self.balance}"

acc = BankAccount("Ada", 100)
acc.deposit(50)
acc.withdraw(30)
acc.withdraw(999)
print(acc.show())
▸ OUTPUT
Insufficient funds
Ada: $120

The account started at 100, gained 50, lost 30, and refused the oversized withdrawal, ending at 120. Notice how the withdraw method protects the data by checking before subtracting. Keeping such rules inside the class is one of OOP's main benefits.

A brief look at inheritance

Inheritance lets a new class build on an existing one, reusing its code and adding or changing behaviour. The new class lists the parent in parentheses:

PYTHON
class Animal:
    def __init__(self, name):
        self.name = name
    def speak(self):
        return "some sound"

class Cat(Animal):
    def speak(self):
        return f"{self.name} says meow"

c = Cat("Milo")
print(c.name)
print(c.speak())
▸ OUTPUT
Milo
Milo says meow

Cat inherits the __init__ method from Animal, so it already knows how to store a name, but it provides its own speak method, replacing the parent's version. This ability to reuse and specialise code keeps large programs manageable.

Object-oriented programming takes practice to appreciate fully. For now, remember the core idea: a class groups related data (attributes) and actions (methods) into one tidy package, and you create as many objects from it as you need.

The four pillars of OOP

Once you can define a class and create objects, the next step is understanding why OOP is organised the way it is. Almost everything you will meet from here on is an expression of four core ideas, usually called the four pillars of object-oriented programming. You do not need to memorise the names, but recognising them helps you see the pattern behind the syntax.

PillarOne-line meaningEveryday analogy
EncapsulationBundle data with the code that works on it, and control who can touch it.A medicine capsule: the contents are sealed inside; you take the pill, you don't repackage the powder.
InheritanceA new class can reuse and extend an existing one ("is-a").A sports car is a car: it has everything a car has, plus extras.
PolymorphismThe same method name behaves correctly for different types.Pressing "play" works on a phone, a TV and a car stereo, even though each does it differently.
AbstractionExpose a simple interface and hide the messy details.Driving a car: you use the steering wheel and pedals without knowing how the engine burns fuel.

The rest of this chapter walks through each pillar with runnable code. Keep the table in mind: every feature below is a tool for one of these four goals.

Instance attributes vs class attributes

There are two places data can live in a class, and mixing them up is one of the most common beginner bugs.

  • Instance attributes are created inside __init__ using self. Each object gets its own private copy.
  • Class attributes are defined directly in the class body, outside any method. There is only one copy, shared by every instance of the class.
PYTHON
class Dog:
    species = "Canis familiaris"   # class attribute - shared by all dogs

    def __init__(self, name):
        self.name = name           # instance attribute - unique per dog

d1 = Dog("Rex")
d2 = Dog("Bella")

print(d1.name, "-", d1.species)
print(d2.name, "-", d2.species)

Dog.species = "Canis lupus"        # change it once, on the class
print(d1.species)
print(d2.species)
▸ OUTPUT
Rex - Canis familiaris
Bella - Canis familiaris
Canis lupus
Canis lupus

Notice that changing Dog.species affected both existing dogs instantly, because they all read the same shared value. Instance attributes like name stay independent. Class attributes are perfect for constants and defaults that genuinely belong to the whole type.

The mutable class-attribute trap. Problems appear when a shared class attribute is something changeable, like a list. Watch what happens:

PYTHON
class Dog:
    tricks = []                    # shared mutable list - BUG waiting to happen

    def __init__(self, name):
        self.name = name

    def add_trick(self, trick):
        self.tricks.append(trick)

a = Dog("Rex")
b = Dog("Bella")
a.add_trick("roll over")
b.add_trick("play dead")
print(a.tricks)
print(b.tricks)
▸ OUTPUT
['roll over', 'play dead']
['roll over', 'play dead']

Rex somehow knows Bella's trick! Because tricks is one shared list, every dog appends to the same object. The fix is to make the list an instance attribute, created fresh for each dog inside __init__:

PYTHON
class Dog:
    def __init__(self, name):
        self.name = name
        self.tricks = []          # a brand-new list for every dog

    def add_trick(self, trick):
        self.tricks.append(trick)

a = Dog("Rex")
b = Dog("Bella")
a.add_trick("roll over")
b.add_trick("play dead")
print(a.tricks)
print(b.tricks)
▸ OUTPUT
['roll over']
['play dead']

Rule of thumb: use class attributes for constants shared by every object; put anything each object should own separately (especially mutable things like lists and dicts) in __init__ via self.

Three kinds of methods: instance, class, and static

So far every method you wrote took self. That is just one of three method types Python offers, each with a different job.

  • Instance method — takes self; works with one specific object's data. This is the default.
  • Class method — marked with @classmethod, takes cls (the class itself). Great as an alternative constructor / factory.
  • Static method — marked with @staticmethod, takes neither self nor cls. It is just a plain utility function that logically belongs inside the class.
PYTHON
class Pizza:
    def __init__(self, radius, toppings):
        self.radius = radius
        self.toppings = toppings

    # instance method - needs a specific pizza (self)
    def describe(self):
        return f"{self.radius}cm pizza with {', '.join(self.toppings)}"

    # class method - a factory that builds a common pizza
    @classmethod
    def margherita(cls):
        return cls(15, ["tomato", "mozzarella"])

    # static method - a helper that needs no pizza at all
    @staticmethod
    def area(radius):
        return 3.14159 * radius ** 2

p = Pizza.margherita()          # build via the factory
print(p.describe())
print(f"Area: {Pizza.area(15):.1f} cm2")
▸ OUTPUT
15cm pizza with tomato, mozzarella
Area: 706.9 cm2

Why use cls instead of writing Pizza(...) directly in the factory? Because cls respects inheritance: if a subclass calls margherita(), it builds an instance of the subclass, not of Pizza. The static area method does not read any object state, so it takes neither self nor cls and can be called straight off the class.

Encapsulation: controlling access

Python does not have true "private" keywords like some languages. Instead it uses naming conventions plus one mild technical trick:

  • name — public: part of the official interface, use freely.
  • _name — protected (single underscore): "internal, please don't touch from outside." A convention only; Python does not stop you.
  • __name — private (double underscore): Python performs name mangling, rewriting it to _ClassName__name so it is hard to hit by accident.
PYTHON
class Account:
    def __init__(self, owner):
        self.owner = owner          # public
        self._bank = "ACME"         # protected (convention)
        self.__pin = "1234"         # private (name-mangled)

a = Account("Mia")
print(a.owner)                      # fine - public
print(a._bank)                      # works, but you shouldn't rely on it
try:
    print(a.__pin)                  # looks private...
except AttributeError:
    print("Cannot access __pin directly")
print(a._Account__pin)              # ...but mangling means it's still reachable
▸ OUTPUT
Mia
ACME
Cannot access __pin directly
1234

So __pin is not truly hidden; it has merely been renamed to _Account__pin. The point of the double underscore is to avoid accidental clashes and signal "really internal," not to provide security.

The clean way to guard data: @property. A property lets an attribute look like a plain variable from the outside while secretly running a getter and setter. This is how you validate data without forcing callers to use ugly get_x() / set_x() methods.

PYTHON
class Temperature:
    def __init__(self, celsius=0):
        self._celsius = celsius

    @property
    def celsius(self):              # the getter
        return self._celsius

    @celsius.setter
    def celsius(self, value):       # the setter, with validation
        if value < -273.15:
            raise ValueError("Below absolute zero!")
        self._celsius = value

    @property
    def fahrenheit(self):           # a read-only computed property
        return self._celsius * 9 / 5 + 32

t = Temperature(25)
print(t.celsius)                    # reads via the getter
print(t.fahrenheit)
t.celsius = 100                     # writes via the setter
print(t.fahrenheit)
try:
    t.celsius = -300                # triggers validation
except ValueError as e:
    print("Rejected:", e)
▸ OUTPUT
25
77.0
212.0
Rejected: Below absolute zero!

From the caller's point of view, t.celsius = 100 is just an assignment. Under the hood the setter runs, rejecting impossible values. fahrenheit has only a getter, so it is effectively read-only and always computed from the current Celsius. This is encapsulation done the Pythonic way: start with plain public attributes, and reach for @property only when you need validation or a computed value.

Inheritance in depth

Inheritance models an is-a relationship: a Dog is an Animal. The child class automatically gets the parent's attributes and methods, and can add new ones or replace (override) existing ones. Use super().__init__() to run the parent's constructor so you don't rewrite its setup.

Animal Dog Cat is-a is-a

The arrows point from the specific class up to the general one: "Dog is-a Animal." Here is a three-level hierarchy where each level overrides speak():

PYTHON
class Animal:
    def __init__(self, name):
        self.name = name

    def speak(self):
        return "..."

    def describe(self):
        return f"{self.name} says {self.speak()}"

class Dog(Animal):
    def __init__(self, name, breed):
        super().__init__(name)       # reuse Animal's constructor
        self.breed = breed

    def speak(self):                 # override
        return "Woof"

class Puppy(Dog):
    def speak(self):                 # override again
        return "Yip"

d = Dog("Rex", "Labrador")
p = Puppy("Bella", "Poodle")
print(d.describe())
print(p.describe())
print(p.breed)                       # inherited all the way from Dog.__init__
▸ OUTPUT
Rex says Woof
Bella says Yip
Poodle

Look closely at describe(): it is defined only on Animal, yet it prints the right sound for each animal. That is because it calls self.speak(), and self looks up speak on the actual object's class first. The Puppy never defines its own __init__, so it transparently uses Dog's, which is why p.breed works.

Two built-in functions let you ask about relationships at runtime:

PYTHON
print(isinstance(d, Dog))       # is d a Dog?
print(isinstance(d, Animal))    # is d also an Animal? (yes - is-a)
print(isinstance(p, Dog))       # a Puppy is-a Dog
print(issubclass(Puppy, Animal))# is the Puppy class below Animal?
print(issubclass(Dog, Puppy))   # is Dog below Puppy? (no)
▸ OUTPUT
True
True
True
True
False

Multiple inheritance and the MRO (briefly). Python allows a class to inherit from more than one parent at once, e.g. class D(B, C). When you call a method, Python searches the parents in a fixed order called the Method Resolution Order (MRO), which you can inspect with ClassName.__mro__. It generally goes left-to-right, depth-first, with each class appearing once:

PYTHON
class A:
    def greet(self):
        return "A"
class B(A):
    def greet(self):
        return "B"
class C(A):
    def greet(self):
        return "C"
class D(B, C):
    pass

print(D().greet())
print([cls.__name__ for cls in D.__mro__])
▸ OUTPUT
B
['D', 'B', 'C', 'A', 'object']

Multiple inheritance is powerful but easy to misuse; for most programs, prefer single inheritance (or composition, covered later) and keep hierarchies shallow.

Polymorphism and duck typing

Polymorphism ("many forms") means the same method name can be called on different types and each does the right thing. Because of this, you can loop over a mixed collection of objects and call one method without caring which exact class each one is:

PYTHON
class Dog:
    def speak(self):
        return "Woof"

class Cat:
    def speak(self):
        return "Meow"

class Duck:
    def speak(self):
        return "Quack"

for animal in [Dog(), Cat(), Duck()]:
    print(animal.speak())
▸ OUTPUT
Woof
Meow
Quack

Notice these three classes do not share a parent. Python does not check their type; it only checks that each object has a speak() method. This is called duck typing: "if it walks like a duck and quacks like a duck, treat it like a duck." What matters is the behaviour an object offers, not its label.

PYTHON
def make_it_speak(thing):
    print(thing.speak())          # works on ANYTHING with a speak() method

class Robot:
    def speak(self):
        return "Beep boop"

make_it_speak(Dog())
make_it_speak(Robot())            # a Robot is not an Animal, but it still fits
▸ OUTPUT
Woof
Beep boop

The Robot has nothing to do with animals, yet make_it_speak accepts it happily. Duck typing keeps Python code flexible: you program to a capability, not to a rigid type hierarchy.

Abstraction with abstract base classes

Sometimes you want to guarantee that every subclass implements a particular method, and you want to forbid creating the vague "base" type on its own. The abc module (Abstract Base Classes) provides exactly this. You inherit from ABC and mark required methods with @abstractmethod.

PYTHON
from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self):
        ...                          # no body - subclasses MUST provide one

class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius
    def area(self):
        return 3.14159 * self.radius ** 2

class Rectangle(Shape):
    def __init__(self, width, height):
        self.width = width
        self.height = height
    def area(self):
        return self.width * self.height

for s in [Circle(2), Rectangle(3, 4)]:
    print(f"{type(s).__name__}: {s.area():.2f}")

try:
    s = Shape()                      # cannot build the abstract base directly
except TypeError:
    print("Cannot instantiate abstract class Shape")
▸ OUTPUT
Circle: 12.57
Rectangle: 12.00
Cannot instantiate abstract class Shape

Shape describes what every shape must be able to do (compute an area) without saying how. Trying to create a bare Shape() raises TypeError, because it has an unimplemented abstract method. If you wrote a Triangle(Shape) and forgot to define area, Python would refuse to instantiate it too. This is abstraction enforced by the language: the base class defines the contract, concrete subclasses fulfil it.

Special (dunder) methods

Methods with double underscores on both sides (__init__, __str__, ...) are called dunder ("double underscore") or magic methods. Python calls them automatically in response to built-in operations like print(), ==, len(), + and sorting. Defining them makes your objects feel like native Python types.

By default, printing an object is unhelpful:

PYTHON
class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

p = Point(1, 2)
print(p)
▸ OUTPUT
<__main__.Point object at 0x7f9c1a2b3d40>

(The exact hexadecimal address varies every run.) Two dunders fix this. __str__ gives a friendly, human-readable form used by print() and str(); __repr__ gives an unambiguous, developer-facing form used in the interactive shell, in containers, and as a fallback when __str__ is missing.

PYTHON
class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __repr__(self):                # for developers / debugging
        return f"Point({self.x}, {self.y})"

    def __str__(self):                 # for end users
        return f"({self.x}, {self.y})"

p = Point(1, 2)
print(p)          # uses __str__
print(str(p))     # uses __str__
print(repr(p))    # uses __repr__
print([p])        # a list shows the __repr__ of each element
▸ OUTPUT
(1, 2)
(1, 2)
Point(1, 2)
[Point(1, 2)]

A good habit: always define at least __repr__. If you only define one, make it __repr__, because __str__ falls back to it.

Value equality with __eq__. By default == compares identity (are these the same object in memory?). Define __eq__ to compare by value instead:

PYTHON
class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y
    def __eq__(self, other):
        return self.x == other.x and self.y == other.y

print(Point(1, 2) == Point(1, 2))     # same values
print(Point(1, 2) == Point(3, 4))     # different values
▸ OUTPUT
True
False

Without __eq__, the first line would print False, because the two points are distinct objects even though their values match.

Making objects sortable with __lt__. The sorted() function and < operator use __lt__ ("less than"). Define it and your objects sort automatically:

PYTHON
class Student:
    def __init__(self, name, grade):
        self.name = name
        self.grade = grade
    def __repr__(self):
        return f"{self.name}({self.grade})"
    def __lt__(self, other):
        return self.grade < other.grade

students = [Student("Ann", 88), Student("Bob", 72), Student("Cy", 95)]
print(sorted(students))
▸ OUTPUT
[Bob(72), Ann(88), Cy(95)]

Length with __len__, and operators with __add__. __len__ lets len() work on your object; __add__ lets + combine two objects:

PYTHON
class Playlist:
    def __init__(self, songs):
        self.songs = songs
    def __len__(self):
        return len(self.songs)

class Money:
    def __init__(self, cents):
        self.cents = cents
    def __add__(self, other):
        return Money(self.cents + other.cents)
    def __repr__(self):
        return f"${self.cents / 100:.2f}"

print(len(Playlist(["a", "b", "c"])))
print(Money(150) + Money(350))
▸ OUTPUT
3
$5.00
Dunder methodWhat it enables
__init__Set up a new object (the constructor).
__str__Friendly text for print() / str().
__repr__Unambiguous text for debugging / containers.
__eq__Compare two objects with == by value.
__lt__Order objects with < and sorted().
__len__Make len(obj) work.
__add__Define what obj1 + obj2 does.
__getitem__Enable indexing, e.g. obj[0].

Composition vs inheritance

Inheritance models is-a ("a Dog is an Animal"). Composition models has-a ("a Car has an Engine"): instead of inheriting, one object simply holds another object as an attribute and delegates work to it.

PYTHON
class Engine:
    def __init__(self, horsepower):
        self.horsepower = horsepower
    def start(self):
        return f"Engine ({self.horsepower}hp) starting..."

class Car:
    def __init__(self, model, horsepower):
        self.model = model
        self.engine = Engine(horsepower)   # Car HAS-A Engine

    def start(self):
        return f"{self.model}: {self.engine.start()}"   # delegate to the engine

c = Car("Falcon", 300)
print(c.start())
▸ OUTPUT
Falcon: Engine (300hp) starting...

A Car is clearly not a kind of Engine, so inheritance would be wrong here; the car simply contains one. Composition is more flexible: you could swap in an ElectricEngine, or give the car several components (wheels, radio, battery) without tangling them into one inheritance chain.

Rule of thumb: if you can say "X is a Y," consider inheritance; if you can say "X has a Y," prefer composition. When unsure, favour composition — it leads to looser coupling and fewer surprises.

Best practices & common pitfalls

  • One responsibility per class. A class should model one clear thing. If it is doing networking and maths and file I/O, split it up.
  • Prefer composition for "has-a". Reach for inheritance only when there is a genuine is-a relationship, and keep hierarchies shallow.
  • Don't overuse inheritance. Deep chains and multiple inheritance make code hard to follow. Most designs need only one or two levels.
  • Use @property for validated access. Expose plain attributes by default; switch to a property when you need to validate input or compute a value, rather than littering code with get_x()/set_x().
  • Never forget self. Every instance method's first parameter is self, and you access object data through it (self.name, not just name).
  • Avoid mutable defaults and mutable class attributes. Create lists/dicts inside __init__ so each object owns its own, instead of sharing one by accident.
  • Define __repr__ (and often __str__). Readable objects make debugging and logging far easier.
  • Keep methods short and named for what they do. account.withdraw(50) reads better than one giant do_everything() method.

Quick reference

FeatureSyntaxPurpose
Define a classclass Dog:Create a new type.
Constructordef __init__(self, ...):Set up a new object's data.
Current objectselfRefers to the instance inside its methods.
Class attributespecies = "..." (in class body)Value shared by all instances.
Class method@classmethod / def m(cls):Factory or alternative constructor.
Static method@staticmethod / def m():Utility that needs no object state.
Validated attribute@property + @x.setterGetter/setter that looks like an attribute.
Inheritanceclass Dog(Animal): + super().__init__()Reuse and extend a parent class.
Abstract method@abstractmethod (class inherits ABC)Force subclasses to implement a method.
Readable text__str__ / __repr__Control how the object prints.
Value equality__eq__Compare objects with ==.