Index Ai Academy
Phase 7 · Software engineering · Python in depth

Classes in depth

Properties, alternative constructors, abstract base classes and tuned dataclasses.

About 40 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

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

  1. Properties: attributes with rules
  2. Other ways to build an object
  3. Abstract base classes: a contract
  4. Dataclasses, tuned
  5. A validated property
  6. Which method runs? Method resolution order
  7. Where you'll meet these
  8. 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)
It runs right here in your browser, with nothing to install.

@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.

Try it now

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.

Quick check
What does this print?
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)
  1. 86.0
  2. 68.0
  3. An AttributeError
  4. 30
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. Writing items: 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=True makes objects read-only after creation, so they can be dictionary keys and set members. Assigning sku.code = "x" raises an error.
  • slots=True stores fields in fixed slots instead of a per-object dictionary: less memory per object, and a typo like cart.ownr = ... fails instead of silently adding an attribute.
Drill

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)
In the lesson, your code is checked as you write it.

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, columns and T (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 own from_... method rather than one __init__ full of if statements.
  • Abstract base classes define plug-in points. The standard library's collections.abc has ABCs like Mapping and Iterable, 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

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)
In the lesson you can change this and run it.

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

Create a free account

Try a quiz question

Why write cls(...) rather than Member(...) inside a classmethod constructor?

  1. So a subclass calling it gets an object of the subclass
  2. Because Member isn't defined inside the class
  3. It's faster
  4. To make the object frozen

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson