Object-oriented programming organises code around data + the operations on it. Python's OOP is lightweight (no private keyword, no compile-time type checking) yet flexible enough for everything from 10-line scripts to massive frameworks. This post covers classes, instances, inheritance, dunder methods, properties, and the modern @dataclass decorator.

Series map: 00 Setup → 01 Syntax → 02 Data Structures → 03 Functions & Modules → 04 OOP (this post) → 05 Files & Exceptions → (future).


Learning Outcome

  • Define classes with __init__, methods, and instance attributes.
  • Use inheritance and the super() mechanism.
  • Override dunder methods (__repr__, __eq__, __len__, etc.).
  • Use @property for computed attributes.
  • Use @classmethod and @staticmethod.
  • Write classes with @dataclass (the modern shortcut).
  • Understand composition over inheritance.

Chapter 1 — Class basics

Terminology

  • Class — a template that defines attributes and methods.
  • Instance — an object created from a class.
  • Attribute — data stored on an instance (or class).
  • Method — a function defined inside a class.
  • self — the conventional first parameter of every instance method, automatically bound to the instance.

Minimal class

1
2
3
4
5
6
7
8
9
10
11
12
class Dog:
def __init__(self, name, age):
self.name = name # instance attribute
self.age = age

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

d = Dog("Rex", 3)
print(d.name) # Rex
print(d.bark()) # Rex says woof!

__init__ is the constructor — called automatically when you do Dog(...). The first parameter self is the freshly-created instance.

Trick: you don't pass self when calling d.bark() — Python passes it for you. Inside the class definition, every method that operates on instance data needs self as the first parameter.


Chapter 2 — Class vs instance attributes

1
2
3
4
5
6
7
8
9
10
11
12
13
class Dog:
species = "Canis familiaris" # class attribute (shared by all dogs)

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

a = Dog("Rex")
b = Dog("Buddy")
print(a.species, b.species) # both: Canis familiaris

Dog.species = "Canis lupus familiaris"
print(a.species) # updated for all instances

Mutable class attributes are a trap — same pattern as the function default bug:

1
2
3
4
5
6
7
8
9
10
class Bad:
items = [] # SHARED across all instances!

def add(self, x):
self.items.append(x)

a = Bad(); a.add(1)
b = Bad(); b.add(2)
print(a.items, b.items) # [1, 2] [1, 2] surprise!

Fix: initialise in __init__ so each instance gets its own list.

Class attributes are shared by all instances while instance attributes belong to each object, and the mutable class attribute trap


Chapter 3 — Inheritance

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Animal:
def __init__(self, name):
self.name = name

def speak(self):
raise NotImplementedError("subclass must implement speak()")

class Dog(Animal):
def speak(self):
return f"{self.name}: woof!"

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

for a in [Dog("Rex"), Cat("Mochi")]:
print(a.speak())

super() — calling the parent

1
2
3
4
5
6
7
8
9
class Puppy(Dog):
def __init__(self, name, owner):
super().__init__(name) # call Dog.__init__
self.owner = owner

def speak(self):
base = super().speak()
return f"{base} (and wags tail)"

Multiple inheritance exists (class C(A, B):) and uses the MRO (method resolution order). Keep it simple — use single inheritance unless you have a strong reason.


Chapter 4 — Dunder methods

"Dunder" = "double underscore". These are how Python hooks into your class for built-in operations.

Dunder Triggered by Returns
__init__(self, ...) Cls(...) nothing (initialises)
__repr__(self) repr(obj), REPL display str (developer view)
__str__(self) str(obj), print(obj) str (user view)
__eq__(self, other) obj == other bool
__lt__(self, other) obj < other bool
__len__(self) len(obj) int
__getitem__(self, k) obj[k] item
__contains__(self, x) x in obj bool
__iter__(self) for x in obj iterator
__add__(self, other) obj + other result
__call__(self, ...) obj(...) result

Example: a Vector class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import math

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

def __repr__(self):
return f"Vec2({self.x}, {self.y})"

def __add__(self, other):
return Vec2(self.x + other.x, self.y + other.y)

def __eq__(self, other):
return self.x == other.x and self.y == other.y

def __abs__(self):
return math.hypot(self.x, self.y)

a = Vec2(1, 2)
b = Vec2(3, 4)
print(a + b) # Vec2(4, 6)
print(abs(a)) # 2.236...
print(a == Vec2(1, 2)) # True

Chapter 5 — Properties (computed attributes)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Circle:
def __init__(self, radius):
self.radius = radius

@property
def area(self):
return math.pi * self.radius ** 2

@property
def diameter(self):
return self.radius * 2

@diameter.setter
def diameter(self, value):
self.radius = value / 2

c = Circle(5)
print(c.area) # accessed like an attribute, not a method!
c.diameter = 20 # setter called, radius becomes 10
print(c.radius) # 10

@property lets you expose calculated values as if they were attributes, and add validation/setters later without breaking callers.


Chapter 6 — classmethod and staticmethod

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Date:
def __init__(self, y, m, d):
self.y, self.m, self.d = y, m, d

@classmethod
def from_string(cls, s): # alternative constructor
y, m, d = map(int, s.split("-"))
return cls(y, m, d)

@staticmethod
def is_leap(year): # no self, no cls
return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)

d = Date.from_string("2026-05-21")
Date.is_leap(2024) # True
  • @classmethod — gets the class as cls. Used for alternative constructors.
  • @staticmethod — gets nothing. Used for utility functions that belong with the class logically.

Chapter 7 — @dataclass (the modern shortcut)

Most classes are just "bags of attributes" with __init__, __repr__, and __eq__. The @dataclass decorator generates all of that for you.

1
2
3
4
5
6
7
8
9
10
11
from dataclasses import dataclass

@dataclass
class Point:
x: float
y: float

p = Point(1.0, 2.0)
print(p) # Point(x=1.0, y=2.0) <-- __repr__ auto-generated
print(p == Point(1.0, 2.0)) # True <-- __eq__ auto-generated

That's it. No boilerplate.

Options

1
2
3
4
5
6
7
8
@dataclass(frozen=True, slots=True)
class Coord:
x: int
y: int

c = Coord(1, 2)
c.x = 5 # AttributeError — frozen=True makes it immutable
  • frozen=True — immutable (hashable, usable as dict keys).
  • slots=True (3.10+) — faster + uses less memory.
  • order=True — generates <, <=, >, >=.

Default values

1
2
3
4
5
6
7
8
from dataclasses import dataclass, field

@dataclass
class User:
name: str
tags: list = field(default_factory=list) # mutable default — must use factory
score: int = 0

Trick: @dataclass should be your default class style. Reach for plain class only when you need custom __init__ logic.


Chapter 8 — Composition over inheritance

A common OOP design rule: when you can use containment instead of inheritance, do.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Inheritance approach (often wrong)
class Engine:
def start(self): print("vroom")

class Car(Engine): # "Car is an Engine"? — no, a Car *has* an Engine
pass

# Composition (better)
class Car:
def __init__(self):
self.engine = Engine()

def start(self):
self.engine.start()

Inheritance creates a tight, often-unwanted coupling. Composition keeps pieces interchangeable.


Practice Exercises

Exercise 1 — BankAccount

Write a class BankAccount with deposit(amount), withdraw(amount), and balance (read-only). Reject withdrawals that would overdraw. Track all transactions in a list.

Solution
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class BankAccount:
def __init__(self, owner, opening=0):
self.owner = owner
self._balance = opening
self.history = []

@property
def balance(self):
return self._balance

def deposit(self, amount):
if amount <= 0:
raise ValueError("amount must be positive")
self._balance += amount
self.history.append(("deposit", amount))

def withdraw(self, amount):
if amount > self._balance:
raise ValueError("insufficient funds")
self._balance -= amount
self.history.append(("withdraw", amount))

Exercise 2 — Vector class

Extend Vec2 from Chapter 4: add scalar multiplication (v * 3), subtraction, dot product as a method.

Exercise 3 — Animal hierarchy

Build Animal → Dog, Cat, Bird. Each speak() returns a different sound. Then write a function that takes a list of animals and prints each one's sound.

Exercise 4 — Stack with dunders

Implement a Stack class supporting:

  • push(x)
  • pop()
  • len(s)
  • bool(s) — False if empty
  • repr(s) — shows contents
Solution
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Stack:
def __init__(self):
self._items = []

def push(self, x):
self._items.append(x)

def pop(self):
return self._items.pop()

def __len__(self):
return len(self._items)

def __bool__(self):
return bool(self._items)

def __repr__(self):
return f"Stack({self._items!r})"

Exercise 5 — @dataclass for a Book library

Use @dataclass to model a Book (title, author, year). Then make a Library class that holds a list of books with methods add(book), find_by_author(name), and __len__.


Cheat-sheet

What How
Class class Foo:
Constructor def __init__(self, ...):
Inherit class Bar(Foo):
Call parent super().method(...)
Repr def __repr__(self):
Equality def __eq__(self, other):
Property @property decorator
Setter @<name>.setter
Class method @classmethod (alternative constructor)
Static method @staticmethod (utility, no self)
Dataclass @dataclass
Frozen @dataclass(frozen=True)
Mutable default field(default_factory=list)

What's next

Post 05 covers files and exceptions: reading and writing text/binary files, the modern pathlib, exception handling, custom exceptions, and the with context-manager protocol.


References

Note: this post is in progress. Planned additions: ABCs (abstract base classes), __slots__ deep dive, multiple inheritance + MRO walkthrough.