A class defines a new type: its data (attributes) and behaviour (methods). __init__ sets up a new object; self refers to the object itself. A leading underscore (_balance) signals "internal — don't touch from outside".
@property exposes a computed or read-only value like an attribute. Special methods such as __repr__ and __eq__ control how objects print and compare.
For classes that are mostly data, @dataclass writes __init__, __repr__ and __eq__ for you. frozen=True makes instances immutable, and field(default_factory=list) gives each object its own list.
from dataclasses import dataclass, field
class Account:
def __init__(self, owner: str) -> None:
self.owner = owner
self._balance = 0
def deposit(self, amount: int) -> None:
if amount <= 0:
raise ValueError("deposit must be positive")
self._balance += amount
def withdraw(self, amount: int) -> None:
if amount > self._balance:
raise ValueError(f"insufficient funds: short by {amount - self._balance}")
self._balance -= amount
@property
def balance(self) -> int:
return self._balance
def __repr__(self) -> str:
return f"Account(owner={self.owner!r}, balance={self._balance})"
@dataclass
class Student:
name: str
form: int
scores: list[int] = field(default_factory=list)
@property
def average(self) -> float:
return sum(self.scores) / len(self.scores) if self.scores else 0.0
@dataclass(frozen=True)
class Subject:
code: str
name: str
acc = Account("Amina")
acc.deposit(50_000)
acc.withdraw(20_000)
print(acc, acc.balance)
s = Student("Neema", 4)
s.scores += [71, 84]
print(s, f"average={s.average:.1f}")
print(Subject("BIO", "Biology") == Subject("BIO", "Biology")) # TrueKey points
- Keep internal state behind methods and
@property. - Use
@dataclassfor data-holding classes — less code, fewer bugs. - Mutable defaults need
field(default_factory=...).
Exercise
Build a Library class that manages Book dataclasses (isbn, title, author, available). Add borrow(isbn), give_back(isbn) and available_books(), raising a custom error when a book is already borrowed.
Show solution
Try the exercise yourself first — then compare your approach with this one.
Book is a dataclass because it is mostly data. Library keeps books in a dictionary keyed by ISBN and raises a custom error when a book is already out, so callers can respond to that case specifically.
from dataclasses import dataclass
class BookAlreadyBorrowedError(Exception):
pass
@dataclass
class Book:
isbn: str
title: str
author: str
available: bool = True
class Library:
def __init__(self) -> None:
self._books: dict[str, Book] = {}
def add(self, book: Book) -> None:
self._books[book.isbn] = book
def borrow(self, isbn: str) -> Book:
book = self._books[isbn] # KeyError for an unknown ISBN
if not book.available:
raise BookAlreadyBorrowedError(f"{book.title!r} is already borrowed")
book.available = False
return book
def give_back(self, isbn: str) -> None:
self._books[isbn].available = True
def available_books(self) -> list[str]:
return [b.title for b in self._books.values() if b.available]
library = Library()
library.add(Book("978-1", "Things Fall Apart", "Chinua Achebe"))
library.add(Book("978-2", "Kinjeketile", "Ebrahim Hussein"))
library.borrow("978-1")
print(library.available_books()) # ['Kinjeketile']
try:
library.borrow("978-1")
except BookAlreadyBorrowedError as err:
print("Sorry:", err)
library.give_back("978-1")
print(library.available_books()) # ['Things Fall Apart', 'Kinjeketile']