Codetail

Article 13 of 15

Classes & OOP

Objects, inheritance, when (and when not) to use OOP.

30 min read

Class basics

A class is a blueprint. Calling it creates an instance, an object with its own copy of the data defined in __init__. Every method receives the instance as its first argument, conventionally named self.

Python
1class BankAccount:
2 def __init__(self, owner, balance=0):
3 self.owner = owner # instance attribute
4 self.balance = balance
5
6 def deposit(self, amount):
7 self.balance += amount
8
9 def withdraw(self, amount):
10 if amount > self.balance:
11 raise ValueError("insufficient funds")
12 self.balance -= amount
13
14 def __repr__(self):
15 return f"BankAccount({self.owner!r}, {self.balance})"
16
17# Each call creates a separate, independent instance
18alice = BankAccount("Alice", 100)
19bob = BankAccount("Bob") # balance defaults to 0
20
21alice.deposit(50)
22print(alice.balance) # 150
23print(bob.balance) # 0, completely independent
24print(alice) # BankAccount('Alice', 150)

Class attributes vs instance attributes

Class attributes live on the class itself and are shared across all instances. Instance attributes live on the object and are unique to each one.

Python
1class BankAccount:
2 interest_rate = 0.02 # class attribute, shared by all instances
3
4 def __init__(self, owner, balance=0):
5 self.owner = owner # instance attribute, unique per object
6 self.balance = balance
7
8 def apply_interest(self):
9 self.balance += self.balance * BankAccount.interest_rate
10
11a = BankAccount("Alice", 1000)
12b = BankAccount("Bob", 500)
13
14BankAccount.interest_rate = 0.05 # changes for all instances
15a.apply_interest()
16print(a.balance) # 1050.0
17print(b.balance) # 500, apply_interest not called on b

Create instances and call methods:

BankAccount class explorer

No instances yet, create one above

Inheritance

A subclass inherits all methods from its parent. Override only the ones that need to change. Use super() to call the parent implementation rather than duplicating it.

Python
1class Animal:
2 def __init__(self, name, sound):
3 self.name = name
4 self.sound = sound
5
6 def speak(self):
7 return f"{self.name} says {self.sound}"
8
9 def __repr__(self):
10 return f"{type(self).__name__}({self.name!r})"
11
12class Dog(Animal):
13 def __init__(self, name, breed):
14 super().__init__(name, sound="Woof") # delegate to Animal
15 self.breed = breed
16
17 def fetch(self):
18 return f"{self.name} fetches!"
19
20class Cat(Animal):
21 def __init__(self, name, indoor=True):
22 super().__init__(name, sound="Meow")
23 self.indoor = indoor
24
25 def speak(self): # override
26 return f"{self.name} says Meow... or maybe not."
27
28d = Dog("Rex", "Labrador")
29c = Cat("Luna")
30print(d.speak()) # Rex says Woof
31print(c.speak()) # Luna says Meow... or maybe not.
32print(d.fetch()) # Rex fetches!
33print(d) # Dog('Rex')

isinstance and issubclass

Python
1d = Dog("Rex", "Labrador")
2
3print(isinstance(d, Dog)) # True
4print(isinstance(d, Animal)) # True, Dog is a subclass of Animal
5print(isinstance(d, Cat)) # False
6
7print(issubclass(Dog, Animal)) # True
8print(issubclass(Cat, Dog)) # False

Explore the hierarchy and method resolution order:

Inheritance explorer
MRO:DogAnimalobject
class Dog(Animal):
Defined here
__init__(name, breed)
fetch()
Inherited
speak(), from Animal
__repr__(), from Animal
Instance attributes
self.name
self.sound
self.breed
super().__init__() called in Dog.__init__

Properties

A @property turns a method into attribute-style access. Callers write obj.radius instead of obj.radius(), but you can add validation or computation behind it.

Python
1class Circle:
2 def __init__(self, radius):
3 self._radius = radius # private by convention
4
5 @property
6 def radius(self):
7 return self._radius
8
9 @radius.setter
10 def radius(self, value):
11 if value < 0:
12 raise ValueError("radius must be non-negative")
13 self._radius = value
14
15 @property
16 def area(self): # computed, no setter
17 import math
18 return math.pi * self._radius ** 2
19
20c = Circle(5)
21print(c.radius) # 5, looks like attribute access
22print(c.area) # 78.53...
23
24c.radius = 10
25print(c.area) # 314.15...
26
27c.radius = -1 # ValueError: radius must be non-negative

Two-way conversion

A property with both getter and setter lets callers set via either representation, useful for unit conversion.

Python
1class Temperature:
2 def __init__(self, celsius=0.0):
3 self.celsius = celsius # plain attribute is fine to start
4
5 @property
6 def fahrenheit(self):
7 return self.celsius * 9 / 5 + 32
8
9 @fahrenheit.setter
10 def fahrenheit(self, value):
11 self.celsius = (value - 32) * 5 / 9
12
13t = Temperature(100)
14print(t.fahrenheit) # 212.0
15
16t.fahrenheit = 32
17print(t.celsius) # 0.0

Start with a plain attribute. Only add @property when you need to enforce a constraint or derive a value. Do not use properties just to follow a Java-style getter/setter pattern.

Dunder methods

Dunder (double-underscore) methods let your class hook into Python's built-in operators and functions. Python calls them automatically, you never call __str__ yourself.

Python
1class Vector:
2 def __init__(self, x, y):
3 self.x = x
4 self.y = y
5
6 def __repr__(self):
7 return f"Vector({self.x}, {self.y})" # for debuggers and repls
8
9 def __str__(self):
10 return f"({self.x}, {self.y})" # for print()
11
12 def __add__(self, other):
13 return Vector(self.x + other.x, self.y + other.y)
14
15 def __len__(self):
16 return 2 # dimensionality
17
18 def __eq__(self, other):
19 return self.x == other.x and self.y == other.y
20
21v1 = Vector(1, 2)
22v2 = Vector(3, 4)
23print(v1) # (1, 2) __str__
24print(repr(v1)) # Vector(1, 2) __repr__
25print(v1 + v2) # (4, 6) __add__
26print(len(v1)) # 2 __len__
27print(v1 == v1) # True __eq__

__repr__ vs __str__

Always define __repr__. It shows up in the REPL, inside collections, and in error messages. __str__ is optional, Python falls back to __repr__ when it is absent.

Python
1class Point:
2 def __init__(self, x, y):
3 self.x = x
4 self.y = y
5
6 def __repr__(self):
7 return f"Point({self.x!r}, {self.y!r})"
8
9points = [Point(1, 2), Point(3, 4)]
10print(points) # [Point(1, 2), Point(3, 4)] __repr__ used inside list

Sortable objects with @total_ordering

Python
1from functools import total_ordering
2
3@total_ordering # fills in <=, >, >= from __eq__ and __lt__
4class Temperature:
5 def __init__(self, celsius):
6 self.celsius = celsius
7
8 def __repr__(self):
9 return f"Temperature({self.celsius})"
10
11 def __eq__(self, other):
12 return self.celsius == other.celsius
13
14 def __lt__(self, other):
15 return self.celsius < other.celsius
16
17temps = [Temperature(30), Temperature(10), Temperature(20)]
18print(sorted(temps)) # [Temperature(10), Temperature(20), Temperature(30)]
19print(max(temps)) # Temperature(30)

When to use classes

Classes exist for one reason: bundling mutable state and the operations on that state. If you do not have persistent state across calls, a plain function is simpler and easier to test.

Python
1# Unnecessary class, no useful state
2class MathHelper:
3 def add(self, a, b): return a + b
4 def square(self, x): return x * x
5
6# Better: plain functions
7def add(a, b): return a + b
8def square(x): return x * x
Python
1# Good use: state + behavior together
2class RateLimiter:
3 def __init__(self, max_calls, period_seconds):
4 self.max_calls = max_calls
5 self.period_seconds = period_seconds
6 self._calls: list[float] = []
7
8 def allow(self) -> bool:
9 import time
10 now = time.time()
11 self._calls = [t for t in self._calls if now - t < self.period_seconds]
12 if len(self._calls) >= self.max_calls:
13 return False
14 self._calls.append(now)
15 return True
16
17# Each endpoint gets its own independent limiter
18api_limiter = RateLimiter(100, 60)
19login_limiter = RateLimiter(5, 60)

@dataclass for pure data

When you need structured data without behavior, @dataclass generates __init__, __repr__, and __eq__ for free.

Python
1from dataclasses import dataclass, field
2
3@dataclass
4class Point:
5 x: float
6 y: float
7
8@dataclass(frozen=True) # immutable, safe in sets and dicts
9class Color:
10 r: int
11 g: int
12 b: int
13
14@dataclass
15class Config:
16 host: str = "localhost"
17 port: int = 8080
18 tags: list[str] = field(default_factory=list) # mutable default
19
20p = Point(1.0, 2.0)
21print(p) # Point(x=1.0, y=2.0)
22print(p == Point(1.0, 2.0)) # True
23
24c = Config()
25print(c.host, c.port) # localhost 8080

Composition over inheritance

Deep inheritance hierarchies are hard to change and reason about. When you find yourself inheriting just to share code, pass an object instead.

Python
1# Inheritance: Logger is tightly coupled to every subclass
2class ServiceBase:
3 def log(self, msg): print(f"[LOG] {msg}")
4
5class UserService(ServiceBase):
6 pass # inherits log, but must carry all of ServiceBase
7
8# Composition: inject the dependency
9class Logger:
10 def log(self, msg): print(f"[LOG] {msg}")
11
12class UserService:
13 def __init__(self, logger: Logger):
14 self.logger = logger
15
16 def create_user(self, name):
17 self.logger.log(f"Creating {name}")
18
19svc = UserService(Logger())
20svc.create_user("Alice") # [LOG] Creating Alice