0%

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 is for.

Two kinds of object

Try it

Points is data-oriented. It carries a , and nearly everything you do with it is done by something else. Its whole definition is the of what it holds.

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 , and 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 that do not belong to it. Watch this go wrong:

@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.

Try it

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 that take those classes apart, then the classes are just with better reprs. That arrangement has a name, the anemic model, and Chapter 1 of this course opened with it.

@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 in this program have drifted in both directions. Put them where they belong.

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 -level 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 . Its read-only 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.