Why classes exist, explained with a real problem
Most explanations of classes start with animals. A Dog inherits from Animal
and overrides speak(). It is memorable, it demonstrates the syntax, and it
teaches you nothing about when to reach for a class — because nobody has ever
needed a Dog class at work.
This lesson starts from a problem instead.
The problem
You are tracking expenses. Using what you know from module 4:
expenses = [
{"amount": 250.0, "category": "food", "note": "lunch"},
{"amount": 1200.0, "category": "transport", "note": "train"},
]
That is fine. Then requirements arrive.
Amounts must never be negative. So you check on the way in:
if amount < 0:
raise ValueError("Amount cannot be negative")
expenses.append({"amount": amount, ...})
Except there are now three places that add an expense — the form, the CSV import, the API — and the check has to exist in all three. It exists in two.
Every expense needs GST added. A function:
def with_gst(expense):
return expense["amount"] * 1.18
Fine, until somebody writes expense["amount"] * 1.18 inline instead because
they did not know the function existed.
Category must be one of a fixed set. Another check, in the same three places.
Expenses print in a standard format. Another function.
Now the real problem. Nothing connects these. The dictionary does not know its
own rules. Any code anywhere can write expense["amount"] = -500 and nothing
stops it. A typo — expense["catagory"] — creates a new key rather than an
error. And a reader has no way to know what keys an expense is supposed to have
without finding somewhere it is created.
The data and the rules about the data live apart, and only convention keeps them together. Convention fails as soon as the codebase has more than one person or more than a few months of history.
The fix
A class puts them together:
class Expense:
VALID_CATEGORIES = {"food", "transport", "rent", "other"}
def __init__(self, amount: float, category: str, note: str = ""):
if amount < 0:
raise ValueError(f"Amount cannot be negative, got {amount}")
if category not in self.VALID_CATEGORIES:
raise ValueError(f"Unknown category: {category!r}")
self.amount = amount
self.category = category
self.note = note
def with_gst(self) -> float:
"""Return the amount including 18% GST."""
return round(self.amount * 1.18, 2)
def describe(self) -> str:
return f"{self.category}: ₹{self.amount:,.2f} ({self.note})"
lunch = Expense(250, "food", "lunch")
print(lunch.with_gst())
print(lunch.describe())
Expense(-50, "food")
295.0
food: ₹250.00 (lunch)
ValueError: Amount cannot be negative, got -50
What changed:
There is one place that creates an expense, so the validation cannot be skipped. Not "should not" — cannot.
The behaviour travels with the data. lunch.with_gst() is available
wherever the expense is, so nobody reimplements it.
The valid categories are stated in the class, next to the check that uses them.
An invalid expense cannot exist. Anything of type Expense has been through
the constructor.
That last point is the real prize, and it is worth saying plainly: the value of a class is that it makes some wrong states impossible rather than merely discouraged.
What a class actually is
A class is a template. An object (or instance) is a thing made from it.
lunch = Expense(250, "food", "lunch")
train = Expense(1200, "transport", "train")
One class, two objects, each with its own data. lunch.amount is 250 and
train.amount is 1200, and they do not interfere.
You have been using objects all along. A string is an object — "hello".upper()
is a method call on it. A list is an object; .append() is its method. Path
from module 7 is a class, and Path("data") / "x.csv" creates objects. This
module is about writing your own.
When to reach for one
Use a class when data and behaviour genuinely belong together, and especially when there are rules the data must obey.
Good signs:
- You keep passing the same group of values into function after function
- You have functions that only make sense for one kind of data
- There are invariants — things that must always be true
- You need several independent instances, each with their own state
Bad signs, meaning you probably want something simpler:
- The class has one method and no state. That is a function.
- Everything is
get_xandset_xwith no logic. That is a dictionary. - You created it because "this is how you write proper code".
The last lesson of this module goes through the bad signs in detail, because over-using classes is a more common problem in practice than under-using them.
What about dictionaries
Often a dictionary is still right. A dictionary is ideal for data that arrives from outside — JSON from an API, a CSV row — where the shape is not yours to control and there are no rules to enforce.
The switch happens when you find yourself writing functions that all take the same dictionary as their first argument:
def with_gst(expense): ...
def describe(expense): ...
def validate(expense): ...
Three functions whose first parameter is always the same thing is a class asking
to be written. self is exactly that repeated first parameter, given a name.
Check your work
Where the dictionary version can be broken. Any code anywhere can write
expense["amount"] = -500. A typo like expense["catagory"] creates a new key
rather than raising. Nothing states what keys an expense should have. And the
validation must be repeated at every place an expense is created.
Class or dictionary.
| Answer | Why | |
|---|---|---|
| API response | dictionary | shape is not yours, no rules to enforce |
| Bank account | class | an invariant — the balance must not go negative |
| Config file | dictionary | data without rules |
| Shopping cart | class | state plus operations that must agree |
| CSV row | dictionary | external data, no rules |
| Timer | class | state with a lifecycle — started, stopped |
Three functions sharing a first argument is a class asking to be written.
self is precisely that repeated first parameter, given a name.
Bank account invariants. The balance is never negative; every deposit and withdrawal is positive; the balance equals the opening balance plus every transaction. Those invariants are the argument for a class — they make certain wrong states impossible rather than merely discouraged.
Practice
No code yet. Thinking first.
- Take the expense example and write down every place a rule could be broken using the dictionary version.
- For each of these, decide class or dictionary, and say why in one sentence: a user profile from an API response · a bank account with a balance that must never go negative · a configuration file's contents · a shopping cart · a row from a CSV file · a timer that can be started and stopped.
- Look at a program you wrote earlier in this course. Find any group of functions that all take the same first argument. That is a candidate.
- Write down three things that must always be true about a bank account. Those are its invariants, and they are the argument for making it a class.
Next: writing one, and the two pieces of syntax that confuse everybody at first.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship