Helpful context:


In 1967, two Norwegian computer scientists named Ole-Johan Dahl and Kristen Nygaard were working on a problem: how do you simulate the behavior of a shipping port? Ships arrive, dock, unload, wait for cargo, load, depart. Each ship has its own state - its cargo, its position, its schedule. The ships interact with the port, with each other, with the tides.

They invented a programming language to solve it. They called it Simula, and it was the first language to introduce classes and objects. The insight was elegant: if you are simulating entities that have state and behavior, write code that models entities with state and behavior. A ship becomes a Ship object with a cargo attribute and a depart() method. The simulation reads like the world it models.

Object-oriented programming (OOP) was born not as a philosophy about how all software should be written, but as a practical tool for a specific kind of problem: simulating or modeling systems of interacting entities with state. For about forty years, it dominated the industry. Today, the relationship is more complicated and more interesting.

The Core Abstraction: Bundling State and Behavior

The fundamental idea in OOP is encapsulation: grouping related data and the functions that operate on it into a single unit called an object.

Consider modeling a bank account without OOP:

account = {"owner": "Alice", "balance": 100}

def deposit(account, amount):
    account["balance"] += amount

def withdraw(account, amount):
    if amount > account["balance"]:
        raise ValueError("Insufficient funds")
    account["balance"] -= amount

This works, but nothing enforces the rules. Any code anywhere can reach in and set account["balance"] = -9999. The data and its rules live apart - the rules are in the functions, but the data is in a plain dict that anyone can touch.

A class solves this by making the data and its invariants inseparable:

class BankAccount:
    def __init__(self, owner: str, balance: float = 0):
        self.owner = owner
        self._balance = balance   # underscore signals "don't touch directly"

    def deposit(self, amount: float) -> None:
        if amount <= 0:
            raise ValueError("Deposit must be positive")
        self._balance += amount

    def withdraw(self, amount: float) -> None:
        if amount <= 0 or amount > self._balance:
            raise ValueError("Invalid withdrawal")
        self._balance -= amount

    @property
    def balance(self) -> float:
        return self._balance

    def __repr__(self) -> str:
        return f"BankAccount(owner={self.owner!r}, balance={self._balance})"

Now the rules are enforced. You cannot withdraw more than the balance because withdraw prevents it. You cannot set an invalid balance because the only way in is through deposit and withdraw. The object controls its own state.

Classes, Instances, and __init__

A class is a blueprint. An instance (or object) is a specific thing created from that blueprint:

acc1 = BankAccount("Alice", 500)   # instance 1
acc2 = BankAccount("Bob", 200)     # instance 2

acc1.deposit(100)   # only affects acc1
print(acc1.balance)   # 600
print(acc2.balance)   # 200

__init__ is the initializer - it runs when you create an instance and sets up the initial state. self is the conventional name for the instance being operated on. Python passes it automatically; you just need to accept it as the first parameter in every method.

The @property decorator turns a method into an attribute access. acc1.balance looks like reading a variable but calls the balance method. This lets you add validation or computed values while keeping the interface clean.

The Three Pillars: Inheritance, Polymorphism, Encapsulation

Inheritance lets a class build on another, inheriting its attributes and methods:

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

    def speak(self) -> str:
        raise NotImplementedError("Subclasses must implement speak()")

class Dog(Animal):
    def speak(self) -> str:
        return f"{self.name} says: Woof!"

class Cat(Animal):
    def speak(self) -> str:
        return f"{self.name} says: Meow."

class Parrot(Animal):
    def __init__(self, name: str, vocabulary: list):
        super().__init__(name)   # call parent's __init__
        self.vocabulary = vocabulary

    def speak(self) -> str:
        return f"{self.name} says: {self.vocabulary[0]}!"

super() calls the parent class’s method. In Parrot.__init__, we need the parent’s initialization (setting self.name), plus our own additional setup (setting self.vocabulary). super().__init__(name) handles the parent’s part.

Polymorphism means different objects can respond to the same message:

animals = [Dog("Rex"), Cat("Whiskers"), Parrot("Polly", ["Squawk", "Hello"])]
for animal in animals:
    print(animal.speak())   # each calls its own implementation

You do not need to know the specific type. You just need to know the interface - speak() exists and returns a string. This is the power: code that works on Animal objects works on any future animal class you create, without modification.

Python takes this further with duck typing: you do not even need formal inheritance. If an object has a speak() method, you can call speak() on it. The Animal parent class is optional if you just want polymorphic behavior.

Method Resolution Order

What happens when you have multiple inheritance? Python needs a rule for which class’s method to use. That rule is the Method Resolution Order (MRO), computed using the C3 linearization algorithm:

class A:
    def hello(self):
        return "A"

class B(A):
    def hello(self):
        return "B"

class C(A):
    def hello(self):
        return "C"

class D(B, C):
    pass

print(D().hello())   # "B" - B comes first in D's MRO
print(D.__mro__)     # (D, B, C, A, object)

The MRO lists the classes in the order Python searches them for a method. Understanding it matters when you use multiple inheritance - a pattern that is powerful and frequently misused.

Dunder Methods: The Protocol Interface

Python classes can participate in the language’s built-in operations by implementing dunder (double-underscore) methods:

class Vector:
    def __init__(self, x: float, y: float):
        self.x = x
        self.y = y

    def __repr__(self) -> str:
        return f"Vector({self.x}, {self.y})"

    def __add__(self, other: "Vector") -> "Vector":
        return Vector(self.x + other.x, self.y + other.y)

    def __mul__(self, scalar: float) -> "Vector":
        return Vector(self.x * scalar, self.y * scalar)

    def __abs__(self) -> float:
        return (self.x**2 + self.y**2) ** 0.5

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, Vector):
            return NotImplemented
        return self.x == other.x and self.y == other.y

v1 = Vector(3, 4)
v2 = Vector(1, 2)
print(v1 + v2)    # Vector(4, 6) - uses __add__
print(v1 * 2)     # Vector(6, 8) - uses __mul__
print(abs(v1))    # 5.0 - uses __abs__
print(v1 == v2)   # False - uses __eq__

__repr__ is for developers - what appears in the REPL and in error messages. __str__ is for end users - what print() shows. If you only implement one, implement __repr__.

Key dunders to know: __init__, __repr__, __str__, __eq__, __hash__, __len__, __getitem__, __setitem__, __contains__, __iter__, __enter__/__exit__ (context managers).

Dataclasses: OOP Without the Boilerplate

For classes that are primarily data containers, @dataclass eliminates the __init__, __repr__, and __eq__ boilerplate:

from dataclasses import dataclass, field

@dataclass
class Point:
    x: float
    y: float

@dataclass(frozen=True)   # immutable - generates __hash__ too
class Color:
    r: int
    g: int
    b: int

@dataclass
class Student:
    name: str
    grades: list = field(default_factory=list)   # mutable default, correctly handled

p1 = Point(1.0, 2.0)
p2 = Point(1.0, 2.0)
print(p1)          # Point(x=1.0, y=2.0)
print(p1 == p2)    # True

field(default_factory=list) solves the mutable default argument problem we saw in the functions post. frozen=True creates immutable instances and automatically generates __hash__, making them usable as dictionary keys.

OOP’s Rise and the Backlash

In the 1980s and 1990s, OOP became the dominant paradigm for enterprise software. Java made it near-mandatory - everything was a class, even main lived inside a class. Books like Design Patterns (1994) described elaborate class hierarchies as the solution to software design problems. Corporate Java codebases developed inheritance hierarchies five and six levels deep.

Then the backlash started.

The banana-gorilla-jungle problem, articulated by Joe Armstrong (creator of Erlang), captured the frustration: “You wanted a banana but what you got was a gorilla holding the banana and the entire jungle.” Deep inheritance couples objects to entire hierarchies. Reusing a class means importing everything it inherits from. The promise of reuse becomes a tangle of dependencies.

“Prefer composition over inheritance” became the counter-slogan. Instead of SavingsAccount inheriting from BankAccount, give SavingsAccount a reference to a BankAccount. Instead of Logger inheriting from FileWriter, give it a FileWriter. This decouples components. You can swap out the FileWriter for a NetworkLogger without changing Logger at all.

Go and Rust took the critique further. Go has no class inheritance whatsoever - you compose behavior through interfaces (structural typing, similar to Python’s duck typing) and struct embedding. Rust has no inheritance - it uses traits, which are similar to protocols in Python. Both languages are productive at scale without classical OOP, which is significant evidence that inheritance is not necessary for organized, maintainable code.

When Not to Use Classes

Classes are the right tool when:

  • You have state and behavior that naturally belong together
  • You are modeling entities that have multiple instances
  • You need to implement a protocol or interface (via dunder methods)

Classes are the wrong tool when:

  • You just need a group of related functions (use a module)
  • You have a simple data record (use a dataclass or namedtuple)
  • You are tempted to create a class with one method (probably just a function)
  • You find yourself building inheritance hierarchies deeper than two levels (consider composition)

The worst OOP anti-pattern is the anemic domain model: classes with a lot of attributes and almost no methods, where all the logic lives in external utility functions. This gives you the overhead of OOP (classes, instances, self everywhere) with none of the benefit (encapsulation, behavior bundled with data). You have the syntax of OOP without the substance.

OOP in the Real World: Web Frameworks

The frameworks you will use if you write web applications are built on OOP patterns.

Django’s ORM models are classes that map to database tables. A User class inherits from django.db.models.Model and gets database read/write methods for free. A CharField is an object describing a column’s type and constraints.

Django’s class-based views (CBVs) use inheritance to compose HTTP handlers. A ListView provides generic list-and-render logic; you subclass it and override a few attributes to customize it. This is inheritance done well - shallow, with a clear purpose.

Flask’s @app.route decorator is the decorator pattern (discussed in design patterns) applied to functions. Behind the scenes, Flask stores registered route functions in a dictionary on the Flask application object - which is itself an OOP-designed class.

SQLAlchemy’s ORM turns Python classes into relational table schemas, with class attributes mapping to columns and methods handling queries. It is a sophisticated example of OOP enabling a clean DSL (domain-specific language) for database access.

These frameworks succeed not because they are deeply OOP-pure, but because they use classes where classes make sense - for stateful objects with clear lifecycles - and avoid them elsewhere.

The Future: Composition, Protocols, and Types

The trajectory of the field is away from deep inheritance and toward:

Composition over inheritance, as described. Build objects that hold references to other objects rather than inheriting from them. This is more flexible and produces less coupling.

Protocols (Python 3.8+) define interfaces structurally without requiring inheritance:

from typing import Protocol

class Drawable(Protocol):
    def draw(self) -> None: ...

class Circle:
    def draw(self) -> None:
        print("Drawing circle")

class Square:
    def draw(self) -> None:
        print("Drawing square")

def render(shape: Drawable) -> None:
    shape.draw()

render(Circle())   # works - Circle has draw()
render(Square())   # works - Square has draw()
# Neither Circle nor Square inherits from Drawable

This is duck typing with static analysis support. The type checker verifies that the argument to render has a draw method, without requiring any inheritance. It is the best of both worlds.

Immutability and functional patterns have grown more prominent. Python’s frozenset, @dataclass(frozen=True), and the functional tools in itertools and functools reflect the insight that mutable state is a primary source of bugs. The languages most praised for reliability today - Rust, Haskell, Elm - treat mutability as something to be managed carefully, not as a default.

OOP is not going away. But the industry has learned that it is a tool, not a religion. Used well - for encapsulation, for modeling entities with state, for implementing clean interfaces - it produces clear, maintainable code. Used dogmatically - deep hierarchies, everything-is-a-class, inheritance as the primary code reuse mechanism - it produces unmaintainable tangles.


Concept What it does When to use
Class Blueprint for objects with state and behavior When you have state + behavior that belong together
Inheritance Extend and override a parent class Shallow hierarchies, clear “is-a” relationships
Encapsulation Bundle data with the rules that protect it Whenever invariants need enforcing
Polymorphism Different objects, same interface Collections of objects you want to treat uniformly
Duck typing Structural compatibility without inheritance Most Python polymorphism
Composition Object holds references to other objects Prefer over deep inheritance
Dunder methods Hook into Python’s built-in operations Custom containers, numeric types, context managers

Read Next: