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