Skip to content
VLSI Mentor

Python Part I · Module 16 · Object-Oriented Programming

Dataclasses for Results a Whole Flow Passes Around

Chapter 16.14 ended with a dictionary that was doing almost everything right and failing in two specific ways: nothing anywhere stated what fields a result has, and a missing one was discovered three functions after it went missing. This chapter closes both with four lines, using a decorator that is the only one this curriculum introduces before Part II. It also hands back three methods you have already written or wished for by hand — and it is worth being precise about which of its promises are enforced and which are merely documentation, because one of the two is widely misunderstood.

Intermediate16 min readPythonPart IFundamentalsOOPDataclasses

Part I · Module 16 · Chapter 16.15 · Dataclasses for Results a Whole Flow Passes Around

1. Four Declared Fields

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
from dataclasses import dataclass


@dataclass
class TestResult:
    name: str
    seed: int
    passed: bool
    runtime_s: float


r = TestResult("smoke", 1234, True, 182.4)

print("the object :", r)
print("a field    :", r.name, r.seed)
print("its type   :", type(r).__name__)
print("its storage:", r.__dict__)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
the object : TestResult(name='smoke', seed=1234, passed=True, runtime_s=182.4)
a field    : smoke 1234
its type   : TestResult
its storage: {'name': 'smoke', 'seed': 1234, 'passed': True, 'runtime_s': 182.4}

@dataclass is a decorator — a line beginning with @ placed above a class that modifies it. This is the only one introduced in Part I, and it is used exactly as shown; Part II covers decorators properly.

The class body contains no __init__, no self, and no assignments. Four lines naming fields and their types, and the decorator writes the rest.

2. Gap One: the Fields Are Now Stated

Chapter 16.14's first gap was that a dict's shape existed only as a habit. Now it is a declaration a reader, an editor and a program can all see:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
from dataclasses import dataclass, fields


@dataclass
class TestResult:
    name: str
    seed: int
    passed: bool
    runtime_s: float


print("declared fields:")
for f in fields(TestResult):
    print(f"  {f.name:<11} {f.type}")

print("field names:", [f.name for f in fields(TestResult)])
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
declared fields:
  name        <class 'str'>
  seed        <class 'int'>
  passed      <class 'bool'>
  runtime_s   <class 'float'>
field names: ['name', 'seed', 'passed', 'runtime_s']

Note f.type came back as the class object itself rather than the string "str" — the declaration is stored as a real reference, not as text.

The structure is introspectable. A reporting tool can ask what a result contains without anyone writing that list a second time.

3. Gap Two: a Missing Field Fails at Construction

The second gap was that an incomplete dict was built happily and failed later:

Azvya Education Pvt. Ltd.VLSI Mentor
missing.py (BROKEN ON PURPOSE)
from dataclasses import dataclass


@dataclass
class TestResult:
    name: str
    seed: int
    passed: bool
    runtime_s: float


print("building an incomplete result")
r = TestResult("smoke", 1234, True)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
building an incomplete result
Traceback (most recent call last):
  File "missing.py", line 13, in <module>
    r = TestResult("smoke", 1234, True)
TypeError: TestResult.__init__() missing 1 required positional argument: 'runtime_s'

The failure is at the line that built the object, naming the field that is missing. Compare Chapter 16.14 Section 4, where the same mistake produced a KeyError inside a summarising function two calls away.

That is the whole of the second gap, closed. A result either exists complete or does not exist.

Defaults work as they do anywhere (Chapter 11.5):

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
from dataclasses import dataclass


@dataclass
class TestResult:
    name: str
    seed: int
    passed: bool = False
    runtime_s: float = 0.0


print(TestResult("smoke", 1234))
print(TestResult("cdc", 5678, True, 204.1))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
TestResult(name='smoke', seed=1234, passed=False, runtime_s=0.0)
TestResult(name='cdc', seed=5678, passed=True, runtime_s=204.1)

4. The Types Are Not Enforced

The most common misunderstanding, and it matters:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
from dataclasses import dataclass


@dataclass
class TestResult:
    name: str
    seed: int
    passed: bool
    runtime_s: float


r = TestResult("smoke", "not an integer", "yes", [1, 2])

print("constructed happily:", r)
print("seed is a:", type(r.seed).__name__)
print("runtime is a:", type(r.runtime_s).__name__)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
constructed happily: TestResult(name='smoke', seed='not an integer', passed='yes', runtime_s=[1, 2])
seed is a: str
runtime is a: list

Nothing checked anything. seed: int is a type hint, and Chapter 11.12 established that hints are documentation — Python does not verify them at runtime.

So what did they buy? Exactly Section 2's answer: a declaration. A reader knows what was intended, an editor can warn, and a type checker run separately will catch the mismatch. What they do not buy is a runtime guarantee, and a dataclass that must validate needs an explicit check — Chapter 14.7's raise at the boundary, written in a __post_init__ method that this curriculum does not otherwise cover.

A dataclass declares the shape. It does not police the contents.

5. Three Methods You Already Wrote by Hand

The decorator generates more than __init__:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
from dataclasses import dataclass


@dataclass
class Generated:
    name: str
    seed: int


class ByHand:
    def __init__(self, name, seed):
        self.name = name
        self.seed = seed

    def __repr__(self):
        return f"ByHand(name={self.name!r}, seed={self.seed})"

    def __eq__(self, other):
        return self.name == other.name and self.seed == other.seed


g1, g2 = Generated("smoke", 1234), Generated("smoke", 1234)
h1, h2 = ByHand("smoke", 1234), ByHand("smoke", 1234)

print("repr  :", g1, "|", h1)
print("equal :", g1 == g2, "|", h1 == h2)
print("lines :", "4 declared", "vs", "13 hand-written")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
repr  : Generated(name='smoke', seed=1234) | ByHand(name='smoke', seed=1234)
equal : True | True
lines : 4 declared vs 13 hand-written

Three things arrive free, and you have met all three as problems:

  • __init__ — the four self.x = x lines from Chapter 16.3, which is also where forgetting self. silently built an empty object.
  • __repr__ — Chapter 16.8's memory address, now readable in lists and debuggers without writing anything.
  • __eq__ — Chapter 16.2 Section 8 found that two objects with identical fields compare False. A dataclass compares by field value instead.

And __eq__ arrives without Chapter 16.8 Section 6's hashing trap, because the decorator handles both together:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
from dataclasses import dataclass


@dataclass(frozen=True)
class Key:
    name: str
    seed: int


keys = {Key("smoke", 1), Key("smoke", 1), Key("cdc", 2)}

print("three added, set holds:", len(keys))
print("sorted             :", sorted(keys, key=lambda k: k.name))
print("as a dict key      :", {Key("smoke", 1): "PASS"}[Key("smoke", 1)])
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
three added, set holds: 2
sorted             : [Key(name='cdc', seed=2), Key(name='smoke', seed=1)]
as a dict key      : PASS

(The set is sorted before printing because a set has no reliable order — Chapter 7.2's rule applies to your own objects too.)

Two identical Key objects collapsed to one in the set, because equality is by value and frozen=True supplies a matching __hash__.

6. The One Error a Dataclass Refuses

Chapter 11.5's mutable-default trap is finally caught by a tool rather than by a bug:

Azvya Education Pvt. Ltd.VLSI Mentor
mutable.py
from dataclasses import dataclass


def define_bad():
    @dataclass
    class TestResult:
        name: str
        tags: list = []
    return TestResult


try:
    define_bad()
except ValueError as e:
    print("refused at definition time")
    print("ValueError:", e)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
refused at definition time
ValueError: mutable default <class 'list'> for field tags is not allowed: use default_factory

The error is at class-definition time, not at the second instance — which is when the plain-class version of this bug produces its first wrong answer. The message names the field and the fix.

The fix uses field(default_factory=...), which builds a fresh list per instance:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
from dataclasses import dataclass, field


@dataclass
class TestResult:
    name: str
    tags: list = field(default_factory=list)


a = TestResult("smoke")
b = TestResult("cdc")
a.tags.append("nightly")

print("a:", a)
print("b:", b)
print("same list:", a.tags is b.tags)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
a: TestResult(name='smoke', tags=['nightly'])
b: TestResult(name='cdc', tags=[])
same list: False

That is Chapter 16.4 Section 5's fix — a new list per instance — expressed as a declaration instead of a line in __init__.

7. Freezing a Result That Should Not Change

Chapter 16.6 asked whether a method should mutate. For a recorded result, the answer is usually that nothing should:

Azvya Education Pvt. Ltd.VLSI Mentor
frozen.py (BROKEN ON PURPOSE)
from dataclasses import dataclass


@dataclass(frozen=True)
class TestResult:
    name: str
    passed: bool


r = TestResult("smoke", True)
print("reading is fine:", r.name, r.passed)
r.passed = False
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
reading is fine: smoke True
Traceback (most recent call last):
  File "frozen.py", line 12, in <module>
    r.passed = False
    ^^^^^^^^
  File "<string>", line 16, in __setattr__
dataclasses.FrozenInstanceError: cannot assign to field 'passed'

frozen=True makes assignment an error. For a result that records what happened, that is the right default — a run's outcome is a fact, and code that wants a modified copy should say so:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
from dataclasses import dataclass, replace


@dataclass(frozen=True)
class TestResult:
    name: str
    passed: bool


original = TestResult("smoke", True)
amended = replace(original, passed=False)

print("original:", original)
print("amended :", amended)
print("distinct:", original is not amended)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
original: TestResult(name='smoke', passed=True)
amended : TestResult(name='smoke', passed=False)
distinct: True

replace() is Chapter 16.6 Section 4's "return a new object" pattern, supplied for you.

8. Predict, Run, Observe

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
from dataclasses import dataclass, asdict


@dataclass
class R:
    name: str
    seed: int = 0


a = R("smoke", 1234)
b = R("smoke", 1234)
c = R("smoke")

print("A:", a == b, a is b)
print("B:", c)
print("C:", asdict(a))
print("D:", a.__dict__ == asdict(a))
a.seed = 9999
print("E:", a, a == b)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
A: True False
B: R(name='smoke', seed=0)
C: {'name': 'smoke', 'seed': 1234}
D: True
E: R(name='smoke', seed=9999) False

A — equal by value, distinct objects. Contrast Chapter 16.2 Section 8, where the hand-written class gave False.

C and D — asdict() converts back to a plain dictionary, and it matches __dict__ for a flat dataclass. That is the bridge to Module 21's JSON and CSV work: a dataclass internally, a dict at the boundary.

E — a plain dataclass is mutable, so assignment works and equality follows the new value. Use frozen=True when that is not what you want.

9. Exercises

Exercise 1 — four fields. Write a dataclass with four typed fields and create two instances. Print both.

Exercise 2 — the declaration. Use fields() to list a dataclass's field names and types without reading the source.

Exercise 3 — missing field. Omit a required argument and record the error. Compare it to Chapter 16.14's KeyError.

Exercise 4 — hints are not checks. Construct an instance with the wrong type in every field. Confirm it succeeds, and say what the hints are for.

Exercise 5 — three free methods. Write the hand-written equivalent of a two-field dataclass. Count the lines and name each generated method.

Exercise 6 — the mutable default. Write tags: list = [] and record the error. Fix it with default_factory and prove two instances do not share.

Exercise 7 — frozen. Freeze a dataclass, try to assign to a field, then produce a modified copy with replace().

Exercise 8 — round trip. Convert a dataclass to a dict with asdict() and back with R(**d). Say where in a real flow each direction would be used.

10. Summary

  • @dataclass is a decorator that writes a class's boilerplate from a list of declared fields.
  • The fields are stated where a reader, an editor and a program can see them — Chapter 16.14's first gap.
  • A missing field fails at construction, naming the field — Chapter 16.14's second gap.
  • Type hints are not enforced; a dataclass declares the shape and does not police the contents.
  • __init__, __repr__ and __eq__ arrive free — the three things you wrote by hand or missed in Chapters 16.3, 16.8 and 16.2.
  • Equality is by field value, not identity.
  • frozen=True also supplies __hash__, so instances work in sets and as dict keys.
  • A mutable default raises at class-definition time, naming default_factory — Chapter 11.5's trap, caught by a tool.
  • frozen=True suits anything you record, and replace() gives you the modified copy.
  • asdict() converts back to a plain dict at a boundary.

The last chapter of the module argues that you should often use none of this.

Where this fits

Part of the Python curriculum.