Python Classes and Data Model


  • Description: class syntax, instance/class/static methods, inheritance and MRO, dunder methods (__init__, __repr__, __eq__, __hash__, etc.), @property, @dataclass, __slots__, and abstract base classes
  • My Notion Note ID: K2A-D1-10
  • Created: 2022-08-22
  • Updated: 2026-05-11
  • License: Reuse is very welcome. Please credit Yu Zhang and link back to the original on yuzhang.io

Table of Contents


1. Class Definition

class 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

p = Point(3, 4)
p.distance_to(Point(0, 0))     # 5.0
  • No public/private keywords, convention only (_leading_underscore)
  • No attribute declarations, attributes spring into existence on first assignment to self

2. self, Instance vs Class Attributes

  • self is the explicit first parameter of every instance method (C++ this, made visible)
  • inst.method(x) is sugar for Class.method(inst, x)
class Counter:
    count = 0            # CLASS attribute, shared across all instances

    def __init__(self) -> None:
        self.value = 0   # INSTANCE attribute

    def bump(self) -> None:
        self.value += 1
        Counter.count += 1   # mutate the class attribute
  • Trap: self.count = 1 shadows the class attribute with an instance attribute; class attribute unchanged

3. @staticmethod, @classmethod

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 supports subclassing

    @staticmethod
    def is_leap(y: int) -> bool:
        return y % 4 == 0 and (y % 100 != 0 or y % 400 == 0)

Date.from_iso("2026-05-11")
Date.is_leap(2024)
  • @classmethod, class as first arg; used for alternative constructors, class-level state
  • @staticmethod, no implicit first arg; namespaced free function

4. Inheritance, super(), and MRO

class Animal:
    def __init__(self, name): self.name = name
    def speak(self): return "..."

class Dog(Animal):
    def __init__(self, name, breed):
        super().__init__(name)        # forward to parent
        self.breed = breed
    def speak(self): return "Woof"
  • super() walks the Method Resolution Order (MRO), computed by C3 linearization
  • Single inheritance: it's just the parent. Multiple inheritance: the merged linear chain
class A: ...
class B(A): ...
class C(A): ...
class D(B, C): ...
D.__mro__   # (D, B, C, A, object)
  • Resolves the diamond problem by default, C++ requires virtual inheritance for the same effect

5. The Data Model (Dunder Methods)

  • The "data model" is the contract between user classes and the language
  • Dunder methods (__name__) hook into operators, built-ins, and protocols

5.1 __init__, __new__, __del__

  • __init__(self, ...), initializer (NOT constructor). The object already exists; this fills it in.
  • __new__(cls, ...), allocator. Rarely overridden; needed for immutable types and metaclass tricks.
  • __del__(self), finalizer. Timing is not guaranteed (especially with cycles); don't rely on it for cleanup, use with.

5.2 __repr__ vs __str__

  • __repr__, unambiguous; ideally eval()-able. Used by repr(x) and the REPL.
  • __str__, readable, user-facing. Used by str(x) and print(). Defaults to __repr__ if missing.
class Point:
    def __init__(self, x, y): self.x, self.y = x, y
    def __repr__(self): return f"Point({self.x!r}, {self.y!r})"
    def __str__(self):  return f"({self.x}, {self.y})"
  • Rule: always define __repr__; define __str__ only when the user-facing form differs

5.3 __eq__ and __hash__

class Currency:
    def __init__(self, code, amount):
        self.code, self.amount = code, amount

    def __eq__(self, other):
        if not isinstance(other, Currency): return NotImplemented
        return self.code == other.code and self.amount == other.amount

    def __hash__(self):
        return hash((self.code, self.amount))

Rules:

  • Define __eq__ → Python sets __hash__ = None (unhashable); re-define __hash__ to make instances hashable
  • a == b must imply hash(a) == hash(b)
  • Hash must be immutable for the object's lifetime, never hash mutable state

5.4 Ordering and total_ordering

  • Define __lt__, __le__, __gt__, __ge__ to enable ordering
  • @functools.total_ordering fills the rest from __eq__ + __lt__
from functools import total_ordering

@total_ordering
class Version:
    def __init__(self, major, minor): self.major, self.minor = major, minor
    def __eq__(self, o): return (self.major, self.minor) == (o.major, o.minor)
    def __lt__(self, o): return (self.major, self.minor) <  (o.major, o.minor)

5.5 Container Protocols

Dunder Enables
__len__ len(x)
__getitem__ x[i], slicing, iteration fallback (with __len__)
__setitem__ x[i] = v
__delitem__ del x[i]
__contains__ v in x
__iter__ / __next__ for v in x
__call__ x(...), instances become callable
  • A class with __getitem__/__setitem__/__delitem__/__len__/__iter__ quacks like a sequence
  • Add keys → quacks like a mapping
  • No formal interface, duck typing is the rule

6. @property

  • Turns a method into an attribute-like accessor, no ()
class Circle:
    def __init__(self, r): self._r = r

    @property
    def radius(self) -> float:
        return self._r

    @radius.setter
    def radius(self, r: float) -> None:
        if r < 0: raise ValueError("negative radius")
        self._r = r

    @property
    def area(self) -> float:
        return 3.14159 * self._r ** 2

c = Circle(5)
c.radius         # 5   , no parentheses
c.radius = 10    # invokes setter
c.area           # 314.159
  • Unlike C++ getter/setter methods, you can rewrite a public attribute as a property later without breaking callers
  • Start public; add @property only when validation or computation is needed

7. @dataclass

  • PEP 557 (3.7+), generates __init__, __repr__, __eq__ from type-annotated class attributes
from dataclasses import dataclass, field

@dataclass(frozen=True, slots=True)     # slots since 3.10
class Point:
    x: float
    y: float
    label: str = ""

@dataclass
class Inventory:
    items: list[str] = field(default_factory=list)   # avoid the mutable-default trap

Key options:

  • frozen=True, immutable, hashable

  • slots=True, use __slots__, no __dict__

  • kw_only=True, force keyword args

  • order=True, generate ordering

  • For richer needs (validation, serialization): pydantic or attrs


8. __slots__

  • By default each instance has a __dict__
  • __slots__ replaces it with a fixed array, saves memory and prevents typo-attributes
class Point:
    __slots__ = ("x", "y")
    def __init__(self, x, y): self.x, self.y = x, y

p = Point(1, 2)
p.z = 3       # AttributeError, z is not in __slots__
  • Use it on classes instantiated by the millions, or to lock attribute names
  • Subclassing a slotted class without re-declaring __slots__ brings __dict__ back

9. Abstract Base Classes

  • abc.ABC + @abstractmethod enforce that subclasses implement specific methods
from abc import ABC, abstractmethod

class Storage(ABC):
    @abstractmethod
    def read(self, key: str) -> bytes: ...

    @abstractmethod
    def write(self, key: str, data: bytes) -> None: ...

class MemoryStorage(Storage):
    def __init__(self): self._d = {}
    def read(self, key): return self._d[key]
    def write(self, key, data): self._d[key] = data

Storage()           # TypeError: can't instantiate abstract class
  • For typing without enforced inheritance, Protocol (structural typing) is usually a better fit