0%

Dataclasses und Wertobjekte · Übung

Datenorientierte Objekte und Objekte mit viel Verhalten

Dataclasses machen eine bestimmte Art von Klasse so einfach, dass sich die Frage lohnt, wann du sie nicht verwenden solltest. Die Antwort betrifft nicht die Syntax, sondern die Aufgabe des Objekts.

Zwei Arten von Objekten

Try it

Points ist datenorientiert. Es trägt einen Wert, und fast alles, was mit ihm geschieht, erledigt etwas anderes. Seine gesamte Definition ist die Liste dessen, was es enthält.

QuizAttempt besitzt viel eigenes Verhalten. Es trägt Zustand, aber sein Existenzgrund ist das, was es mit diesem Zustand tut. Die Attribute erklären, wie es arbeitet, nicht was es ist.

Hier sind beide Dataclasses, und das ist in Ordnung. Der Versuch erfasst Punkte bei Abgabe einer Antwort und erhält damit die Regel für Momentaufnahmen aus Kapitel 6. In der Übung verwendet seine interne Liste field(default_factory=list, init=False, repr=False): init=False hält diesen Verlauf aus den Konstruktorargumenten heraus, und repr=False lässt ihn in der erzeugten Darstellung weg.

Die Unterscheidung ist aus einem anderen Grund wichtig.

Wo die Methoden stehen sollten

Eine datenorientierte Klasse zieht Methoden an, die nicht zu ihr gehören. Sieh dir an, wie das schiefgeht:

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

Jede Methode wurde ergänzt, weil Points die nächstgelegene Klasse mit der Zahl war. Keine handelt wirklich von Punkten. format_for_report gehört zu einem Bericht. convert_to_percentage braucht eine Gesamtsumme, die Points nichts angeht. save_to_database bindet ein Wertobjekt an ein System, von dem es nie hören sollte.

Der Test aus Kapitel 6 gilt weiterhin: Welches Objekt würde sich ändern, wenn sich die Regel ändert? Ein Wert sollte Methoden behalten, die Aussagen über den Wert selbst treffen, unabhängig davon, was das umgebende Programm tut.

Try it

Beides hier sind Aussagen über Zeitdauern. total_seconds drückt dieselbe Dauer anders aus, und plus kombiniert zwei Dauern zu einer dritten. Keine Methode erwähnt Berichte, Speicherung oder Quizze. Beide würden in einem völlig anderen Programm weiterhin Sinn ergeben.

Beachte, dass plus eine neue Duration zurückgibt, statt eine Eingabe zu ändern. So sieht eine Methode auf einem eingefrorenen Wert aus. Es entspricht dem Verhalten von + bei Zahlen.

Welche Methode gehört zu einem eingefrorenen Wert Points?

Ein anämisches Modell

Am anderen Ende gibt es einen häufigeren Fehler.

Ist jede Klasse eines Programms datenorientiert und leben alle Regeln in Funktionen, die diese Klassen auseinandernehmen, sind die Klassen nur Dictionarys mit besseren repr-Darstellungen. Diese Anordnung heißt anämisches Modell, und Kapitel 1 dieses Kurses begann damit.

@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

Beide Funktionen lesen die Attribute eines Objekts und treffen eine Entscheidung, die dieses Objekt selbst treffen könnte. Question zu einer Dataclass zu machen entfernte die Boilerplate. Es erlaubte nicht, das Verhalten auszulagern.

@dataclass erzeugt __init__, __repr__ und __eq__. Das sind die Methoden, über die niemand eigens nachdenkt. Alles, was du tatsächlich entschieden hast, gehört weiterhin in die Klasse.

Eine praktische Regel

Frage, wozu die Klasse dient.

Ein Wert. Eingefrorene Dataclass. Methoden, die Aussagen über den Wert treffen und neue Werte zurückgeben. Points, Duration, Question.

Eine Entität. Dataclass mit eq=False oder gewöhnliche Klasse. Nach Ereignissen benannte Methoden, über die sich der Zustand ändert. QuizAttempt, Learner.

Ein Dienst. Oft überhaupt keine Daten oder nur seine Partnerobjekte. ScoreReport hält einen Versuch und wandelt ihn in Zeilen um.

Die Übung enthält ein Programm, dessen Methoden auf beiden Seiten dieser Grenzen am falschen Ort gelandet sind.

Aufgabe

Die Methoden dieses Programms sind in beide Richtungen abgewandert. Bringe sie an ihren richtigen Platz.

Duration hat format_for_report aufgenommen, das für die Darstellung arbeitet. Verschiebe es nach ScoreReport und gib Duration die Methode, die tatsächlich zu Dauern gehört: plus(other), das eine neue Duration für die kombinierte Zeit zurückgibt.

Question wurde ausgehöhlt. Die Funktionen is_correct und points_for auf Modulebene lesen jeweils eine Frage und treffen eine Entscheidung, die sie selbst treffen könnte. Verschiebe beide als Methoden nach Question und lösche die Funktionen.

QuizAttempt behält sein Verhalten und erhält record(question, given), das eine Momentaufnahme (given, earned) in seiner internen Liste _results speichert. Seine schreibgeschützte Property score berechnet aus diesem einen Verlauf, und results() gibt eine Kopie zurück. Frage das Frageobjekt, statt hier zu vergleichen.

ScoreReport gibt Zeilen zurück und verwendet keine Ausgabe.

ScoreReport.lines() gibt zwei Strings zurück: "<learner>: <score> points" und "took <minutes>m<seconds>s", wobei die Sekunden auf zwei Stellen aufgefüllt werden. Zwei Minuten und fünf Sekunden erscheinen zum Beispiel als "took 2m05s". Verwende die kombinierte Dauer aus plus; übertrage jeweils volle 60 Sekunden in Minuten.