Dataclasses and Value Objects · practice
Data-oriented and Behavior-rich Objects
Dataclasses make a certain kind of class so cheap that it is worth asking when not to use one. The answer is not about syntax. It is about what the
Two kinds of object
Points is data-oriented. It carries a
QuizAttempt is behavior-rich. It carries state, but its reason for existing is what it does with that state. The attributes are how it works, not what it is.
Both are dataclasses here, and that is fine. The attempt records points when an answer is given, keeping Chapter 6’s snapshot rule. In the exercise, its internal list uses field(default_factory=list, init=False, repr=False): init=False keeps that history out of constructor repr=False leaves it out of the generated display.
The distinction matters for a different reason.
Where the methods should sit
A data-oriented class attracts
@dataclass(frozen=True)
class Points:
amount: int
def format_for_report(self, width): # display
...
def convert_to_percentage(self, total): # arithmetic about something else
...
def save_to_database(self): # storage
...
Each was added because Points was the class nearby with the number in it. None of them is about points. format_for_report belongs to a report, convert_to_percentage needs a total that Points has no business knowing, and save_to_database ties a value object to a system it should never hear about.
The test from Chapter 6 still applies: which object would change if the rule changed? A value should keep methods that are true of the value itself, whatever the surrounding program is doing.
Both of these are facts about durations. total_seconds is the same duration expressed differently, and plus combines two of them into a third. Neither mentions reports, storage, or quizzes, and both would still make sense in a completely different program.
Notice that plus returns a new Duration rather than changing either input. That is what a method on a frozen value looks like, and it matches how + behaves on numbers.
Which method belongs on a frozen Points value?
An anemic model
There is a failure at the other end, and it is more common.
If every class in a program is data-oriented, and all the rules live in
@dataclass
class Question:
prompt: str
answer: str
points: int
def is_correct(question, response): # belongs on Question
return response == question.answer
def points_for(question, response): # belongs on Question
if is_correct(question, response):
return question.points
return 0
Both functions read one object’s attributes and make a decision that object could make. Making Question a dataclass removed the boilerplate; it did not license moving the behavior out.
@dataclass generates __init__, __repr__, and __eq__. Those are the methods nobody thinks about. Everything you actually decided still belongs in the class.
A working rule
Ask what the class is for.
A value. Frozen dataclass. Methods that are facts about the value, returning new values. Points, Duration, Question.
An entity. Dataclass with eq=False, or an ordinary class. Methods named for events, changing state through them. QuizAttempt, Learner.
A service. Often no data at all, or only its collaborators. ScoreReport holds an attempt and turns it into lines.
The exercise has a program where the methods have ended up on the wrong side of these lines in both directions.
Task
The
Duration has picked up format_for_report, which is display work. Move that into ScoreReport and give Duration the one method that is genuinely about durations: plus(other), returning a new Duration of the combined time.
Question has been hollowed out. The is_correct and points_for each read one question and make a decision it could make itself. Move both onto Question as methods, and delete the functions.
QuizAttempt keeps its behavior, and gains record(question, given), which stores a (given, earned) snapshot in its internal _results score property computes from that one history, and results() returns a copy. Ask the question rather than comparing here.
ScoreReport returns lines and prints nothing.
ScoreReport.lines() returns two "<learner>: <score> points" and "took <minutes>m<seconds>s", with seconds padded to two digits. For example, two minutes and five seconds appears as "took 2m05s". Use the combined duration from plus; carry each full 60 seconds into minutes.