Classes and Data Modeling
Decide when a class earns its place over a function or dict, use @dataclass to remove constructor boilerplate, prefer composition over inheritance, and let dunder methods participate in printing and equality.
By the end of this lesson, you can
- Decide whether a class, a function, or a plain dict best fits a given piece of data and behavior
- Use @dataclass to generate __init__, __repr__, and __eq__ instead of writing them by hand
- Explain why composition is usually preferred over inheritance for an unrelated is-a relationship
- Describe what __repr__ and __eq__ actually do, and what happens without them
Why it matters
A class is the right tool exactly when a program needs to bundle related data with the behavior
that acts on it, and needs more than one instance of that bundle at once. Reaching for one before
that point adds ceremony a function or a dict would have handled more simply; reaching for one
too late means related data and behavior end up scattered across loose variables and free
functions instead of living together.
Mental model
Ask two questions before writing a class: does this need to hold state (data that persists and
can change), and does more than one instance of it exist at once, each with its own state? A
single one-off calculation is a function. A handful of related values passed around together, read
but never changed, might be a plain dict or, better, a lightweight @dataclass.
A class earns its place when instances need their own independent state and behavior that acts
on it — a Library that tracks which books are currently borrowed is a good example; a function
that just computes a late fee from two numbers is not.
Composition — one class holding instances of another as an attribute — is the default relationship between classes. Inheritance (one class is a more specific version of another) is a stronger, more constraining relationship, worth reaching for only when that’s genuinely true, not just as a way to reuse some methods.
Worked example: a small lending library
from dataclasses import dataclass, field
@dataclass
class Book:
title: str
author: str
available: bool = True
@dataclass
class Library:
books: list[Book] = field(default_factory=list)
def add(self, book: Book) -> None:
self.books.append(book)
def borrow(self, title: str) -> bool:
for book in self.books:
if book.title == title and book.available:
book.available = False
return True
return False
def available_titles(self) -> list[str]:
return [book.title for book in self.books if book.available]
def main() -> None:
library = Library()
library.add(Book(title="Dune", author="Frank Herbert"))
library.add(Book(title="Foundation", author="Isaac Asimov"))
print(library.available_titles())
borrowed = library.borrow("Dune")
print(f"Borrowed Dune: {borrowed}")
print(library.available_titles())
already_gone = library.borrow("Dune")
print(f"Borrowed Dune again: {already_gone}")
book = Book(title="Dune", author="Frank Herbert", available=False)
print(book)
print(book == library.books[0])
if __name__ == "__main__":
main()['Dune', 'Foundation']
Borrowed Dune: True
['Foundation']
Borrowed Dune again: False
Book(title='Dune', author='Frank Herbert', available=False)
TrueLibrary has a list of Book objects — composition, not inheritance; there’s no is-a
relationship between a library and a book. books: list[Book] = field(default_factory=list)
sidesteps the mutable default argument trap the
same way a function parameter would need None and a manual check — @dataclass actually
refuses a bare [] default outright, raising an error at class-definition time rather than
letting the bug happen silently.
The last two lines show @dataclass’s generated __repr__ and __eq__
in action: print(book) shows a genuinely readable value instead of a memory address, and book == library.books[0] is True even though they’re two separate objects — the generated __eq__
compares field values, not identity.
Common mistakes
- Writing a class with only one method and no persisting state — that’s usually a function; a class earns its place when there’s actual state to hold across multiple method calls.
- Reaching for inheritance to reuse a couple of methods between two classes that aren’t genuinely in an is-a relationship — composition (holding an instance, delegating to it) is more flexible and doesn’t force an artificial hierarchy.
- Writing
__eq__without__repr__(or vice versa) by hand instead of reaching for@dataclasswhen the class is really just a labeled bundle of fields — most of that boilerplate is exactly what@dataclassexists to remove.
Check your understanding
Or reveal the answer without checking
Answer:True False
@dataclass generates __eq__ that compares field values, so p1 == p2 is True (same x and y). p1 and p2 are still two separate objects created by two separate calls, so p1 is p2 is False.
Or reveal the answer without checking
Answer:Composition — Library has a list of Books, it is not a kind of Book
Composition means one class holds an instance (or, here, a collection) of another as an attribute. Library is not a more specific kind of Book — there's no is-a relationship — so this is composition, not inheritance.
Practice: warm-up
Predict, then check: what does print(Book(title="Dune", author="Frank Herbert")) show, without a
custom __repr__ written by hand — and what would it show for an ordinary class (no
@dataclass) with the same fields, instead?
Stuck? Reveal one hint at a time.
Hint 1
@dataclass generates a __repr__ automatically from the fields you declared, even though you never wrote one yourself.
Hint 2
An ordinary class with no @dataclass and no hand-written __repr__ falls back to the default object representation.
Hint 3
@dataclass's Book prints as Book(title='Dune', author='Frank Herbert', available=True) — genuinely readable. An ordinary class instance would print something like <__main__.Book object at 0x...> instead — a memory address, not useful for debugging.
Reveal the answer
Try the problem yourself before reading this. There is often more than one reasonable approach — treat this as one worked example, not the only correct answer.
from dataclasses import dataclass
@dataclass
class Book:
title: str
author: str
class PlainBook:
def __init__(self, title, author):
self.title = title
self.author = author
print(Book("Dune", "Frank Herbert"))
print(PlainBook("Dune", "Frank Herbert"))Book(title='Dune', author='Frank Herbert')
<__main__.PlainBook object at 0x...>@dataclass generates __repr__ for you; a plain class falls back to the default, which is
technically correct but useless for actually reading what’s inside.
Practice: apply it
Add a return_book(title: str) -> bool method to Library, the reverse of borrow — it should
set available back to True and return whether a matching, currently-unavailable book was
found. Predict its return value for a title that was never borrowed in the first place.
Or reveal the answer without checking
Answer:False — there was nothing to return, since it was never borrowed
Mirroring borrow's own logic (which only matches an available book), return_book should only match a book that is currently unavailable — a book that was never borrowed has nothing to return, so False correctly reports that no matching, borrowed book was found.
Summary
- A class earns its place when there’s real state to hold across multiple method calls and more than one independent instance of it; a single computation is usually just a function.
@dataclassgenerates__init__,__repr__, and__eq__from a class’s type-annotated fields, and refuses a bare mutable default (= []) outright — usefield(default_factory=list)instead.- Composition (holding another class’s instance as an attribute) is the default relationship between classes; reach for inheritance only for a genuine, stable is-a relationship.
__repr__controls how an object prints for debugging;__eq__controls what==checks — without them, printing falls back to a memory address and==falls back to identity, which@dataclassfixes for you automatically.