Defining Classes
Why Object Orientation Matters
The Problem: Procedural code with global mutable state and ad-hoc dicts becomes unreadable once a system grows past ~1000 lines.
The Solution: Classes group data and behavior, dataclasses kill the __init__/__repr__/__eq__ boilerplate, and __slots__ + frozen instances give immutability and memory savings when you need them.
Real Impact: Done well, classes are the difference between a script that works once and a system that scales to a team — they make invariants explicit and refactoring safe.
Real-World Analogy
Think of a class as a blueprint and an object as a building:
- Class = the architectural plan — describes what attributes/methods exist
- Instance = an actual building constructed from the plan — has its own values
- self = the building referring to itself when it acts on its own state
- @dataclass = auto-generated paperwork — __init__/__repr__/__eq__ for free
- Inheritance = extending a base plan to a specialised variant
class Dog:
# Class attribute — shared by all instances
species = "Canis lupus familiaris"
def __init__(self, name: str, age: int) -> None:
# Instance attributes — unique per object
self.name = name
self.age = age
def bark(self) -> str:
return f"{self.name} says woof!"
def __repr__(self) -> str:
return f"Dog(name={self.name!r}, age={self.age})"
rex = Dog("Rex", 3)
print(rex.bark()) # Rex says woof!
print(rex) # Dog(name='Rex', age=3)
self is a convention
The first parameter of an instance method receives the instance. The name self is a convention — any name works, but always use self in production code.
Inheritance
class Animal:
def __init__(self, name: str):
self.name = name
def speak(self) -> str:
raise NotImplementedError
class Dog(Animal):
def __init__(self, name: str, breed: str):
super().__init__(name) # call parent
self.breed = breed
def speak(self) -> str:
return f"{self.name} (a {self.breed}) barks"
class Puppy(Dog):
def speak(self) -> str:
return f"{self.name} yips"
print(isinstance(Puppy("Bo", "Lab"), Animal)) # True
Multiple Inheritance and MRO
Python supports multiple inheritance. Method resolution follows the Method Resolution Order (MRO) — left to right, depth first, with C3 linearization.
class A: pass
class B(A): pass
class C(A): pass
class D(B, C): pass
print(D.__mro__)
# (D, B, C, A, object)
Dataclasses (Python 3.7+)
The boilerplate-free way to define value classes. @dataclass auto-generates __init__, __repr__, and __eq__.
from dataclasses import dataclass, field
@dataclass
class User:
name: str
email: str
is_admin: bool = False
tags: list[str] = field(default_factory=list)
u = User("Alice", "[email protected]")
print(u)
# User(name='Alice', email='[email protected]', is_admin=False, tags=[])
# Equality is by value, not identity
User("A", "x") == User("A", "x") # True
Frozen / Immutable Dataclasses
@dataclass(frozen=True, slots=True)
class Point:
x: float
y: float
# Hashable (can be dict key / set member); attempts to assign raise FrozenInstanceError
slots=True for memory
Without __slots__, every instance carries a per-instance __dict__. slots=True on a dataclass declares attributes statically and saves ~30-50% memory per instance.
Methods: instance, class, static
class Circle:
pi = 3.14159
def __init__(self, radius: float):
self.radius = radius
# Instance method — receives self
def area(self) -> float:
return Circle.pi * self.radius ** 2
# Class method — receives the class
@classmethod
def unit(cls):
return cls(1)
# Static method — no implicit first arg
@staticmethod
def describe() -> str:
return "A closed curve where all points are equidistant from a center."
Circle.unit() # alt constructor — Circle(1)
Circle.describe() # no instance needed
Properties
class Temperature:
def __init__(self, celsius: float):
self._c = celsius
@property
def celsius(self) -> float:
return self._c
@celsius.setter
def celsius(self, value: float):
if value < -273.15:
raise ValueError("below absolute zero")
self._c = value
@property
def fahrenheit(self) -> float:
return self._c * 9/5 + 32
Dunder (Magic) Methods
Special methods that hook into Python's syntax. Implement them to make your objects behave like built-ins.
class Vector:
def __init__(self, x, y):
self.x, self.y = x, y
def __repr__(self):
return f"Vector({self.x}, {self.y})"
def __add__(self, other):
return Vector(self.x + other.x, self.y + other.y)
def __eq__(self, other):
return (self.x, self.y) == (other.x, other.y)
def __hash__(self):
return hash((self.x, self.y))
def __len__(self):
return int((self.x**2 + self.y**2) ** 0.5)
Common Dunder Methods
| Method | Enables |
|---|---|
__init__ | Constructor |
__repr__ | Developer-friendly string (in REPL, debug) |
__str__ | User-friendly string (used by print) |
__eq__, __lt__ ... | ==, <, etc. |
__hash__ | Use as dict key / set member |
__len__ | len(obj) |
__iter__, __next__ | Iteration protocol |
__getitem__, __setitem__ | obj[key] syntax |
__enter__, __exit__ | Context manager (with) |
__call__ | Instance is callable like a function |
🎯 Practice Exercises
Exercise 1: BankAccount
Class with deposit, withdraw, and a balance property. Raise on overdrafts. Add __repr__ and __eq__.
Exercise 2: Money dataclass
Define a frozen, slotted Money dataclass with amount and currency. Implement __add__ that refuses to add different currencies.
Exercise 3: Stack
Implement a Stack class. Support push, pop, peek, __len__, __bool__ (empty?), and iteration.
Exercise 4: Alternate constructor
Add a @classmethod from_string to your Money dataclass that parses "USD 12.50".