LEARN PYTHON | Composition And Dataclasses

Lesson objective

Model has-a relationships with collaborating objects, create dataclass records and explain factories, equality and the limits of frozen fields.

Learn

06.4 | COMPOSITION AND DATACLASSES

01 | A LIBRARY HAS BOOKS

Composition assembles behaviour or data by holding references to other objects. A Library holds Book records and Loan records; it does not become a specialised Book. A robot can hold a Sensor and ask it for a reading.

A has-a relationship is a useful starting description. In stricter modelling notation, composition can imply ownership and lifetime rules, while aggregation describes looser sharing. Python references alone do not enforce those policies, so document which objects may be shared and who controls changes.

This lesson uses collaborating objects in a small design. The goal is clear responsibilities, not a requirement to create a wrapper around every simple value.

02 | DELEGATE TO A COMPONENT

The Robot constructor receives a sensor object and stores it. read_distance delegates to that sensor’s read method. The Robot does not need to duplicate the sensor’s measurement code.

Providing the component as an argument is sometimes called dependency injection. It lets a test supply a fixed-reading sensor instead of connecting hardware. The required interface remains important: the component must support the promised operation.

Replacing a component can be simpler than adding a subclass for every robot-and-sensor combination. The ownership policy is a separate question; this example intentionally stores the supplied reference.

class FixedSensor:
    def __init__(self, reading):
        self.reading = reading

    def read(self):
        return self.reading

class Robot:
    def __init__(self, sensor):
        self._sensor = sensor

    def read_distance(self):
        return self._sensor.read()

robot = Robot(FixedSensor(25))
print(robot.read_distance())

EXPECTED OUTPUT

25

03 | RECORD CLASSES REPEAT A LOT OF CODE

A Book record might need copy_id, title and author. Writing every assignment in __init__, a readable representation and field comparisons becomes repetitive across several similar classes.

A dataclass is a class decorated with @dataclass from the standard library. Its annotated fields let the decorator generate useful methods, typically __init__, __repr__ and field-based __eq__.

The generated methods do not independently check every annotation or business rule. Dataclasses reduce boilerplate; they do not turn untrusted values into valid records automatically.

from dataclasses import dataclass

@dataclass
class Book:
    copy_id: str
    title: str
    author: str

book = Book("B001", "Python Adventures", "A. Coder")
print(book.title)
print(book)

EXPECTED OUTPUT

Python Adventures
Book(copy_id='B001', title='Python Adventures', author='A. Coder')

04 | FIELD EQUALITY IS NOT OBJECT IDENTITY

Two ordinary dataclass instances of the same class compare equal when their corresponding fields compare equal. They can still be distinct objects. a == b can be True while a is b is False.

In a library, titles do not uniquely identify physical copies. Include a copy ID when the record represents one copy. Two equal titles with B001 and B002 remain different records under their field equality.

An application must enforce uniqueness when adding records. A dataclass field named copy_id does not automatically prevent another record from using the same ID.

from dataclasses import dataclass

@dataclass
class Book:
    copy_id: str
    title: str

a = Book("B001", "Python Adventures")
b = Book("B001", "Python Adventures")
c = Book("B002", "Python Adventures")
print(a == b, a is b)
print(a == c)

EXPECTED OUTPUT

True False
False

05 | USE A FACTORY FOR MUTABLE DEFAULTS

A per-instance list needs a fresh object for each record. field(default_factory=list) supplies a callable that generates a new list when an instance needs the default. Pass list itself, not list(), because the factory must be called later for each instance.

Modern dataclasses reject a direct list field default in this ordinary case. The factory is both the supported syntax and a clear explanation of when the new mutable value is created.

A caller can still deliberately pass an existing list. The dataclass stores that supplied reference; it does not automatically deep-copy it. Document whether such sharing is permitted.

from dataclasses import dataclass, field

@dataclass
class ReadingList:
    name: str
    titles: list[str] = field(default_factory=list)

a = ReadingList("Maya")
b = ReadingList("Noor")
a.titles.append("Python Adventures")
print(a.titles)
print(b.titles)

EXPECTED OUTPUT

['Python Adventures']
[]

06 | VALIDATE IN POST-INITIALISATION

__post_init__ runs after a dataclass’s generated initializer assigns its fields. It is a useful place to validate combinations of field values or compute derived information.

The generated initializer will still accept a wrongly typed argument unless you check it or a separate tool reports it. Validate the runtime boundary when external data is involved.

The example rejects blank titles. The application still needs to enforce unique copy IDs across its collection; one record cannot know whether another existing record already has that ID.

from dataclasses import dataclass

@dataclass
class Book:
    copy_id: str
    title: str

    def __post_init__(self):
        if type(self.title) is not str or not self.title.strip():
            raise ValueError("Use a non-blank title")

print(Book("B001", "Python Adventures").title)

EXPECTED OUTPUT

Python Adventures

07 | FROZEN IS A LIMITED GUARANTEE

@dataclass(frozen=True) prevents ordinary field assignment and deletion after construction. An attempted book.title = "Changed" raises FrozenInstanceError. This helps when records should be replaced rather than edited.

Frozen is not deep immutability or a security boundary. A frozen record containing a list can still have that list’s contents changed. Objects can also be manipulated through deliberate low-level routes outside the intended interface.

For the project, frozen records contain strings and date objects, which are immutable values. Returning a tuple of those records therefore provides a useful ordinary read-only snapshot without exposing mutable field collections.

from dataclasses import dataclass, field

@dataclass(frozen=True)
class ReadingList:
    name: str
    titles: list[str] = field(default_factory=list)

reading = ReadingList("Maya")
reading.titles.append("Still mutable inside")
print(reading.titles)

EXPECTED OUTPUT

['Still mutable inside']

08 | SERIALISATION IS STILL AN EXPLICIT JOB

A dataclass is not automatically a JSON document. dataclasses.asdict creates a dictionary representation and recursively handles nested dataclasses, but a date field still needs a JSON-compatible representation such as an ISO string.

The library project stays session-only to focus on object responsibilities. Adding saved records is a sensible extension using the previous section’s validation and file rules, but it requires an explicit schema and restoration into objects.

Do not assume deepcopy, asdict or a frozen decorator independently provides validation, unique IDs or persistence. These tools each solve a particular structural problem.

09 | COMPARE FRESH AND SHARED COMPONENTS

The fixed model creates two reading lists. With separate factories, adding a title affects only Maya’s list. With a deliberately supplied shared list, both records observe the same append. A frozen outer record would not stop that shared list mutation.

10 | CHOOSE THE RIGHT RESPONSIBILITY

Use a dataclass for a coherent record. Use a service class for operations involving several records and rules. The library’s Loan says which copy, which member and which dates; Library decides whether such a loan may be created.

Use composition when parts collaborate through references. Use inheritance when a specialised object truly fits the shared behavioural contract. Both can coexist in a larger system, but the simplest clear design is often preferable.

Test default-list independence, field equality, rejected record values and the limits of frozen containers. Those cases reveal more than merely printing one successfully created object.

PUT YOUR SKILLS TO WORK

Use the Section 6 challenges to practise these ideas independently. Work in your Python editor, predict results and keep a short test record.

Section 6 challenges Python course navigation

MATCH THE PYTHON TERM TO ITS MEANING

Terminology

Terminology

Composition

Building a component using references to collaborating objects.

Component

An object supplying part of another object’s responsibility.

Delegation

Asking a component to perform part of an operation.

Dependency injection

Supplying a needed collaborator rather than creating a hidden fixed dependency.

Ownership policy

The rules for which object controls a component’s lifetime and changes.

Dataclass

A decorated class generating common record methods from fields.

Field

An annotated item of dataclass record data.

Boilerplate

Repeated structural code with little new application meaning.

Generated initializer

An __init__ created by the dataclass decorator.

Representation

A readable description supplied by __repr__.

Field equality

Comparison based on corresponding record field values.

default_factory

A callable creating a fresh default value when needed.

__post_init__

A hook called after the generated dataclass initializer.

Frozen record

A dataclass blocking ordinary later field assignment and deletion.

Deep immutability

A stronger condition in which contained state cannot mutate either.

FrozenInstanceError

An exception from ordinary assignment to a frozen dataclass field.

asdict

A helper converting dataclass data to a dictionary representation.

Service object

An object coordinating operations and rules across records.

Questions

Questions

CHECK YOUR UNDERSTANDING

Select all correct choices. Each exact set earns one point.

1. What does composition model?
2. What does @dataclass commonly generate?
3. Why use field(default_factory=list)?
4. Two same-class records have equal fields. What may be true?
5. When does __post_init__ run for the normal generated initializer?
6. What does frozen=True provide?
7. Why does a physical copy need its own ID?
8. What is dependency injection in the sensor example?

EXPLAIN AND COMPARE

Write your answers before revealing each sample.

1. Explain composition using a sensor.

2. Explain dataclass benefits and limits.

3. Explain default_factory.

4. Distinguish dataclass equality from identity.

5. Describe __post_init__ validation.

6. Explain frozen with a contained list.

7. Evaluate a copy ID field.

8. Describe a JSON extension.

CODE IT | YOUR INDEPENDENT CHALLENGE

Build a composed robot with an injected fixed sensor, then create dataclass records with independent mutable defaults.

Work in your Python editor. Keep a working version, explain one design choice and record at least two tests. Use the Learn tab for worked examples before comparing your result.

Flashcards

Flashcards

Click to flip. Select the ideas you need to revisit.

0 cards selected for revision.

    Selections are kept while this page is open.

    Workbook

    Workbook

    COMING SOON

    The workbook for Composition And Dataclasses is coming soon. Complete the matching task and written questions in your exercise book.