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 self. Operates on one instance. The default.

Class method

Decorated with @classmethod. First arg is cls; receives the class, not an instance. Alternative constructors live here.

Static method

Decorated with @staticmethod. Receives neither self nor cls. A plain function namespaced under the class.

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

__init__

Construction

__new__

Allocation (rare; metaclasses, immutables)

__del__

Finalization (avoid; prefer context managers)

__repr__ / __str__

repr() / str() / print

__eq__ / __hash__

==, set / dict keys

__lt__ / __le__ / __gt__ / __ge__

Ordering operators, sorted, min / max

__bool__

Truthiness (fallback: __len__)

__len__

len(), truthiness fallback

__iter__ / __next__

for x in obj, generators

__contains__

in / not in

__getitem__ / __setitem__ / __delitem__

obj[k] / obj[k] = v / del obj[k]

__getattr__ / __setattr__ / __delattr__

Attribute access fallback

__call__

obj(...) (callables)

__enter__ / __exit__

with obj: (context managers; see Errors)

__add__ / __sub__ / __mul__ / __matmul__ / …

Arithmetic operators

__iadd__ / __isub__ / …

Augmented assignment (+=, etc.)

__class_getitem__

MyType[int] generic-subscript

__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

a + b

__add__

__radd__ (when left has no __add__ for this type)

a - b

__sub__

__rsub__

a * b

__mul__

__rmul__

a / b

__truediv__

__rtruediv__

a // b

__floordiv__

__rfloordiv__

a % b

__mod__

__rmod__

a ** b

__pow__

__rpow__

a @ b

__matmul__

__rmatmul__ (NumPy, JAX use this)

a & b / a | b / a ^ b

__and__ / __or__ / __xor__

__rand__ / __ror__ / __rxor__

a << b / a >> b

__lshift__ / __rshift__

__rlshift__ / __rrshift__

-a / +a / ~a / abs(a)

__neg__ / __pos__ / __invert__ / __abs__

n/a

a == b / a != b

__eq__ / __ne__

(Python falls back to the other side if NotImplemented)

a < b / <= / > / >=

__lt__ / __le__ / __gt__ / __ge__

n/a (reflection swaps the operands)

a in b

__contains__

n/a

a[k] / a[k] = v / del a[k]

__getitem__ / __setitem__ / __delitem__

n/a

a(*args)

__call__

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()

References#

  • Operators for the operator dispatch through dunder methods.

  • Functions for the function model methods extend.

  • Types for Protocol, Generic, TypeVar, and the gradual typing strategy.

  • Errors for __enter__ / __exit__ context managers.

  • Libraries for dataclasses, functools, abc, collections.abc.