OOP#
Python is an object-oriented language all the way down. Every
value is an object; every object has a type; every type is itself
an object. Classes are how the operator defines new types,
attaches behaviour, and composes one type out of others. The
class keyword is the main entry point; @dataclass covers
the plain-data case; protocols cover the duck-typed case.
This page is the reference for class definition, inheritance, properties, dataclasses, and the dunder-method protocols that hook custom types into the language’s built-in operators. For type hints on classes and protocols see Types. For operator dispatch through dunders see Operators.
Class basics#
A class is a callable factory for instances. __init__ is the
constructor; methods take self explicitly as the first
argument.
class Person:
def __init__(self, name: str, age: int) -> None:
self.name = name
self.age = age
def __repr__(self) -> str:
return f"Person({self.name!r}, {self.age})"
def greet(self) -> str:
return f"hello, {self.name}"
op = Person("Alice", 30)
op.greet() # 'hello, Alice'
Three method flavours.
Flavour |
Behaviour |
|---|---|
Instance method |
First arg is |
Class method |
Decorated with |
Static method |
Decorated with |
class Date:
def __init__(self, y, m, d): self.y, self.m, self.d = y, m, d
@classmethod
def from_iso(cls, s: str) -> "Date":
y, m, d = map(int, s.split("-"))
return cls(y, m, d) # cls = Date here
@staticmethod
def days_in_month(y: int, m: int) -> int:
return 31 if m in {1,3,5,7,8,10,12} else 30 # simplified
today = Date.from_iso("2026-05-17")
Inheritance and MRO#
Single and multiple inheritance both work. super() walks the
Method Resolution Order (the C3 linearization of the class
graph), which makes cooperative multiple inheritance possible.
Inspect with Cls.__mro__.
class Employee(Person):
def __init__(self, name: str, age: int, title: str) -> None:
super().__init__(name, age)
self.title = title
def greet(self) -> str:
return f"{super().greet()} ({self.title})"
class Manager(Employee):
def greet(self) -> str:
return super().greet() + " — manager"
Manager.__mro__
# (Manager, Employee, Person, object)
Mixins compose narrow behaviour onto a target class.
class JSONMixin:
def to_json(self) -> str:
import json
return json.dumps(self.__dict__)
class Event(JSONMixin):
def __init__(self, name, ts):
self.name, self.ts = name, ts
Event("login", 1715000000).to_json()
Multiple inheritance is supported but is rarely the right answer for new code. Prefer composition or protocols.
Properties#
@property turns a method into a read-only attribute; the
setter is opt-in. Use it when validation, lazy computation, or
backward-compatible attribute promotion is needed.
class Account:
def __init__(self, balance: int) -> None:
self._balance = balance
@property
def balance(self) -> int:
return self._balance
@balance.setter
def balance(self, value: int) -> None:
if value < 0:
raise ValueError("negative balance")
self._balance = value
@balance.deleter
def balance(self) -> None:
self._balance = 0
For pure caching of a computed attribute, @functools.cached_property
caches the first call’s result on the instance.
from functools import cached_property
class Report:
def __init__(self, rows): self.rows = rows
@cached_property
def totals(self):
return sum(r.value for r in self.rows) # computed once
Dataclasses#
For plain data containers, @dataclass writes __init__,
__repr__, and __eq__ for the operator. frozen=True
makes instances hashable and immutable. slots=True swaps the
__dict__ for a tuple-shaped layout (smaller, faster, no
late-bound attributes).
from dataclasses import dataclass, field
@dataclass(frozen=True, slots=True)
class Point:
x: float
y: float
tags: list[str] = field(default_factory=list)
p = Point(1.0, 2.0)
p == Point(1.0, 2.0) # True
{p} # works because frozen + hashable
Use field(default_factory=...) for mutable defaults. Use
dataclasses.asdict() and dataclasses.replace() for
conversion and copy-with-changes.
For schema-validated data (config, API payloads, MCP tool arguments) reach for Pydantic or attrs instead of bare dataclasses.
Special methods#
The dunder methods that hook custom types into language built-ins.
Dunder |
Hooks into |
|---|---|
|
Construction |
|
Allocation (rare; metaclasses, immutables) |
|
Finalization (avoid; prefer context managers) |
|
|
|
|
|
Ordering operators, |
|
Truthiness (fallback: |
|
|
|
|
|
|
|
|
|
Attribute access fallback |
|
|
|
|
|
Arithmetic operators |
|
Augmented assignment ( |
|
|
__eq__ and __hash__ come as a pair. Override one, override
the other, or the class loses hashability. Dataclasses do this
correctly by default; hand-rolled classes need both.
Operator overloading#
Every operator in Operators dispatches through a dunder method. Implementing the right dunder makes the operator’s custom class behave like a built-in in expressions.
Operator |
Dunder |
Reflected (right-hand) form |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
n/a |
|
|
(Python falls back to the other side if NotImplemented) |
|
|
n/a (reflection swaps the operands) |
|
|
n/a |
|
|
n/a |
|
|
n/a |
Augmented assignment (a += b) tries __iadd__ first (in-
place); if absent it falls back to __add__ and rebinds the
name. Mutable types implement __iadd__ for performance;
immutable types skip it.
Worked example. A 2-D vector that supports +, -, scalar
* (in either order), unary -, ==, and abs().
from dataclasses import dataclass
from math import hypot
@dataclass(frozen=True)
class Vec2:
x: float
y: float
def __add__(self, other: "Vec2") -> "Vec2":
return Vec2(self.x + other.x, self.y + other.y)
def __sub__(self, other: "Vec2") -> "Vec2":
return Vec2(self.x - other.x, self.y - other.y)
def __mul__(self, k: float) -> "Vec2":
return Vec2(self.x * k, self.y * k)
__rmul__ = __mul__ # 3 * Vec2(1, 2) works too
def __neg__(self) -> "Vec2":
return Vec2(-self.x, -self.y)
def __abs__(self) -> float:
return hypot(self.x, self.y)
Vec2(1, 2) + Vec2(3, 4) # Vec2(4, 6)
2 * Vec2(1, 2) # Vec2(2, 4)
abs(Vec2(3, 4)) # 5.0
Return NotImplemented (the singleton, not the
NotImplementedError exception) when the dunder cannot handle
the other operand. Python then tries the reflected dunder on the
other side and ultimately raises TypeError if neither side
knows what to do. This is how mixed-type arithmetic stays open.
class Money:
def __init__(self, amount, currency):
self.amount, self.currency = amount, currency
def __add__(self, other):
if not isinstance(other, Money) or self.currency != other.currency:
return NotImplemented # let Python try the other side
return Money(self.amount + other.amount, self.currency)
Overloading is powerful and dangerous. Use it for value-like types (vectors, money, matrices, sets) where the operator’s intuition matches the math. Avoid clever overloads that change operator semantics; the reader will not expect them.
Protocols#
Static-duck-typing without isinstance. A class doesn’t have
to inherit from a protocol to satisfy it; it just needs the
right methods.
from typing import Protocol
class Closeable(Protocol):
def close(self) -> None: ...
def cleanup(things: list[Closeable]) -> None:
for t in things:
t.close()
For the deeper typing surface (Generic, TypeVar,
Annotated, runtime_checkable), see Types.
ABCs and Mixins#
abc.ABC + @abstractmethod forces subclasses to implement
named methods.
from abc import ABC, abstractmethod
class Sink(ABC):
@abstractmethod
def write(self, data: bytes) -> int: ...
class TcpSink(Sink):
def write(self, data: bytes) -> int:
return self.sock.send(data)
Sink() # TypeError: cannot instantiate abstract class
The standard ABCs in collections.abc define the iteration,
container, hashable, callable, and mapping protocols Python’s
built-ins rely on. Subclassing them gets the operator free
methods.
GoF patterns#
The Gang-of-Four catalog, with the Python-idiomatic version of each. Many class-heavy Java patterns collapse to a single function or module in Python.
Singleton#
Java needs a class with a private constructor. Python has
modules, which are loaded once and cached in sys.modules.
# config.py
_settings = load_settings() # runs once on first import
def get(key):
return _settings[key]
# elsewhere
from config import get
db_url = get("DB_URL")
If a class instance is genuinely required, a class-level
attribute plus __new__ is the idiom; usually a module is the
better answer.
Factory#
A function that returns instances. Often a classmethod when the caller wants different construction modes.
class Target:
def __init__(self, host, port, scheme):
self.host, self.port, self.scheme = host, port, scheme
@classmethod
def from_url(cls, url):
parsed = urlparse(url)
return cls(parsed.hostname, parsed.port or 80, parsed.scheme)
@classmethod
def from_dict(cls, d):
return cls(d["host"], d["port"], d.get("scheme", "https"))
Strategy#
In Python, the “strategy” is a function. Pass it directly; there is no need for a Strategy interface class.
def hash_file(path, *, algorithm=hashlib.sha256):
h = algorithm()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(65536), b""):
h.update(chunk)
return h.hexdigest()
Swap the algorithm at the call site.
hash_file("payload.bin", algorithm=hashlib.blake2b)
Observer#
A registry of callbacks; the subject notifies all observers when state changes. A list of callables is the minimal implementation.
class Channel:
def __init__(self):
self._observers = []
def subscribe(self, fn):
self._observers.append(fn)
def publish(self, event):
for fn in list(self._observers):
fn(event)
Usage.
chan = Channel()
chan.subscribe(lambda e: print("got", e))
chan.publish("hello")
For pub/sub at scale, reach for a broker (Redis, NATS, RabbitMQ) rather than building this out.
Adapter#
Wraps an object so it presents a different interface. In Python, duck typing usually removes the need; reach for an adapter when the operator cannot change either side.
class SocketLikeFile:
"""Adapt a socket to the file-object protocol."""
def __init__(self, sock):
self.sock = sock
def read(self, n):
return self.sock.recv(n)
def write(self, b):
return self.sock.send(b)
def close(self):
self.sock.close()
Facade#
One simple object hides a more complex subsystem. Often a module acts as the facade; sometimes a class is needed when state must be carried.
class Operator:
def __init__(self):
self._http = httpx.Client()
self._db = sqlite3.connect("ops.db")
def scan(self, host):
data = self._http.get(f"https://{host}/").text
self._db.execute("INSERT INTO scans VALUES (?, ?)", (host, data))
self._db.commit()
def close(self):
self._http.close()
self._db.close()