Python Course — 04 · Object-Oriented Python (classes, inheritance, dataclasses)
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
@propertyfor computed attributes. - Use
@classmethodand@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
selfwhen callingd.bark()— Python passes it for you. Inside the class definition, every method that operates on instance data needsselfas 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.
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 ascls. 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:
@dataclassshould be your default class style. Reach for plainclassonly 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 emptyrepr(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
- docs.python.org tutorial §9
- dataclasses module
- Fluent Python — Ch. 9–14.
Note: this post is in progress. Planned additions: ABCs (abstract base classes),
__slots__deep dive, multiple inheritance + MRO walkthrough.










