BeginnerClasses and data modeling~35 minPython

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

Pythonclasses_and_data_modeling.py
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()
Output
['Dune', 'Foundation']
Borrowed Dune: True
['Foundation']
Borrowed Dune again: False
Book(title='Dune', author='Frank Herbert', available=False)
True

Library 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 @dataclass when the class is really just a labeled bundle of fields — most of that boilerplate is exactly what @dataclass exists to remove.

Check your understanding

from dataclasses import dataclass @dataclass class Point: x: int y: int p1 = Point(1, 2) p2 = Point(1, 2) print(p1 == p2, p1 is p2)
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.

A Library class holds a list of Book objects as an attribute. Is this composition or inheritance?
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.

  1. Hint 1

    @dataclass generates a __repr__ automatically from the fields you declared, even though you never wrote one yourself.

  2. Hint 2

    An ordinary class with no @dataclass and no hand-written __repr__ falls back to the default object representation.

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

return_book("Foundation") is called, but "Foundation" was never borrowed (its available is already True). What should return_book return?
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.
  • @dataclass generates __init__, __repr__, and __eq__ from a class’s type-annotated fields, and refuses a bare mutable default (= []) outright — use field(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 @dataclass fixes for you automatically.