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.
Defining a class
Section titled “Defining a class”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.
Instance vs class attributes
Section titled “Instance vs class attributes”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 ita.species = "mine" # creates an INSTANCE attribute, shadowing the class oneDog.species # => 'changed' — the class attribute is untouchedLookup order for obj.attr: instance __dict__, then the class, then base classes along the MRO.
The three kinds of method
Section titled “The three kinds of method”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@property
Section titled “@property”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 parenthesesc.radius = 5 # runs the setterc.radius = -1 # ValueErrorc.area = 10 # AttributeError: property 'area' of 'Circle' object has no setterfunctools.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 freeInheritance and super()
Section titled “Inheritance and super()”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) # => Trueissubclass(Dog, Animal) # => Truetype(d) is Animal # => False — exact type onlyThe MRO
Section titled “The MRO”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 methods
Section titled “Dunder methods”“Dunder” = double underscore. These hook classes into language syntax. Implement the ones your type genuinely supports.
Representation
Section titled “Representation”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 usersrepr() 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__Equality and hashing
Section titled “Equality and hashing”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.
Sizing, containment, iteration
Section titled “Sizing, containment, iteration”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.
Operators, calling, ordering
Section titled “Operators, calling, ordering”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) # => 42callable(double) # => TrueReference 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 |
dataclasses
Section titled “dataclasses”Most classes are just typed bags of fields. @dataclass (Python 3.7+) generates __init__, __repr__, and __eq__ from annotations.
from dataclasses import dataclass, field
@dataclassclass 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) # => TrueOptions 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-onlyHelpers:
from dataclasses import asdict, astuple, replace, fields
asdict(p) # => {'name': 'Widget', ...} — recursivereplace(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__:
@dataclassclass Rectangle: width: float height: float area: float = field(init=False)
def __post_init__(self): self.area = self.width * self.heightChoosing 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.
Encapsulation conventions
Section titled “Encapsulation conventions”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 # AttributeErrora._A__secret # => 1Double 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.
Key points
Section titled “Key points”__init__initialises an already-created object;selfis explicit.- Class attributes are shared — never let one be mutable.
@classmethodfor alternative constructors,@staticmethodfor related helpers.@propertyturns 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__. @dataclassremoves the boilerplate — usefield(default_factory=...)for mutables.- Privacy is a convention:
_internal, with__mangledreserved for collision avoidance.