Classes, methods, and dunder methods

Attributes vs class attributes, properties, __repr__ vs __str__, and the dunder methods that make objects behave.

๐Ÿงฑ Module 2 9 min read Not started

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

  1. Define classes with attributes, methods, and properties.
  2. Distinguish instance, class, and static methods.
  3. Implement key dunder methods.
  4. Control attribute access with __slots__, property, descriptors (peek).
  5. Use __init_subclass__ and class-level configuration.

1. Anatomy of a class

python
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).
  • self is 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

python
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
MethodFirst argCalls
Instance methodselfobj.method()
@classmethodclsCls.method() or obj.method()
@staticmethodnoneCls.method() or obj.method()

Class methods are the canonical alternate constructors:

python
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

python
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.5

Important 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 to None to mark unhashable).
  • Returning NotImplemented from comparison methods lets Python try the reflected operation on the other operand.

functools.total_ordering

Implement __eq__ + one comparison; this decorator fills the rest:

python
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.parts

4. Properties โ€” computed attributes

Avoid Java-style getters/setters. Use @property to expose a computed attribute that behaves like a field:

python
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:

python
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._c

Use 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):

python
from functools import cached_property

class Image:
    @cached_property
    def histogram(self) -> dict[int, int]:
        return self._compute()         # called once; cached on instance

5. Attribute lookup and __slots__

By default, every instance has a __dict__ storing attributes. Flexible but uses memory.

python
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                                 # AttributeError

When 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__

python
class Lazy:
    def __getattr__(self, name):       # called only if normal lookup FAILED
        return f"computed-{name}"

l = Lazy()
l.x                                     # "computed-x"
python
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:

python
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

python
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 != 0

Contracts:

  • a == b โ‡’ hash(a) == hash(b). Never make a hashable object whose hash depends on mutable state.
  • a == a should 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

python
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 > 0

In 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 @dataclass or NamedTuple (Phase 2.3).
  • No: it has no methods โ€” use @dataclass or even a dict / TypedDict.

The "two methods, one of which is __init__" anti-pattern is the dead giveaway of over-engineering.


Hands-on lab (2 hours)

  1. Build a Path2D class with +, -, len(), indexing, and pretty __repr__.
  2. Add comparison: paths order by total length. Use @total_ordering.
  3. Add a @classmethod from_csv(cls, path) alternate constructor.
  4. Add __slots__; verify memory drop with sys.getsizeof on a million instances.
  5. Wrap an attribute with @property to validate it (e.g., radius must be positive).
  6. Use cached_property for an expensive computed attribute.
  7. Implement a small plugin system via __init_subclass__.
  8. Bonus: implement __getstate__ / __setstate__ so the class pickles cleanly with __slots__.

Common pitfalls

  1. Mutable class attribute used as instance default (tags = [] shared across instances).
  2. Forgetting super().__init__(...) in subclasses.
  3. Implementing __eq__ without __hash__.
  4. __repr__ returning user-friendly text (use __str__ for that).
  5. Using getters/setters everywhere; use @property when needed and direct access otherwise.
  6. Putting business logic in __init__ (slow construction; hard to test).

Self-check

  1. When use @classmethod vs @staticmethod?
  2. Why must __eq__ and __hash__ be consistent?
  3. What does NotImplemented mean in a comparison method?
  4. Difference between __repr__ and __str__?
  5. 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.