Classes, methods, and dunder methods
Attributes vs class attributes, properties, __repr__ vs __str__, and the dunder methods that make objects behave.
Why this matters
Object-oriented programming in Python is practical, not religious. You'll write classes when state and behaviour belong together โ domain models, services, custom containers. Master the dunder ("double-underscore") methods and your classes integrate seamlessly with print, len, +, ==, in, slicing, iteration, JSON, and more.
Learning objectives
- Define classes with attributes, methods, and properties.
- Distinguish instance, class, and static methods.
- Implement key dunder methods.
- Control attribute access with
__slots__,property, descriptors (peek). - Use
__init_subclass__and class-level configuration.
1. Anatomy of a class
class Point:
"""A 2-D point."""
def __init__(self, x: float, y: float) -> None:
self.x = x
self.y = y
def distance_to(self, other: "Point") -> float:
return ((self.x - other.x) ** 2 + (self.y - other.y) ** 2) ** 0.5
def __repr__(self) -> str:
return f"Point(x={self.x}, y={self.y})"
p = Point(1.0, 2.0)
q = Point(4.0, 6.0)
p.distance_to(q) # 5.0
print(p) # Point(x=1.0, y=2.0)__init__is the initialiser (called after construction).selfis the convention โ the first arg to instance methods.- Type hints encouraged on every method.
Two-stage construction: __new__ then __init__
99% of the time you only override __init__. __new__ constructs the object โ useful for singletons, immutable types, metaclasses (Phase 3.4).
2. Instance, class, static methods
class Counter:
instances = 0 # class attribute (shared)
def __init__(self) -> None:
Counter.instances += 1
self.value = 0 # instance attribute
def increment(self) -> None: # instance method
self.value += 1
@classmethod
def total_created(cls) -> int: # class method
return cls.instances
@staticmethod
def is_valid(x: int) -> bool: # plain function in the class namespace
return x >= 0| Method | First arg | Calls |
|---|---|---|
| Instance method | self | obj.method() |
@classmethod | cls | Cls.method() or obj.method() |
@staticmethod | none | Cls.method() or obj.method() |
Class methods are the canonical alternate constructors:
class User:
def __init__(self, name: str, age: int) -> None:
self.name = name
self.age = age
@classmethod
def from_dict(cls, d: dict) -> "User":
return cls(d["name"], d["age"])
@classmethod
def from_json(cls, raw: str) -> "User":
return cls.from_dict(json.loads(raw))from_dict works for subclasses too (cls is the actual class โ polymorphic).
3. Dunder methods you'll write all the time
class Vector:
def __init__(self, *coords: float) -> None:
self.coords = tuple(coords)
# Representation
def __repr__(self) -> str:
return f"Vector{self.coords!r}"
def __str__(self) -> str:
return f"<{', '.join(map(str, self.coords))}>"
# Equality & hashing
def __eq__(self, other: object) -> bool:
if not isinstance(other, Vector):
return NotImplemented
return self.coords == other.coords
def __hash__(self) -> int:
return hash(self.coords)
# Ordering โ total_ordering provides the rest
def __lt__(self, other: "Vector") -> bool:
return self.norm() < other.norm()
# Container-like
def __len__(self) -> int:
return len(self.coords)
def __getitem__(self, i: int) -> float:
return self.coords[i]
def __iter__(self):
return iter(self.coords)
def __contains__(self, x: float) -> bool:
return x in self.coords
# Arithmetic
def __add__(self, other: "Vector") -> "Vector":
return Vector(*(a + b for a, b in zip(self.coords, other.coords, strict=True)))
def __mul__(self, k: float) -> "Vector":
return Vector(*(a * k for a in self.coords))
__rmul__ = __mul__ # so `3 * v` works
# Truthiness
def __bool__(self) -> bool:
return any(self.coords)
# Helper
def norm(self) -> float:
return sum(a * a for a in self.coords) ** 0.5Important rules
__repr__should be unambiguous and ideally re-evaluable. Always implement it.__str__is for end-users; falls back to__repr__if missing.- If you override
__eq__, you must override__hash__(or set it toNoneto mark unhashable). - Returning
NotImplementedfrom comparison methods lets Python try the reflected operation on the other operand.
functools.total_ordering
Implement __eq__ + one comparison; this decorator fills the rest:
from functools import total_ordering
@total_ordering
class Version:
def __init__(self, s: str): self.parts = tuple(map(int, s.split(".")))
def __eq__(self, o): return self.parts == o.parts
def __lt__(self, o): return self.parts < o.parts4. Properties โ computed attributes
Avoid Java-style getters/setters. Use @property to expose a computed attribute that behaves like a field:
class Circle:
def __init__(self, radius: float) -> None:
self.radius = radius
@property
def area(self) -> float:
return 3.14159 * self.radius ** 2
c = Circle(5)
c.area # 78.5... (no parens)Read-only by default. To allow writes:
class Celsius:
def __init__(self, c: float) -> None:
self._c = c
@property
def fahrenheit(self) -> float:
return self._c * 9 / 5 + 32
@fahrenheit.setter
def fahrenheit(self, f: float) -> None:
self._c = (f - 32) * 5 / 9
@fahrenheit.deleter
def fahrenheit(self) -> None:
del self._cUse properties when:
- A field needs validation on assignment.
- A read computes from other fields (don't store; recompute).
- You want to keep the API field-shaped while changing internals.
For one-shot expensive computation, use functools.cached_property (recap):
from functools import cached_property
class Image:
@cached_property
def histogram(self) -> dict[int, int]:
return self._compute() # called once; cached on instance5. Attribute lookup and __slots__
By default, every instance has a __dict__ storing attributes. Flexible but uses memory.
class Point:
__slots__ = ("x", "y") # explicit fields; no __dict__
def __init__(self, x, y):
self.x = x; self.y = y
p = Point(1, 2)
p.z = 3 # AttributeErrorWhen to use __slots__:
- You have millions of instances (saves ~50% memory).
- You want to forbid arbitrary attribute assignment (poor man's typing).
Costs:
- No
__dict__/__weakref__unless you include them in slots. - Subclassing is fiddly.
- Doesn't compose with all features (e.g., some descriptors).
For everyday code, don't use __slots__. Use it when profiling tells you instance overhead matters.
__getattr__, __setattr__, __getattribute__
class Lazy:
def __getattr__(self, name): # called only if normal lookup FAILED
return f"computed-{name}"
l = Lazy()
l.x # "computed-x"class Audited:
def __setattr__(self, name, value):
print(f"set {name}={value}")
super().__setattr__(name, value)__getattribute__ runs on every attribute access โ overriding it is fragile; do it rarely (often via descriptors instead).
6. Class-level configuration: __init_subclass__
Auto-register subclasses, validate them, or apply defaults:
class Plugin:
registry: dict[str, type["Plugin"]] = {}
def __init_subclass__(cls, *, name: str, **kwargs) -> None:
super().__init_subclass__(**kwargs)
cls.registry[name] = cls
class CSVPlugin(Plugin, name="csv"):
...
print(Plugin.registry) # {'csv': <class CSVPlugin>}Lighter-weight than a metaclass (Phase 3.4) for most "do something at subclass creation" use cases.
7. Truthiness, equality, hashability โ the contract
class Money:
def __init__(self, cents: int) -> None:
self.cents = cents
def __eq__(self, other: object) -> bool:
return isinstance(other, Money) and self.cents == other.cents
def __hash__(self) -> int:
return hash(("Money", self.cents))
def __bool__(self) -> bool:
return self.cents != 0Contracts:
a == bโhash(a) == hash(b). Never make a hashable object whose hash depends on mutable state.a == ashould be True.__lt__etc. should be consistent (transitive, antisymmetric).
If you don't need hashing, set __hash__ = None to make instances unhashable on purpose (e.g., mutable types).
8. Worked example: a small Money value object
from __future__ import annotations
from typing import Self
class Money:
__slots__ = ("amount", "currency")
def __init__(self, amount: int, currency: str = "USD") -> None:
if amount < 0:
raise ValueError("amount must be non-negative")
self.amount = amount
self.currency = currency
def __repr__(self) -> str:
return f"Money({self.amount}, {self.currency!r})"
def __eq__(self, other: object) -> bool:
return (
isinstance(other, Money)
and self.amount == other.amount
and self.currency == other.currency
)
def __hash__(self) -> int:
return hash((self.amount, self.currency))
def __add__(self, other: Self) -> Self:
if self.currency != other.currency:
raise ValueError("currency mismatch")
return type(self)(self.amount + other.amount, self.currency)
def __mul__(self, k: int) -> Self:
return type(self)(self.amount * k, self.currency)
__rmul__ = __mul__
def __bool__(self) -> bool:
return self.amount > 0In Phase 2.3 we'll see how @dataclass(frozen=True, slots=True) writes most of this for you. But knowing the manual version is mandatory.
9. When NOT to write a class
Python is not Java. Don't make a class just because you have a noun. Heuristics for "should this be a class?":
- Yes: multiple methods share state; you need polymorphism; you need an identity (e.g.,
User); you'd otherwise pass the same 3+ args around. - No: it has only one method besides
__init__โ that's just a function. - No: you want a record with named fields โ use
@dataclassorNamedTuple(Phase 2.3). - No: it has no methods โ use
@dataclassor even adict/TypedDict.
The "two methods, one of which is __init__" anti-pattern is the dead giveaway of over-engineering.
Hands-on lab (2 hours)
- Build a
Path2Dclass with+,-,len(), indexing, and pretty__repr__. - Add comparison: paths order by total length. Use
@total_ordering. - Add a
@classmethod from_csv(cls, path)alternate constructor. - Add
__slots__; verify memory drop withsys.getsizeofon a million instances. - Wrap an attribute with
@propertyto validate it (e.g.,radiusmust be positive). - Use
cached_propertyfor an expensive computed attribute. - Implement a small plugin system via
__init_subclass__. - Bonus: implement
__getstate__/__setstate__so the class pickles cleanly with__slots__.
Common pitfalls
- Mutable class attribute used as instance default (
tags = []shared across instances). - Forgetting
super().__init__(...)in subclasses. - Implementing
__eq__without__hash__. __repr__returning user-friendly text (use__str__for that).- Using getters/setters everywhere; use
@propertywhen needed and direct access otherwise. - Putting business logic in
__init__(slow construction; hard to test).
Self-check
- When use
@classmethodvs@staticmethod? - Why must
__eq__and__hash__be consistent? - What does
NotImplementedmean in a comparison method? - Difference between
__repr__and__str__? - When does
__slots__help?
References
- Fluent Python, Ramalho โ Chapters 5, 9, 12, 14.
- Python Cookbook, Beazley & Jones โ Chapter 8.
- Python data model: https://docs.python.org/3/reference/datamodel.html.
- PEP 487 โ Simpler customisation of class creation (
__init_subclass__).
Sign in to save your progress and earn badges.