# Classes & dataclasses

> Model data and behaviour with classes: __init__, methods, special methods, properties, dataclasses, enums, and when to choose composition.

Python roadmap · Foundations · 18 min read · https://levelcode.org/roadmaps/python/classes-and-dataclasses

Dictionaries are great for loose data, but they don't say what keys must exist, they can't validate themselves, and the functions that work on them are scattered around your code. **Classes** bundle data with the behaviour that belongs to it, under a name that means something in your domain: `Order`, `Invoice`, `Temperature`. This lesson covers writing classes, the special methods that make them feel built in, properties, and **dataclasses**, which remove most of the boilerplate. It ends with how to choose between a dict, a dataclass and a full class.

## A first class

A class is a blueprint; calling it creates an **instance**:

```python
class BankAccount:
    def __init__(self, owner: str, balance: float = 0.0) -> None:
        self.owner = owner
        self.balance = balance

    def deposit(self, amount: float) -> None:
        if amount <= 0:
            raise ValueError("deposit must be positive")
        self.balance += amount

    def withdraw(self, amount: float) -> None:
        if amount > self.balance:
            raise ValueError("insufficient funds")
        self.balance -= amount


acct = BankAccount("Ana", 100)
acct.deposit(50)
acct.withdraw(30)
print(acct.owner, acct.balance)     # Ana 120
```

- `__init__` runs when you create an instance and sets up its **attributes**.
- Every method's first parameter is the instance itself, called `self` by convention. `acct.deposit(50)` is really `BankAccount.deposit(acct, 50)`.
- Attributes are created by assigning to `self.something`. Each instance has its own.

The value of putting `withdraw` on the class is that the rule ("no overdrafts") lives in one place, next to the data it protects, instead of in every function that touches a balance.

## Special methods

Methods with double underscores, called **dunder methods**, hook your class into Python's built-in behaviour. Without them, printing an account gives `<__main__.BankAccount object at 0x10a3f2d50>` and two accounts with identical data aren't equal.

```python
class Money:
    def __init__(self, cents: int, currency: str = "EUR") -> None:
        self.cents = cents
        self.currency = currency

    def __repr__(self) -> str:                 # for developers: repr(), the REPL, debuggers
        return f"Money({self.cents}, {self.currency!r})"

    def __str__(self) -> str:                  # for users: print(), str(), f-strings
        return f"{self.cents / 100:.2f} {self.currency}"

    def __eq__(self, other: object) -> bool:   # ==
        if not isinstance(other, Money):
            return NotImplemented
        return (self.cents, self.currency) == (other.cents, other.currency)

    def __add__(self, other: "Money") -> "Money":   # +
        if self.currency != other.currency:
            raise ValueError("cannot add different currencies")
        return Money(self.cents + other.cents, self.currency)


price = Money(1999) + Money(500)
print(price)          # 24.99 EUR
print(repr(price))    # Money(2499, 'EUR')
print(price == Money(2499))   # True
```

Returning `NotImplemented` from `__eq__` for unknown types lets Python try the other operand's comparison, and ultimately answer `False`, rather than crashing. Other common dunders: `__len__` (`len(x)`), `__iter__` (`for ... in x`), `__contains__` (`in`), `__lt__` (`<`, sorting), `__hash__` (use as a dict key or set member).

## Properties: computed and protected attributes

A **property** looks like an attribute but runs a method. Use it for values derived from other attributes, and to validate on assignment:

```python
class Temperature:
    def __init__(self, celsius: float) -> None:
        self.celsius = celsius            # goes through the setter below

    @property
    def celsius(self) -> float:
        return self._celsius

    @celsius.setter
    def celsius(self, value: float) -> None:
        if value < -273.15:
            raise ValueError("below absolute zero")
        self._celsius = value

    @property
    def fahrenheit(self) -> float:
        return self._celsius * 9 / 5 + 32


t = Temperature(21)
print(t.fahrenheit)       # 69.8
t.celsius = -300          # ValueError: below absolute zero
```

The leading underscore in `_celsius` is a convention meaning "internal, don't touch from outside". Python doesn't enforce privacy; it trusts the convention.

Because properties look like attributes, you can start with a plain attribute and switch to a property later without changing any code that uses the class. So don't write getter and setter methods "just in case".

## Dataclasses: classes without the boilerplate

Many classes are mostly data: a constructor that stores fields, a `__repr__`, an `__eq__`. Writing those by hand is repetitive and error-prone. The `dataclasses` module generates them from type-annotated fields:

```python
from dataclasses import dataclass, field
from datetime import date


@dataclass
class LineItem:
    sku: str
    quantity: int
    unit_price_cents: int

    @property
    def total_cents(self) -> int:
        return self.quantity * self.unit_price_cents


@dataclass
class Order:
    id: str
    customer: str
    items: list[LineItem] = field(default_factory=list)
    created: date = field(default_factory=date.today)

    def add(self, item: LineItem) -> None:
        self.items.append(item)

    @property
    def total_cents(self) -> int:
        return sum(item.total_cents for item in self.items)


order = Order("o-1", "ana")
order.add(LineItem("PEN", 3, 120))
print(order)
# Order(id='o-1', customer='ana', items=[LineItem(sku='PEN', quantity=3, unit_price_cents=120)], created=datetime.date(2026, 10, 31))
print(order.total_cents)    # 360
```

`@dataclass` wrote `__init__`, `__repr__` and `__eq__` from the field list. It's still an ordinary class, so you add methods and properties as usual.

> [!WARNING]
> Never use a mutable default like `items: list[LineItem] = []`. Defaults are created once, so every instance would share the same list. Dataclasses refuse this with an error; use `field(default_factory=list)`, which creates a fresh list per instance. The same trap exists in plain functions: `def f(items=[])` shares one list across calls.

Useful options:

```python
@dataclass(frozen=True, slots=True)
class Point:
    x: float
    y: float
```

- `frozen=True` makes instances immutable (assignment raises an error) and hashable, so they can be dict keys and set members. Use it for **value objects**: things defined entirely by their values, such as a point, a money amount or a date range.
- `slots=True` (Python 3.10+) makes instances smaller and attribute access faster, and catches typos such as `p.z = 1`.
- `order=True` generates `<`, `>` and friends, comparing fields in order.

For validation, add a `__post_init__` method, which runs after the generated `__init__`:

```python
@dataclass(frozen=True)
class DateRange:
    start: date
    end: date

    def __post_init__(self) -> None:
        if self.end < self.start:
            raise ValueError("end must not be before start")
```

## Enums: a fixed set of choices

When a value can only be one of a few options, a string invites typos (`"shiped"`). An **enum** makes the options explicit:

```python
from enum import StrEnum


class Status(StrEnum):
    PENDING = "pending"
    PAID = "paid"
    SHIPPED = "shipped"


s = Status("paid")             # from a string, e.g. loaded from JSON
print(s is Status.PAID)        # True
print(s == "paid")             # True: StrEnum members are also strings
Status("shiped")               # ValueError: 'shiped' is not a valid Status
```

`StrEnum` (Python 3.11+) members compare equal to their string values and serialise cleanly to JSON. Use plain `Enum` when the values shouldn't behave like strings.

## Inheritance and composition

A class can **inherit** from another, reusing its methods and overriding some:

```python
class Notifier:
    def send(self, to: str, message: str) -> None:
        raise NotImplementedError


class EmailNotifier(Notifier):
    def send(self, to: str, message: str) -> None:
        print(f"email to {to}: {message}")


class SmsNotifier(Notifier):
    def send(self, to: str, message: str) -> None:
        print(f"sms to {to}: {message}")
```

Code that takes a `Notifier` works with either. That's the good use of inheritance: several interchangeable implementations of one role.

The bad use is inheriting to reuse code from a class that isn't really the same kind of thing. Deep hierarchies (`Animal → Mammal → Dog → ServiceDog`) become fragile: a change high up ripples down unpredictably. Prefer **composition**, where an object *has* other objects and delegates to them:

```python
@dataclass
class OrderService:
    notifier: Notifier            # has-a, passed in

    def confirm(self, order: Order) -> None:
        self.notifier.send(order.customer, f"Order {order.id} confirmed")


OrderService(EmailNotifier()).confirm(order)
```

`OrderService` doesn't care which notifier it gets, and a test can pass in a fake that records messages. Python also supports **protocols** (structural interfaces) for this, covered in the type hints lesson.

## Choosing a representation

| Use | When |
|---|---|
| `dict` | Loose, dynamic data: parsed JSON you'll pass through, ad hoc grouping |
| `@dataclass` | Records with known fields: the default for domain data |
| `@dataclass(frozen=True)` | Values defined by their contents: money, coordinates, date ranges |
| A regular class | Objects with significant behaviour or internal state to protect |
| `Enum` / `StrEnum` | A fixed set of named options |

## Practice

Model a shopping cart:

1. A frozen dataclass `Money` holding `cents: int` and `currency: str`, with `__add__`, multiplication by an integer (`__mul__`), and a `__str__` like `12.50 EUR`. Adding different currencies raises `ValueError`.
2. A dataclass `CartItem` with `sku`, `name`, `unit_price: Money` and `quantity: int` that validates `quantity >= 1` in `__post_init__`, with a `total` property.
3. A `Cart` class with `add(item)` (adding an existing SKU increases its quantity), `remove(sku)`, `__len__` (number of units) and a `total` property. An empty cart's total is `0.00 EUR`.

**Solution**

```python
from dataclasses import dataclass


@dataclass(frozen=True)
class Money:
    cents: int
    currency: str = "EUR"

    def __add__(self, other: "Money") -> "Money":
        if self.currency != other.currency:
            raise ValueError(f"cannot add {self.currency} and {other.currency}")
        return Money(self.cents + other.cents, self.currency)

    def __mul__(self, factor: int) -> "Money":
        return Money(self.cents * factor, self.currency)

    def __str__(self) -> str:
        return f"{self.cents / 100:.2f} {self.currency}"


@dataclass
class CartItem:
    sku: str
    name: str
    unit_price: Money
    quantity: int = 1

    def __post_init__(self) -> None:
        if self.quantity < 1:
            raise ValueError("quantity must be at least 1")

    @property
    def total(self) -> Money:
        return self.unit_price * self.quantity


class Cart:
    def __init__(self, currency: str = "EUR") -> None:
        self.currency = currency
        self._items: dict[str, CartItem] = {}

    def add(self, item: CartItem) -> None:
        if item.unit_price.currency != self.currency:
            raise ValueError("item currency does not match cart")
        if item.sku in self._items:
            self._items[item.sku].quantity += item.quantity
        else:
            self._items[item.sku] = item

    def remove(self, sku: str) -> None:
        self._items.pop(sku, None)

    def __len__(self) -> int:
        return sum(item.quantity for item in self._items.values())

    @property
    def total(self) -> Money:
        total = Money(0, self.currency)
        for item in self._items.values():
            total = total + item.total
        return total


cart = Cart()
cart.add(CartItem("PEN", "Pen", Money(120), 3))
cart.add(CartItem("BAG", "Backpack", Money(3500)))
cart.add(CartItem("PEN", "Pen", Money(120), 2))
print(len(cart), cart.total)    # 6 41.00 EUR
cart.remove("BAG")
print(len(cart), cart.total)    # 5 6.00 EUR
```

`Money` is frozen because it's a value: two `Money(120)` are interchangeable, and nothing should change one in place. `CartItem` isn't frozen because the cart updates its quantity. `Cart` is a regular class with a private dict keyed by SKU, because it has behaviour (merging duplicates, checking currency) and internal structure that callers shouldn't depend on. Starting the total at `Money(0, self.currency)` is what makes an empty cart's total `0.00 EUR`.

## Key takeaways

- Classes bundle data with the behaviour that protects it; `__init__` sets attributes and methods receive the instance as `self`.
- Special methods such as `__repr__`, `__str__`, `__eq__` and `__add__` make your types work with built-in syntax.
- Properties give computed or validated attributes without getter and setter methods.
- Dataclasses generate the boilerplate for data-centred classes; use `field(default_factory=...)` for mutable defaults and `frozen=True` for value objects.
- Use enums for fixed choices, inheritance for interchangeable implementations of a role, and composition for most other reuse.

## Quiz

1. Why does @dataclass reject items: list[str] = [] as a field default?

   A. Lists can't be dataclass fields
   B. The single default list would be shared by every instance; default_factory=list creates a new one each time
   C. Defaults must be strings
   D. Dataclasses don't support defaults

   **Answer: B.** A default value is created once. A mutable default shared across instances leads to one object's changes appearing in another's, so dataclasses require default_factory.

2. Which method controls what print(obj) shows?

   A. __repr__ only
   B. __init__
   C. __str__ (falling back to __repr__ if __str__ isn't defined)
   D. __format__ only

   **Answer: C.** print uses str(), which calls __str__. If there's no __str__, Python falls back to __repr__, which is meant for developers.

3. What does frozen=True on a dataclass give you?

   A. Faster construction
   B. Immutable instances that are also hashable, so they can be dict keys or set members
   C. Automatic validation of field types
   D. A __len__ method

   **Answer: B.** Frozen instances raise an error on assignment and get a __hash__ based on their fields. Type annotations aren't checked at runtime either way.

4. OrderService needs to send notifications by email in production and record them in tests. Which design fits best?

   A. OrderService receives a notifier object and calls its send method (composition)
   B. OrderService inherits from EmailNotifier
   C. OrderService checks a global TESTING flag
   D. Two copies of OrderService, one per environment

   **Answer: A.** Passing in the collaborator lets production use EmailNotifier and tests use a fake, without inheritance or global flags.
