Python / Reliable programs / Lesson 11
Classes, dataclasses, and ownership
Model meaningful state with explicit invariants, prefer composition, and understand what dataclass-generated behavior does and does not guarantee.
On this page
Lesson goals
- Objects combine state and behavior
- Value models and evolution
Objects combine state and behavior
A class defines a type whose instances can hold state and provide operations. An instance method receives the instance as its first argument, conventionally named self. __init__ initializes an already created instance; it should establish a valid starting state rather than return a replacement object. A class is useful when several operations must preserve the same invariant, such as an account balance never becoming negative. A plain function and dictionary may remain clearer for a one-off transformation. Introducing a class is a modeling decision, not a requirement that every group of related lines must become an object.
Invariants and composition
An invariant is a condition every public operation promises to preserve. Check preconditions before mutating state so a rejected request leaves the object unchanged. Composition gives an object collaborators or component values rather than inheriting unrelated behavior. Inheritance is appropriate when a subtype genuinely satisfies the parent’s behavioral promises, including failure conditions. Python’s underscore naming convention signals internal attributes but is not an access-control security mechanism. Keep the public surface small, document allowed transitions, and avoid exposing mutable internals when callers could bypass validation. A read-only property can present information without providing a public setter, but does not deeply freeze its returned object.
Validate before changing state
The first example keeps a balance in integer cents. deposit accepts only a nonnegative amount and validates before changing the balance. The public result is read through a property, while the internal attribute follows the underscore convention. This is a compact demonstration rather than a production banking system: it does not implement persistence, authorization, concurrency control, or full numeric type validation. Its value is that the invariant has a visible home. Test a rejected operation by checking both its exception and the unchanged balance. Otherwise an implementation might raise the expected error after already corrupting the state.
class Balance:
def __init__(self):
self._cents = 0
def deposit(self, amount):
if amount < 0:
raise ValueError("amount must be nonnegative")
self._cents += amount
@property
def cents(self):
return self._cents
balance = Balance()
balance.deposit(250)
print(balance.cents)
250
Validation precedes mutation and a property supplies the observable balance. The example uses a fixed valid integer amount.
Dataclasses remove routine code
The dataclass decorator can generate initialization, representation, and equality behavior from annotated fields. It does not enforce field types at runtime. Use field(default_factory=list) for a fresh mutable value per instance. frozen=True prevents ordinary field rebinding through generated behavior, but does not make a nested list immutable. Equality generated for a dataclass compares its fields according to dataclass rules, so choose field semantics deliberately when objects represent identities rather than interchangeable values. The second example models a shipment with independent label lists; it demonstrates ownership rather than assuming that concise declarations automatically produce correct domain behavior.
from dataclasses import dataclass, field
@dataclass
class Shipment:
code: str
labels: list[str] = field(default_factory=list)
first = Shipment("A")
second = Shipment("B")
first.labels.append("fragile")
print(first.labels)
print(second.labels)
['fragile']
[]
The factory is called for each new instance, so appending to the first label list leaves the second one empty.
Value models and evolution
A value object is defined mainly by its data, while an entity has an identity that can persist as its attributes change. That distinction affects equality, copying, and caching decisions. Keep parsing external dictionaries separate from constructing a validated domain object; a dataclass annotation alone cannot protect a boundary from malformed JSON. Use __post_init__ for appropriate initialization checks, while remembering that later mutation may require its own enforcement. Prefer a few explicit fields over an unrestricted bag of attributes when the shape is known. Tests should cover construction, valid transitions, rejected transitions, and independence between instances, since shared class-level mutable state can violate every object’s apparent ownership.
Common errors and fixes
Every instance shares the same list
- Why it happens
- Mutable state was stored on the class or in a shared default.
- How to fix it
- Initialize per-instance state or use a dataclass default factory.
- Prevent it next time
- Test that mutating one instance leaves another unchanged.
frozen=True still allows a nested list to change
- Why it happens
- Freezing field rebinding does not recursively freeze referenced objects.
- How to fix it
- Use immutable field values when deep value immutability is required.
- Prevent it next time
- Specify shallow versus deep immutability in the model.
A rejected operation changes the balance
- Why it happens
- Mutation occurs before validation raises the error.
- How to fix it
- Check preconditions first and mutate only after acceptance.
- Prevent it next time
- Assert unchanged state in rejection tests.
Check your understanding
1. Design an immutable point value with x and y integer fields. What does its annotation guarantee?
Show worked solution
Use @dataclass(frozen=True) with x: int and y: int. Generated equality and representation are useful for a value model, and ordinary field reassignment is rejected. Annotations guide tools but do not reject a string at runtime; validate at the boundary if actual integers are required.
2. What should a test inspect after a failed withdrawal?
Show worked solution
Check the documented exception and compare the balance with its exact prior value. Also verify a valid withdrawal separately. The failure contract includes preserving state, so checking only that an exception occurred can miss partial mutation before the error was raised.
Key takeaways
- Objects should own clear invariants.
- Dataclasses generate methods rather than runtime validation.
- Validate before mutation and test instance independence.