15. Classes, properties, and dataclasses
15.1 A class from first principles
class Account:
"""A bank account. Balances are integer cents (never floats for money)."""
bank = "Frontend Bank" # class attribute: shared by all instances
def __init__(self, owner: str, cents: int = 0) -> None:
self.owner = owner # instance attributes: per object
self._cents = cents # leading underscore: internal
def deposit(self, cents: int) -> None:
if cents <= 0:
raise ValueError("deposit must be positive")
self._cents += cents
@property
def balance(self) -> int: # read like an attribute, computed by a method
return self._cents
def __repr__(self) -> str: # what the REPL and debuggers show
return f"Account({self.owner!r}, cents={self._cents})"
acct = Account("ada")
acct.deposit(1500)
print(acct, acct.balance, acct.bank)
acct.balance = 10
# AttributeError: property 'balance' of 'Account' object has no setter
selfis the instance, passed explicitly.acct.deposit(1500)isAccount.deposit(acct, 1500).- Instance attributes live in a per-object dict (
acct.__dict__) unless you use__slots__(Ch 26). @propertylets you start with a plain attribute and later add validation without changing callers. Do not write Java-styleget_balance()methods.
@classmethod receives the class (use it for alternative constructors);
@staticmethod receives nothing (a plain function namespaced in the class):
from datetime import date
class Release:
def __init__(self, version: str, day: date) -> None:
self.version, self.day = version, day
@classmethod
def parse(cls, text: str) -> "Release": # Release.parse("3.14.0 2025-10-07")
v, d = text.split()
return cls(v, date.fromisoformat(d))
@staticmethod
def is_valid(version: str) -> bool:
return all(p.isdigit() for p in version.split("."))
r = Release.parse("3.14.0 2025-10-07")
print(r.version, r.day, Release.is_valid("3.14.x"))
15.2 Dataclasses: classes for data, without the boilerplate
Most classes you write hold data. @dataclass generates __init__,
__repr__, and __eq__ from annotated fields:
from dataclasses import dataclass, field, asdict, replace
@dataclass(frozen=True, slots=True)
class TrainConfig:
model: str = "tiny-lm"
lr: float = 3e-4
batch_size: int = 32
layers: tuple[int, ...] = (256, 256)
tags: frozenset[str] = field(default_factory=frozenset)
base = TrainConfig()
fast = replace(base, lr=1e-3, tags=frozenset({"sweep"})) # a modified copy
print(fast)
print(asdict(fast)["lr"], base == TrainConfig())
base.lr = 0.1
# dataclasses.FrozenInstanceError: cannot assign to field 'lr'
frozen=Truemakes instances immutable (and hashable): the fix for the war story in Ch 2.7.slots=Truestores fields in fixed slots instead of a dict: less memory, faster attribute access, and typos likecfg.lerning_rate = 1raise instead of silently creating a new attribute.- Mutable defaults must use
field(default_factory=list);dataclassrefusestags: list = [], which protects you from Ch 2.6. __post_init__runs after the generated__init__, for validation.
When to use what:
| You need | Use |
|---|---|
| a quick immutable record, tuple-compatible | typing.NamedTuple |
| a record with defaults, methods, maybe frozen | @dataclass |
| validation and parsing of untrusted input (JSON, env, API bodies) | pydantic BaseModel (Ch 44, 61) |
| a JSON-shaped dict with known keys, for type checking only | typing.TypedDict |
15.3 Enums
from enum import Enum, StrEnum, auto
class Level(StrEnum):
DEBUG = auto() # "debug"
INFO = auto()
ERROR = auto()
class State(Enum):
PENDING = 1
RUNNING = 2
DONE = 3
print(Level("error") is Level.ERROR, Level.INFO == "info", State.RUNNING.name)
Use enums instead of magic strings for states and options. A typo in
Level.EROR is an AttributeError at the line, and type checkers catch it
before you run.