2.1.16 · D4OOP Fundamentals

Exercises — Dataclasses — `@dataclass` decorator, `__post_init__`

2,734 words12 min readBack to topic

Level 1 — Recognition

Goal: read the code, predict what Python prints. No writing yet.

Exercise 1.1 — What does __eq__ compare?

from dataclasses import dataclass
 
@dataclass
class Point:
    x: int
    y: int
 
a = Point(1, 2)
b = Point(1, 2)
c = Point(2, 1)
print(a == b, a == c, a is b)

What are the three printed values?

Recall Solution 1.1

Answer: True False False

  • WHAT @dataclass generated an __eq__ that compares the tuple of fields: (a.x, a.y) == (b.x, b.y).
  • WHY a == b is True: the field tuples are (1, 2) == (1, 2) → equal by value.
  • WHY a == c is False: (1, 2) == (2, 1) → not equal.
  • WHY a is b is False: is asks "same object in memory?" — these are two separate constructions, so identity differs even though values match. == and is answer different questions. See Dunder Methods __init__ __repr__ __eq__.

Exercise 1.2 — Is this a field?

from dataclasses import dataclass
 
@dataclass
class Config:
    name: str
    version = "1.0"       # no annotation!
 
print(Config.__annotations__)
Config("app")

Which names are fields, and does Config("app") succeed?

Recall Solution 1.2

Answer: __annotations__ is {'name': <class 'str'>} — only name is a field. Config("app") succeeds.

  • WHY: The decorator discovers fields by reading ==__annotations__==. version = "1.0" has no type annotation, so it is an ordinary class attribute, invisible to @dataclass.
  • Therefore __init__ takes only name. Config.version still exists as a plain class attribute ("1.0") but you cannot pass it to the constructor.
  • See Type Annotations in Python for why the annotation, not the assignment, is the trigger.

Level 2 — Application

Goal: write the dataclass yourself from a spec.

Exercise 2.1 — A Book with a default

Write a dataclass Book with fields title: str, author: str, and in_stock: int defaulting to 0. Then predict what repr(Book("Dune", "Herbert")) produces.

Recall Solution 2.1
from dataclasses import dataclass
 
@dataclass
class Book:
    title: str
    author: str
    in_stock: int = 0
  • WHY the default goes last: in __init__, parameters with defaults must come after those without. Field order = source order, so a defaulted field placed before a non-defaulted one would raise TypeError at class creation.
  • repr output:
Book(title='Dune', author='Herbert', in_stock=0)

The generated __repr__ lists every field as name=value using !r (the repr of each value), which is why strings appear in quotes.

Exercise 2.2 — Cart with a safe list default

Write a dataclass Cart holding items (a list) that starts empty and is not shared across instances. Then run:

c1 = Cart()
c1.items.append("apple")
c2 = Cart()
print(c1.items, c2.items)

What prints?

Recall Solution 2.2
from dataclasses import dataclass, field
 
@dataclass
class Cart:
    items: list = field(default_factory=list)

Answer: ['apple'] []

  • WHY field(default_factory=list): default_factory is a callable invoked once per __init__, so every instance gets a fresh list. c1 and c2 therefore hold different lists — appending to one does not touch the other.
  • See Default Arguments and Mutable Default Trap.

Level 3 — Analysis

Goal: explain why something breaks or behaves oddly.

Exercise 3.1 — Order of assignment in __post_init__

from dataclasses import dataclass, field
 
@dataclass
class Circle:
    radius: float
    area: float = field(init=False)
 
    def __post_init__(self):
        self.area = 3.14159 * self.radius ** 2
 
print(Circle(2).area)

(a) What prints? (b) Explain why self.radius is already available inside __post_init__.

Recall Solution 3.1

(a) 12.56636 (since ). (b) WHY radius is ready: the generated __init__ runs as a simple two-step timeline. Read it top-to-bottom like a checklist:

Circle(2) called


[ phase 1 ] assign every ordinary field
   self.radius = 2          <- caller's argument copied in


[ phase 2 ] run the hook (last statement of __init__)
   self.__post_init__()
      └─ self.area = 3.14159 * self.radius ** 2   <- reads radius, now safe


object handed back to caller (radius=2, area=12.56636)

By the time __post_init__ executes, all ordinary fields are already assigned. area is field(init=False), so it is not in the constructor's parameter list — the caller writes Circle(2), and area is filled by our hook. Derived logic must live in phase 2 precisely because it reads phase-1 values.

Exercise 3.2 — Why can't a plain (non-frozen) dataclass be a dict key?

from dataclasses import dataclass
 
@dataclass
class Tag:
    name: str
 
d = {Tag("x"): 1}   # what happens?

Explain the error and its deeper reason.

Recall Solution 3.2

Answer: raises TypeError: unhashable type: 'Tag'.

  • WHY: When a dataclass defines __eq__ (which it does by default) but stays mutable, Python sets __hash__ = None. An object is unhashable when its __hash__ is None.
  • Deeper reason: a dict key's hash must never change while it sits in the dict. But Tag is mutable — you could do t.name = "y" after inserting it, silently corrupting the dict's bucket placement. Python forbids the whole situation up front by making mutable-with-__eq__ dataclasses unhashable.
  • Fix: @dataclass(frozen=True) → attributes can't change → hash is stable → Python generates __hash__. See Immutability and Hashing.

Level 4 — Synthesis

Goal: combine several features into one working design.

Exercise 4.1 — Frozen + derived field

Build an immutable Vector2D with fields x: float, y: float, and a derived magnitude: float computed at construction (). It must be usable as a dict key. Then predict:

v = Vector2D(3, 4)
print(v.magnitude)
print({v: "here"}[Vector2D(3, 4)])

Before reading the solution, study the figure below — it shows why the derived magnitude field is exactly the length of the arrow.

Figure — Dataclasses — `@dataclass` decorator, `__post_init__`
Recall Solution 4.1
from dataclasses import dataclass, field
import math
 
@dataclass(frozen=True)
class Vector2D:
    x: float
    y: float
    magnitude: float = field(init=False)
 
    def __post_init__(self):
        object.__setattr__(self, "magnitude", math.hypot(self.x, self.y))

Output: 5.0 then here.

  • WHY object.__setattr__: frozen=True blocks every normal self.attr = ..., including inside __post_init__. To set the one derived field we bypass the freeze with object.__setattr__(self, "magnitude", value).
  • WHY 5.0: as the figure shows, — the plum arrow (hypotenuse) of the 3-4-5 right triangle.
  • WHY the dict lookup returns "here": frozen dataclasses are hashable and compare by value. Vector2D(3, 4) built twice has equal field tuples (3, 4, 5.0), so it hashes the same and == matches → same key.

Exercise 4.2 — Validation that rejects bad input

Extend the idea: write a frozen Percentage with one field value: float that must lie in . Construction outside that range raises ValueError. What does Percentage(150) do, and what does Percentage(42).value return?

Recall Solution 4.2
from dataclasses import dataclass
 
@dataclass(frozen=True)
class Percentage:
    value: float
 
    def __post_init__(self):
        if not (0 <= self.value <= 100):
            raise ValueError("percentage must be between 0 and 100")
  • Percentage(150) → raises ValueError. WHY: __post_init__ runs after value is assigned; the guard 0 <= 150 <= 100 is False, so it raises before the object is handed back.
  • Percentage(42).value42. It passes the guard, so construction succeeds and the field is readable (reading is always allowed; only writing is frozen).

Level 5 — Mastery

Goal: reproduce, by hand, what the decorator generates.

Exercise 5.1 — Hand-write the equivalent class

Given:

@dataclass
class Employee:
    name: str
    salary: float = 50000.0

Write, without @dataclass, a plain class that produces the same __init__, __repr__, and __eq__ behaviour. Then confirm Employee("Ada") == Employee("Ada") is True.

Recall Solution 5.1
class Employee:
    def __init__(self, name: str, salary: float = 50000.0):
        self.name = name
        self.salary = salary
 
    def __repr__(self):
        return f"Employee(name={self.name!r}, salary={self.salary!r})"
 
    def __eq__(self, other):
        if other.__class__ is not self.__class__:
            return NotImplemented
        return (self.name, self.salary) == (other.name, other.salary)
  • WHY parameter order: exactly the source field order — name first (no default), salary second (with default 50000.0). Defaults must come last.
  • WHY the __repr__ shape: dataclass emits ClassName(field=value, ...) using !r for each value.
  • WHY __eq__ compares tuples: tuple comparison gives field-by-field equality for free and returns NotImplemented for mismatched types (letting Python try the other operand's __eq__).
  • Check: Employee("Ada") == Employee("Ada") compares ("Ada", 50000.0) == ("Ada", 50000.0)True.
  • Compare this generated machinery against NamedTuple vs Dataclass vs TypedDict to see which parts each alternative gives you.

Exercise 5.2 — Trace the full pipeline (FIDE-P)

For this class, list in order every step the decorator performs at class-creation and every step __init__ performs at instance-creation, using the FIDE-P mnemonic:

@dataclass
class Order:
    qty: int
    price: float
    total: float = field(init=False)
 
    def __post_init__(self):
        self.total = self.qty * self.price

What is Order(3, 20.0).total?

Recall Solution 5.2

Answer: Order(3, 20.0).total == 60.0.

Class-creation time (decorator runs once):

  • F — Fields collected: reads __annotations__qty, price, total in order.
  • I — Init generated: builds __init__(self, qty, price) — note total is excluded because field(init=False).
  • D — Defaults applied: none here for qty/price; total has no constructor default (it's set later).
  • E — Eq/repr made: __repr__ and __eq__ generated over the tuple (qty, price, total).

Instance-creation time (Order(3, 20.0)):

  • self.qty = 3, self.price = 20.0 (phase-1 assignment).
  • P — Post_init runs last: self.total = 3 * 20.0 = 60.0.

WHY the ordering guarantees correctness: total reads self.qty and self.price, which only exist after phase-1 assignment. Because __post_init__ is invoked as the last statement of the generated __init__, those reads are always safe — the derived value can never be computed from a field that hasn't been set yet. This is the same two-phase timeline you saw in Solution 3.1, now with the derivation depending on two inputs instead of one.

Closing remark: notice you did not pass total to the constructor — Order(3, 20.0) has only two arguments. That is field(init=False) at work, and it is exactly the trap the note below warns about.


Recall Feynman recap

A @dataclass is a robot that reads your list of annotated blanks and writes the form-filling code for you: build (__init__), read out loud (__repr__), compare (__eq__), and — if you say frozen=True — freeze + fingerprint (__hash__). __post_init__ is the "now double-check and compute extras" step that runs last, once every blank is filled.