Module 11: Object-Oriented Python and Maintainability
Classes and Dataclasses: When a Record Deserves a Type
What a class adds over a dictionary, why a dataclass is usually the right one for a record, and the mutable-default trap that follows you from functions into classes.
Lesson 34 of 46 in the recommended order · About 25 min (estimate)
On this page
Outcome
By the end of this lesson you can write a class that holds state and behaviour together, write a dataclass for a record, and say when a plain dictionary was already the right answer.
Why it matters
An opportunity has been a dictionary since Module 3, and dictionaries have carried it a long way. They have two costs that grow with a project. A misspelled key is not an error, it is a KeyError at some later point or, with get, a silent None. And nothing about a dictionary says which keys it is supposed to have.
A class fixes both by naming the shape. It also lets behaviour live next to the data it operates on, which is worth having when several functions all take the same record as their first argument.
The corresponding risk is over-application. A class with no behaviour and one instance is a dictionary with extra syntax, and the object-oriented style becomes actively harmful when it produces four layers of classes to do what three functions did.
Concept
A class defines a type. An instance is one value of it. __init__ runs when an instance is created and binds the initial state onto self, the instance itself, which is the first parameter of every method and is passed automatically.
class RuleSet:
def __init__(self, minimum, required_set_aside):
self.minimum = minimum
self.required_set_aside = required_set_aside
def qualifies(self, record):
return record["amount"] >= self.minimum
ruleset.qualifies(record) passes ruleset as self. That is the whole mechanism, and it is why a method that never mentions self is usually a function that has been put in the wrong place.
A dataclass is the right tool for a record. The decorator writes the boilerplate:
from dataclasses import dataclass
@dataclass
class Opportunity:
notice_id: str
amount: int
set_aside: str | None = None
That generates __init__, __repr__ (so printing shows the field values rather than a memory address), and __eq__ (so two instances built from the same values compare equal). The last one matters most in practice: it is what lets a test assert on a whole record in one line instead of field by field.
Type annotations are required syntax here, and are still not enforced at run time. They document the intended shape and a type checker can act on them.
Three rules carry over from functions. Fields with defaults must come after fields without them. A mutable default, a list or a dictionary, is the same shared-object trap as in a function signature, and a dataclass rejects it outright with a clear message; use field(default_factory=list) instead. And @dataclass(frozen=True) makes instances immutable, which is a good default for a record that represents something published elsewhere and should not be edited in place.
When is a dictionary still right? When the keys are genuinely dynamic, when you are handing data straight to something that wants JSON, and when the record crosses a boundary where a class would have to be converted anyway. Parse into a dataclass at the edge of your program, work with the type inside, and convert back at the other edge.
Read the code
from dataclasses import dataclass, field
@dataclass
class Opportunity:
notice_id: str
amount: int
set_aside: str | None = None
tags: list[str] = field(default_factory=list)
def qualifies(self, minimum: int) -> bool:
return self.amount >= minimum and self.set_aside is not None
first = Opportunity("A-1", 310000, "Total Small Business")
second = Opportunity("A-1", 310000, "Total Small Business")
third = Opportunity("A-2", 90000)
first.tags.append("oregon")
print(first == second)
print(first)
print(third.qualifies(100000), first.qualifies(100000))
print(second.tags)
Four fields: two required, one defaulted to None, and one list built fresh for every instance by default_factory.
first == second compares field by field, and they were created equal. Then first.tags.append("oregon") changes only first, because default_factory runs once per instance. Had the field been written tags: list = [], the dataclass decorator would refuse the class outright with ValueError: mutable default, which is a considerably better outcome than the silent sharing a plain function default produces.
qualifies reads self and takes one argument. It belongs on the class because it answers a question about this record using this record's own state.
Predict the output
Predict all four printed lines. The second one is the generated __repr__.
Check your prediction
False
Opportunity(notice_id='A-1', amount=310000, set_aside='Total Small Business', tags=['oregon'])
False True
[]
The first line is False, which catches people. The two instances were equal until first.tags.append("oregon") changed one of them, and __eq__ compares every field including tags.
The last line confirms it from the other side: second.tags is still empty, so the two lists are genuinely separate objects.
The __repr__ on line 2 is worth the decorator on its own. Printing a plain class without one shows something like <__main__.Opportunity object at 0x7f3c...>, which tells you nothing while debugging.
Modify the code
Add frozen=True to the decorator, making it @dataclass(frozen=True), and predict what happens.
What changes, and why
Creating instances still works, and first.tags.append("oregon") still succeeds, which is the surprising part.
frozen=True prevents rebinding a field: first.amount = 5 now raises FrozenInstanceError. It does not make the objects inside the fields immutable, and a list is mutable regardless of what holds it. So first.tags = [] is refused and first.tags.append(...) is allowed.
That distinction is worth holding on to. Freezing gives you a strong guarantee about the record's own fields and no guarantee at all about mutable objects it contains. A genuinely immutable record uses a tuple rather than a list for that field.
Debug the bug
An assistant was asked for a class that accumulates a shortlist. It produced this and said each instance keeps its own.
class Shortlist:
entries = []
def __init__(self, name):
self.name = name
def add(self, notice_id):
self.entries.append(notice_id)
oregon = Shortlist("Oregon")
washington = Shortlist("Washington")
oregon.add("A-1")
washington.add("A-2")
print(oregon.name, oregon.entries)
print(washington.name, washington.entries)
What's actually wrong
Both lines print the same list:
Oregon ['A-1', 'A-2']
Washington ['A-1', 'A-2']
entries = [] sits in the class body, not in __init__, so it is a class attribute: one list, created once when the class was defined, shared by every instance. self.entries.append(...) finds that one shared list through the instance and appends to it.
This is the mutable-default trap from Module 4 wearing different clothes, and it behaves identically: one object created once, shared by everything that did not bring its own.
The fix is to create the list per instance, in __init__:
class Shortlist:
def __init__(self, name):
self.name = name
self.entries = []
self.name was already correct, which is why the bug is easy to miss on a quick read: the class does have per-instance state, just not the piece that matters.
The rule to carry: a class-body assignment is shared by every instance. Constants belong there; anything mutable, or anything that differs per instance, belongs in __init__. A dataclass makes this harder to get wrong, because it refuses a mutable default and pushes you to default_factory.
Try it yourself
Declare the dataclass and its method. Two of the instances are built from identical values, so the equality check should hold without you writing any comparison code.
Loading this exercise…
Practical challenge (optional)
Optional: add a from_row classmethod that builds an Opportunity from one of the dictionaries used in Module 6, applying the normalisation rules from that module. Then write one sentence on why parsing at the boundary, into a type, is better than passing dictionaries all the way down. This pattern, untyped data at the edges and a known type inside, is the one the capstone uses.
Sign in to track your progress on this exercise.
AI collaboration
Checkpoint
- What three methods does
@dataclassgenerate, and which is most useful in tests? - Why does a value assigned in a class body get shared between instances?
- What does
frozen=Trueprevent, and what does it not prevent? - When is a plain dictionary still the better choice?
Answers
__init__,__repr__, and__eq__.__eq__is the one that matters most in tests, because it lets a whole record be asserted in a single comparison.- It is evaluated once when the class is defined and belongs to the class, not to any instance. Every instance that does not shadow it sees the same object.
- It prevents rebinding a field on an instance. It does not make objects stored in those fields immutable, so a list field can still be appended to.
- When keys are genuinely dynamic, when the data is going straight out as JSON, and at boundaries where a class would have to be converted anyway.
Sign in to track your progress on this exercise.
Summary and next step
A class names a shape and puts behaviour beside its data, a dataclass writes the boilerplate and gives you equality for free, class-body values are shared while __init__ values are not, and freezing protects fields rather than their contents. Next: building behaviour by combining small parts, and depending on what an object can do rather than on what it is.