Skip to content

Object-Oriented Python

Python’s object model is small and consistent: a class is a factory for objects, methods are functions that take the instance as their first argument, and almost every language operator is a method call in disguise.

class Account:
"""A bank account."""
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance
def deposit(self, amount):
if amount <= 0:
raise ValueError("amount must be positive")
self.balance += amount
return self.balance
def __repr__(self):
return f"Account(owner={self.owner!r}, balance={self.balance!r})"
acc = Account("Ada", 100)
acc.deposit(50)
acc # => Account(owner='Ada', balance=150)

__init__ is the initialiser, not a constructor. By the time it runs the object already exists; __init__ only populates it. (The real constructor is __new__, which you almost never write — it matters mainly for immutable subclasses and metaclasses.)

self is the instance, passed explicitly. It is a convention, not a keyword, but never rename it. acc.deposit(50) is sugar for Account.deposit(acc, 50).

Attributes are created by assignment. There is no declaration list — whatever __init__ assigns is what the object has.

An attribute defined in the class body is shared by every instance. An attribute assigned on self belongs to that instance alone.

class Dog:
species = "Canis familiaris" # class attribute — one copy
def __init__(self, name):
self.name = name # instance attribute — one per object
a, b = Dog("Rex"), Dog("Fido")
a.species # => 'Canis familiaris'
Dog.species = "changed"
b.species # => 'changed' — all instances see it
a.species = "mine" # creates an INSTANCE attribute, shadowing the class one
Dog.species # => 'changed' — the class attribute is untouched

Lookup order for obj.attr: instance __dict__, then the class, then base classes along the MRO.

class Temperature:
unit = "C"
def __init__(self, celsius):
self.celsius = celsius
def to_fahrenheit(self): # instance method
return self.celsius * 9 / 5 + 32
@classmethod
def from_fahrenheit(cls, f): # gets the CLASS
return cls((f - 32) * 5 / 9)
@staticmethod
def is_valid(celsius): # gets nothing
return celsius >= -273.15
Kind First argument Use for
Instance method self Anything touching instance state
@classmethod cls Alternative constructors, class-level state
@staticmethod none A related helper that needs neither

The key advantage of classmethod is that cls is the actual class, so alternative constructors work correctly in subclasses:

class Kelvin(Temperature):
pass
Kelvin.from_fahrenheit(212) # => a Kelvin instance, not a Temperature

A property makes a method look like an attribute. Use it to add computation or validation without changing the public interface.

class Circle:
def __init__(self, radius):
self._radius = radius
@property
def radius(self):
return self._radius
@radius.setter
def radius(self, value):
if value <= 0:
raise ValueError("radius must be positive")
self._radius = value
@property
def area(self): # read-only: no setter
return 3.14159 * self._radius ** 2
c = Circle(2)
c.area # => 12.56636 — no parentheses
c.radius = 5 # runs the setter
c.radius = -1 # ValueError
c.area = 10 # AttributeError: property 'area' of 'Circle' object has no setter

functools.cached_property computes once per instance and stores the result in the instance __dict__:

from functools import cached_property
class Dataset:
@cached_property
def rows(self):
return expensive_load() # runs once; later accesses are free
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
raise NotImplementedError
def describe(self):
return f"{self.name} says {self.speak()}"
class Dog(Animal):
def __init__(self, name, breed):
super().__init__(name) # run the parent initialiser
self.breed = breed
def speak(self):
return "Woof"
Dog("Rex", "Corgi").describe() # => 'Rex says Woof'

super() (no arguments, Python 3) returns a proxy that dispatches to the next class in the MRO, which is not necessarily the direct parent. Always call it rather than naming the parent explicitly — hardcoding Animal.__init__(self, name) breaks multiple inheritance.

isinstance and issubclass respect the hierarchy:

isinstance(d, Animal) # => True
issubclass(Dog, Animal) # => True
type(d) is Animal # => False — exact type only

Python supports multiple inheritance and resolves the ambiguity with the method resolution order, computed by the C3 linearisation algorithm. It guarantees a class always precedes its parents, and that the order of base classes is preserved.

class A:
def hello(self): return "A"
class B(A):
def hello(self): return "B"
class C(A):
def hello(self): return "C"
class D(B, C):
pass
D().hello() # => 'B'
D.__mro__
# (D, B, C, A, object)

super() in B.hello would go to C, not A — the MRO is a property of the instance’s class, not of where the code was written. That is what makes cooperative multiple inheritance possible, and why every class in such a hierarchy must call super().

[cls.__name__ for cls in D.mro()] # => ['D', 'B', 'C', 'A', 'object']

Every class implicitly inherits from object, which supplies default __repr__, __eq__, __hash__, and the attribute machinery.

“Dunder” = double underscore. These hook classes into language syntax. Implement the ones your type genuinely supports.

class Point:
def __init__(self, x, y):
self.x, self.y = x, y
def __repr__(self):
return f"Point({self.x}, {self.y})" # unambiguous, for developers
def __str__(self):
return f"({self.x}, {self.y})" # readable, for users

repr() is what the REPL and containers show; str() is what print() and f-strings use. If you define only __repr__, str() falls back to it — so __repr__ is the one to write first. Aim for output that could be pasted back as code.

p = Point(1, 2)
p # => Point(1, 2) uses __repr__
print(p) # => (1, 2) uses __str__
[p] # => [Point(1, 2)] containers always use __repr__
f"{p}" # => '(1, 2)' f-strings use __str__
f"{p!r}" # => 'Point(1, 2)' !r forces __repr__
class Point:
def __init__(self, x, y):
self.x, self.y = x, y
def __eq__(self, other):
if not isinstance(other, Point):
return NotImplemented # let Python try the reflected operation
return (self.x, self.y) == (other.x, other.y)
def __hash__(self):
return hash((self.x, self.y))

Returning NotImplemented (not NotImplementedError) tells Python to try the other operand’s reflected method, and to fall back to identity comparison if that fails too.

class Deck:
def __init__(self, cards):
self._cards = list(cards)
def __len__(self):
return len(self._cards) # len(deck)
def __getitem__(self, index):
return self._cards[index] # deck[0], deck[1:3]
def __setitem__(self, index, value):
self._cards[index] = value
def __contains__(self, card):
return card in self._cards # card in deck
def __iter__(self):
return iter(self._cards) # for card in deck

__getitem__ alone is enough to make an object iterable and support in — Python falls back to indexing from 0 until IndexError. Defining __iter__ is clearer and faster.

class Vector:
def __init__(self, x, y):
self.x, self.y = x, y
def __add__(self, other): # self + other
return Vector(self.x + other.x, self.y + other.y)
def __mul__(self, k): # self * k
return Vector(self.x * k, self.y * k)
def __rmul__(self, k): # k * self (reflected)
return self * k
def __neg__(self): # -self
return Vector(-self.x, -self.y)
def __abs__(self):
return (self.x ** 2 + self.y ** 2) ** 0.5
def __bool__(self): # truthiness; falls back to __len__
return bool(self.x or self.y)

Comparison operators are __lt__, __le__, __gt__, __ge__. functools.total_ordering fills in the rest from __eq__ plus one of them.

__call__ makes an instance callable:

class Multiplier:
def __init__(self, factor):
self.factor = factor
def __call__(self, x):
return x * self.factor
double = Multiplier(2)
double(21) # => 42
callable(double) # => True

Reference table of the ones you will actually reach for:

Dunder Triggered by
__init__, __new__ Object creation
__repr__, __str__, __format__ repr(), str(), f"{x:spec}"
__eq__, __hash__, __lt__ ==, hash(), <
__len__, __bool__ len(), truthiness
__getitem__, __setitem__, __delitem__ x[k]
__contains__ in
__iter__, __next__ for, next()
__enter__, __exit__ with
__call__ x()
__getattr__, __setattr__ Attribute access
__add__, __mul__, __r*__, __i*__ Arithmetic operators

Most classes are just typed bags of fields. @dataclass (Python 3.7+) generates __init__, __repr__, and __eq__ from annotations.

from dataclasses import dataclass, field
@dataclass
class Product:
name: str
price: float
tags: list[str] = field(default_factory=list)
_cache: dict = field(default_factory=dict, repr=False, compare=False)
def with_discount(self, pct):
return Product(self.name, self.price * (1 - pct), list(self.tags))
p = Product("Widget", 9.99)
p # => Product(name='Widget', price=9.99, tags=[])
p == Product("Widget", 9.99) # => True

Options on the decorator:

@dataclass(frozen=True) # immutable; assignment raises, and __hash__ is generated
@dataclass(order=True) # generates __lt__, __le__, __gt__, __ge__ by field order
@dataclass(slots=True) # 3.10+: uses __slots__ — less memory, no new attributes
@dataclass(kw_only=True) # 3.10+: all fields become keyword-only

Helpers:

from dataclasses import asdict, astuple, replace, fields
asdict(p) # => {'name': 'Widget', ...} — recursive
replace(p, price=19.99) # a new instance with one field changed
[f.name for f in fields(p)]

Post-initialisation work goes in __post_init__:

@dataclass
class Rectangle:
width: float
height: float
area: float = field(init=False)
def __post_init__(self):
self.area = self.width * self.height

Choosing between them: NamedTuple for an immutable tuple-like record, frozen dataclass for an immutable object with behaviour, plain dataclass for a mutable one, a hand-written class when the initialiser does real work.

Python has no private. It has conventions, and one mild mechanism.

Form Meaning
name Public API
_name Internal. “Don’t touch” — enforced only by convention and linters
__name Name-mangled to _ClassName__name
name_ Avoids a keyword clash (class_, id_)
class A:
def __init__(self):
self.__secret = 1
a = A()
a.__secret # AttributeError
a._A__secret # => 1

Double underscore is not a privacy feature — it exists to stop subclasses accidentally overriding an attribute the base class relies on. Use single underscore for “internal” and reserve __ for real name-collision risk in a class designed for subclassing.

__slots__ fixes the attribute set, removing the per-instance __dict__:

class Point:
__slots__ = ("x", "y")
def __init__(self, x, y):
self.x, self.y = x, y
p = Point(1, 2)
p.z = 3 # AttributeError: 'Point' object has no attribute 'z'

Use it only when you have measured a memory problem with a very large number of instances; it blocks dynamic attributes and complicates multiple inheritance.

  • __init__ initialises an already-created object; self is explicit.
  • Class attributes are shared — never let one be mutable.
  • @classmethod for alternative constructors, @staticmethod for related helpers.
  • @property turns a method into an attribute; start with plain attributes and upgrade later.
  • Always call super().__init__(...); super() follows the MRO, not the literal parent.
  • Write __repr__ for every class you will debug; define __hash__ whenever you define __eq__.
  • @dataclass removes the boilerplate — use field(default_factory=...) for mutables.
  • Privacy is a convention: _internal, with __mangled reserved for collision avoidance.