Classes in depth
Properties, alternative constructors, abstract base classes and tuned dataclasses.
Create a free account Try part of it here
Why it matters
The classes lesson showed how to bundle data with behavior. Real codebases lean on a handful of further tools: properties that keep data valid, constructors that build objects from files and APIs, base classes that define what every subclass must do, and dataclasses tuned for speed and safety. You'll meet all of them in libraries like pandas, SQLAlchemy and FastAPI, and you'll write them yourself as soon as a project has more than a few classes.
What you'll be able to do
- Compute and validate attributes with @property, so objects can't get into a bad state
- Add alternative constructors with @classmethod, and helpers with @staticmethod
- Define a contract with an abstract base class that subclasses must fill in
- Use dataclass options well: field defaults, __post_init__, frozen and slots, and know how method lookup order works
What it covers
- Properties: attributes with rules
- Other ways to build an object
- Abstract base classes: a contract
- Dataclasses, tuned
- A validated property
- Which method runs? Method resolution order
- Where you'll meet these
- Worked example: accounts with rules
Properties: attributes with rules
A plain attribute accepts anything: account.balance = -500 and the object is now wrong. A property looks like an attribute from outside, but runs a method when it's read or set:
class GiftCard: def __init__(self, code, balance): self.code = code self.balance = balance # goes through the setter below @property def balance(self): return self._balance @balance.setter def balance(self, value): if value < 0: raise ValueError(f"balance can't be negative, got {value}") self._balance = round(value, 2) @property def is_empty(self): return self._balance == 0 card = GiftCard("TRAIL-25", 25) card.balance -= 7.5 print(card.balance, card.is_empty) try: card.balance = -1 except ValueError as e: print("refused:", e)
@property turns balance into a method that runs on card.balance. @balance.setter runs on assignment, including the one inside __init__, so even a brand-new card can't start negative. The real value lives in _balance; the leading underscore is the convention for "internal, don't touch". is_empty has no setter, so it's read-only and always up to date: a computed attribute.
Start with plain attributes. Add a property later, when you need a rule, and no code that uses the class has to change.
Step through and notice that every read of card.balance runs the getter, and every assignment, even the one inside __init__, runs the setter.
Other ways to build an object
__init__ takes the arguments the object needs. But data often arrives in other shapes: a dictionary from JSON, a line from a CSV file. A class method is a second constructor. It receives the class itself as cls and returns a new object:
from datetime import date class Member: def __init__(self, name, joined, points=0): self.name, self.joined, self.points = name, joined, points @classmethod def from_dict(cls, data): return cls(data["name"], date.fromisoformat(data["joined"]), data.get("points", 0)) @classmethod def from_csv_line(cls, line): name, joined, points = line.strip().split(",") return cls(name, date.fromisoformat(joined), int(points)) @staticmethod def valid_code(code): return len(code) == 8 and code.isalnum() def __repr__(self): return f"Member({self.name!r}, joined {self.joined}, {self.points} pts)" print(Member.from_dict({"name": "Ada", "joined": "2024-05-02", "points": 340})) print(Member.from_csv_line("Kwame,2023-11-19,85\n")) print(Member.valid_code("AB12CD34"), Member.valid_code("nope"))
Using cls(...) instead of Member(...) matters for subclasses: Pro.from_dict(...) then builds a Pro. A static method is a plain function that lives in the class because it belongs with it; it gets neither the object nor the class. You've used class methods already: date.fromisoformat and dict.fromkeys are two.
class Temp: def __init__(self, c): self.c = c @property def f(self): return self.c * 9 / 5 + 32 t = Temp(20) t.c = 30 print(t.f)
Show the answer
86.0
A property is computed each time it's read, so it follows the new value of c: 30 × 9/5 + 32 = 86.
Abstract base classes: a contract
Sometimes a base class exists only to say "every subclass must have these methods". An abstract base class (ABC) makes that a rule Python enforces:
from abc import ABC, abstractmethod class Shipping(ABC): @abstractmethod def cost(self, weight_kg): """What this method charges, in dollars.""" def quote(self, weight_kg): return f"{type(self).__name__}: ${self.cost(weight_kg):.2f}" class Standard(Shipping): def cost(self, weight_kg): return 6.0 if weight_kg < 5 else 6.0 + 1.5 * (weight_kg - 5) class Express(Shipping): def cost(self, weight_kg): return 15.0 + 2.0 * weight_kg for method in [Standard(), Express()]: print(method.quote(8)) class Pigeon(Shipping): pass try: Pigeon() except TypeError as e: print("refused:", e)
Shipping can't be created itself, and neither can a subclass that forgets cost: the error comes when you create the object, not later in front of a customer. Shared behavior like quote still lives in the base class and calls the subclass's cost. Code that receives "some kind of shipping" can rely on both methods being there.
Dataclasses, tuned
The types lesson introduced @dataclass. A few options make dataclasses safer and faster:
from dataclasses import dataclass, field @dataclass(frozen=True, slots=True) class Sku: code: str def __post_init__(self): if len(self.code) != 6 or self.code[2] != "-": raise ValueError(f"not a SKU: {self.code!r}") @dataclass class Cart: owner: str items: dict = field(default_factory=dict) tags: list = field(default_factory=list) def add(self, sku, qty=1): self.items[sku] = self.items.get(sku, 0) + qty a, b = Cart("ada"), Cart("ben") a.add(Sku("TE-101")) print(a.items, b.items) print(Sku("TE-101") == Sku("TE-101"), hash(Sku("TE-101")) == hash(Sku("TE-101"))) try: Sku("tent") except ValueError as e: print("refused:", e)
field(default_factory=dict)gives each cart its own dictionary. Writingitems: dict = {}is refused, because every cart would share one dictionary: the shared-list bug from the inheritance lesson.__post_init__runs right after the generated__init__: the place for validation.frozen=Truemakes objects read-only after creation, so they can be dictionary keys and set members. Assigningsku.code = "x"raises an error.slots=Truestores fields in fixed slots instead of a per-object dictionary: less memory per object, and a typo likecart.ownr = ...fails instead of silently adding an attribute.
A validated property
Finish Thermostat: target must be a property whose setter refuses values outside 10 to 30 (inclusive) with a ValueError, and fahrenheit must be a read-only property giving the target in °F.
class Thermostat: def __init__(self, target): self.target = target t = Thermostat(21) print(t.target, t.fahrenheit)
Which method runs? Method resolution order
With several layers of inheritance, Python needs a rule for where to look first. It builds the class's method resolution order (MRO): the class itself, then its parents left to right, each before its own parents. super() means "the next class in that order", not simply "my parent":
class Base: def describe(self): return ["base"] class Waterproof(Base): def describe(self): return ["waterproof"] + super().describe() class Insulated(Base): def describe(self): return ["insulated"] + super().describe() class WinterBoot(Waterproof, Insulated): def describe(self): return ["winter boot"] + super().describe() print(WinterBoot().describe()) print([c.__name__ for c in WinterBoot.__mro__])
Each super().describe() passes the call along the MRO, so Base runs exactly once, at the end. Small, focused classes combined like this are called mixins. Use them sparingly. When a hierarchy needs a diagram to explain, giving an object another object to use (composition) is usually clearer than inheriting from both.
Where you'll meet these
These tools aren't academic. They're how the libraries you'll use every day are built:
- Properties are everywhere in data libraries. A pandas DataFrame's
shape,columnsandT(its transpose) are properties: they look like stored values but are worked out when you ask. Django and SQLAlchemy models use them for computed fields like a full name built from two columns. - Class methods as constructors are the standard way to offer several ways in:
datetime.fromisoformat,pd.DataFrame.from_records,dict.fromkeys. Whenever data can arrive in more than one shape, give each shape its ownfrom_...method rather than one__init__full ofifstatements. - Abstract base classes define plug-in points. The standard library's
collections.abchas ABCs likeMappingandIterable, and frameworks use them for "write a subclass that fills in these methods": a storage backend, a payment provider, a model in a training framework. - Frozen dataclasses suit values that shouldn't change once made: a product code, a money amount, a configuration. Immutable objects are easier to reason about, safe to share, and can be dictionary keys.
The common thread is putting rules in one place. A rule in a property or __post_init__ holds for every object, however it was created. A rule repeated at every call site is one that someone will eventually forget.
Worked example: accounts with rules
Put the tools together: an abstract account with a validated balance, a constructor from saved data, and two kinds of account with different fees.
from abc import ABC, abstractmethod class Account(ABC): def __init__(self, owner, balance=0.0): self.owner = owner self.balance = balance @property def balance(self): return self._balance @balance.setter def balance(self, value): if value < 0: raise ValueError("balance can't go below zero") self._balance = round(value, 2) @abstractmethod def monthly_fee(self): ... def charge_fee(self): self.balance -= min(self.monthly_fee(), self.balance) @classmethod def from_dict(cls, data): return cls(data["owner"], data["balance"]) class Basic(Account): def monthly_fee(self): return 2.0 class Club(Account): def monthly_fee(self): return 0.0 if self.balance >= 100 else 1.0 accounts = [Basic.from_dict({"owner": "Ada", "balance": 50}), Club.from_dict({"owner": "Ben", "balance": 120}), Club.from_dict({"owner": "Cy", "balance": 0.5})] for acct in accounts: acct.charge_fee() print(type(acct).__name__, acct.owner, acct.balance)
Account holds what every account shares: the validated balance property, charge_fee and the from_dict constructor. Each subclass supplies only monthly_fee. Club.from_dict returns a Club because from_dict uses cls. charge_fee never takes more than the balance, so Cy's account ends at zero instead of raising: a business rule written once, in one place.
Practice what you just read
- 6 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
Why write cls(...) rather than Member(...) inside a classmethod constructor?