Objekt-orientierte Programmierung

Code mit vielen Eigenschaften kann schnell unübersichtlich werden - sogar selbst geschriebener Code altert schlecht und ist nach wenigen Wochen unlesbar. Was macht der folgende Code?

spaghetti.py
v = [
    [["Baum", "tree"], 0, 0],
    [["Blume", "flower"], 0, 0],
    [["Fisch", "fish"], 0, 0],
]
 
for _ in range(3):
    for w in v:
        w1, w2 = w[0]
        if w2 == input(f"Translate {w1}: "):
            w[1]+=1
            print("correct")
        else:
            w[2]+=1
            print("incorrect")
for w in v:
    print(f'{w[1]/(w[1]+w[2]):.0%}: {w[0]}')

Code wird nur einmal geschrieben, aber sehr viel häufiger und von mehr Personen gelesen. Es lohnt sich also, in leserlichen Code zu investieren. Natürlich könnten wir obigen Spaghetti-Code mit besseren Variablennamen, Dictionaries und Kommentaren verbessern. Nur verlieren wir vor lauter Strings schnell den Überblick…

noodles.py
# Define vocabulary unit
voci_unit = [
    {'translation': {'word1':"Baum", 'word2': "tree"}, 'correct': 0, 'incorrect': 0},
    {'translation': {'word1':"Blume", 'word2': "flower"}, 'correct': 0, 'incorrect': 0},
    {'translation': {'word1':"Fisch", 'word2': "fish"}, 'correct': 0, 'incorrect': 0},
]
 
# Make three passes
for _ in range(3):
    for w in voci_unit:
        if w['translation']['word2'] == input(f"Translate {w['translation']['word1']}: "):
            w['correct']+=1
            print("correct")
        else:
            w['incorrect']+=1
            print("incorrect")
 
# Print statistics
for w in voci_unit:
    print(f'{w['correct']/(w['correct']+w['incorrect']):.0%}: {w['translation']}')

Wouldn't it be nice… wenn wir unsere Lernprogramm ohne viel Kommentar und trotzdem kurz und verständlich definieren könnten?

Zum Beispiel so:

from voci import *
 
unit = VocabularyUnit([
    WordPair('Baum', 'tree'),
    WordPair('Blume', 'flower'),
    WordPair('Fisch', 'fish')
])
 
learner = ConsoleLearner()
learner.learn(unit)
unit.print_stats()

Genau das erreichen wir mit der objekt-orientierten Programmierung. Du hast bereits sehr oft Objekte benutzt, zum Beispiel Strings oder Listen. Ein Objekt gruppiert Attribute (en. attributes) und Methoden (Funktionen, die zum Objekt gehören). Alle Objekte einer Klasse haben die gleichen Methoden und die gleichen Attribut-Namen, aber möglicherweise unterschiedliche Inhalte in den Attributen.

Beispiel: Alle Listen-Objekte enthalten eine Sequenz von Elementen, aber nicht dieselben. Die pop-Methode wird aber für alle Listen-Objekte das letzte Element entfernen und zurückgeben.

Eine Klasse wird in Python mit dem class Keyword definiert. Methoden haben immer einen ersten Parameter self, der für das konkrete Objekt steht, für das die Methode ausgeführt wird. Wird eine neues Objekt erstellt, wird zuerst die __init__ Methode aufgerufen.

Am bestem am Beispiel:

voci.py
class WordPair:
    """A word and its translation."""
    def __init__(self, word1, word2):
        """Creates a new pair from two strings."""
        self.word1 = word1   # All objects of class WordPair share the same attributes
        self.word2 = word2   # but not necessarily the same contents.
 
    def reversed(self):       # All objects of class WordPair share the same methods
        """Returns a copy of the pair in the reverse direction."""
        return WordPair(self.word2, self.word1)
 
tree = WordPair('Baum', 'tree')  # Calls __init__, we are passing only two arguments, the self argument is implicit.
 
print(f'{tree.word1} translates to {tree.word2}')  # Attributes can be accessed using dot-notation.

Terminologie

Alle Objekte mit den gleichen Eigenschaften werden durch eine Klasse (en. class) beschrieben. Die Eigenschaften sind Attribute (Objekt-Variablen) und Methoden (Objekt-Funktionen).

Man kann sich ein Objekt vorstellen wie ein Dictionary mit definierten Keys, wobei die gespeicherten Werte entweder andere Objekte oder Funktionen sind. Wenn bekannt ist, dass eine Variable eine Objekt einer bestimmten Klasse ist, kann man sich darauf verlassen, dass die von der Klasse definierten Einträge vorhanden sind.

Objekte werden manchmal auch als Instanzen (einer Klasse) bezeichnet; im obigen Code-Beispiel ist das in der Variable tree gespeicherte Objekt eine Instanz der Klasse WordPair.

Coding Conventions

  • Klassennamen in CamelCase
  • Methoden und Attribute in lower_case_with_underscores()
  • Dokumentation mit """doc strings"""
    • Der doc-string für die ganze Klasse beschreibt deren Funktion. Typischerweise ist der erste Satz die Vervollständigung des Satzanfangs „Ein <classname> ist …“. Im obigen Beispiel „[A WordPair is] a word and its translation.“.
    • Der doc-string für Methoden beschreibt die Tätigkeit der Methode. Typischerweise beginnt der Satz mit einem Verb und ist die Vervollständigung des Satzanfangs „Die <functionname> Methode …“. Im obigen Beispiel „[The reversed method] returns a copy of the pair in the reverse direction.“
  • Erstelle ein neues github-Repository ksr_talit_vocabulary und clone es lokal in VS Code.
  • Teile es mit tkilla77 und anschae.
  • Füge das .gitignore für Python hinzu (von hier).

Erstelle eine neue Python-Datei voci.py und erstelle deine erste Klasse WordPair wie oben und zeige sie der Lehrperson. Achte auf Coding Conventions und Dokumentation.

Wir möchten zusätzlich eine Klasse VocabularyUnit haben, die eine Liste von WordPairs speichert. Wie sieht die __init__ Methode dieser Klasse aus?

Als drittes benötigen wir eine Klasse ConsoleLearner mit einer Methode learn(unit), die alle Wort-Paare abfragt.

  • um das Terminal zu leeren kannst du print("\033c", end="") benützen
  • um nur die aktuelle Zeile im Terminal zu löschen, verwendest du print("\033[H\033[J", end="")
  • mehr dazu auf ANSI_escape_code

Jeder Wert in Python ist eigentlich ein Objekt. Zahlen sind Objekte der Klassen int oder float. Die Klasse eines Objekts kann mit der Funktion type() herausgefunden werden:

print(f'42 is of class {type(42)}') print(f'3.14 is of class {type(3.14)}') print(f'"foobar" is of class {type("foobar")}')

Sogar Funktionen sind Objekte:

print(f'len() is of class {type(len)}')

Klassen bilden eine Hierarchie: Eine Klasse kann von einer anderen Klasse erben; dier Basisklasse vererbt alle Eigenschaften (Attribute und Methoden) an ihre Unterklassen. Methoden können in den Unterklassen auch überschrieben werden. Jedes Objekt einer Unterklasse gehört automatisch auch zur Basisklasse.

Wir können die Vererbung explizit angeben:

class Animal: def __init__(self, name): self.name = name # Attribute shared between all objects. def make_sound(self): print('generic animal sound') class Cat(Animal): # Cats are animals, but not all animals are cats. """A furry animal inclined to mice.""" def make_sound(self): # Overwrites the Animal.make_sound method. print(self.name + ': meow') class Dog(Animal): """An animal larger than cats.""" def make_sound(self): print(self.name + ': woof') for animal in [Cat("Tom"), Dog("Spike")]: animal.make_sound()

Wenn nicht anders angegeben, erbt eine Klasse direkt von der object Klasse. Diese definiert allerhand praktische Methoden, die wir aber überschreiben können. Beispielsweise definiert die __str__ Methode, wie ein Objekt in einen String umgewandelt wird. Dies wird von print benützt und gibt im Allgemeinen die Klasse und die Speicheradresse des Objekts aus:

class Animal: pass pair = Animal() print(pair)

Überschreiben wir die __str__ Methode, können wir bestimmen, wie Objekte in Strings umgewandelt werden:

class Animal: def __init__(self, name): self.name = name # Attribute shared between all objects. def __str__(self): return f'I am {self.name}, a {type(self)} but also an {type(self).__bases__[0]}' class Cat(Animal): # Cats are animals, but not all animals are cats. """A furry animal inclined to mice.""" class Dog(Animal): """An animal larger than cats.""" for animal in [Animal("Unknown"), Cat("Tom"), Dog("Spike")]: print(animal)

Wir möchten aufzeichnen, wie oft unsere Wörter richtig bzw. falsch übersetzt werden. Dafür benötigen wir eine Stats-Klasse. Neben den Attributen correct bzw. incorrect möchten wir ein Attribut score haben, das einen Wert zwischen [0..1] bereitstellt, wobei 0 bedeutet, dass das Wort noch komplett unbekannt ist, und 1, dass das Wort perfekt gelernt wurde.

Die Klasse Stats soll eine Methode record(correct) erhalten: Jedes Mal, wenn ein Wortpaar getestet wird, findet der Learner heraus, ob das Wort richtig oder falsch ist. Dieses Verdikt wird mit record() an die Statistik weitergeleitet und dort aufgezeichnet.

Zudem sollte Stats auch noch eine sinnvolle __str__ Methode haben.

Composition

Wo sollen wir die Statistik in unserem Programm unterbringen? Eigentlich soll jedes WordPair über ein eigenes Stats-Objekt verfügen. Gleichzeitig macht es keinen Sinn, ein Stats ohne dazugehöriges WordPair zu haben. Am besten fügen wir der Klasse WordPair ein Attribut stats hinzu. Wir sagen diesem Muster auch Composition: ein WordPair besteht aus zwei Strings und einem Stats-Objekt.

Score berechnen

Der Score soll folgende Eigenschaften haben:

  • der Score soll immer zwischen 0 und 1 sein.
  • zu Beginn ist der Score 0.
  • 0 bedeutet, dass das Wort unbekannt ist oder immer falsch war.
  • 1 bedeutet, dass das Wort unendlich mal richtig getestet wurde.
  • Der neueste Versuch soll mehr Gewicht haben als lange zurückliegende Versuche.

Es bietet sich an, mit einem Decay zu arbeiten: jedes Mal, wenn ein neuer Wert dazukommt, wird der alte Score mit einem Faktor <1 multipliziert. Mit einem Faktor von 0.5 setzt sich der Score zur Hälfte aus dem neuesten Test, zur anderen Hälfte aus dem bisherigen Score zusammen: $$\begin{aligned} score_{new} &= 0.5 \cdot (test_0 + score_{old}) \\ &= 0.5 \cdot (test_0 + 0.5 \cdot (test_1 + 0.5 \cdot (test_2 + \ldots))) \\ &= \frac{test_0}{2} + \frac{test_1}{4} + \frac{test_2}{8} + \frac{test_3}{16} + \ldots \end{aligned}$$

Ausgabe

Nach einem Learning Run möchten wir alle WordPairs mit ihren Statistiken ausgeben. Füge eine Methode print_stats() zu VocabularyUnit hinzu und verwende darin die __str__ Funktion von WordPair.

Die Ausgabe soll z.B. wie folgt aussehen - und nach Score sortiert sein:

 84% (22/25) Fluss -> river
 98% (26/74) Haus -> house
100% (17/19) See -> lake
100% (28/38) Fisch -> fish
100% (23/27) Maus -> mouse
100% (24/35) Katze -> cat
100% (21/37) Tisch -> table
100% (23/36) Stuhl -> chair
100% (26/30) Baum -> tree
100% (32/46) Blume -> flower

Dein Code in ConsoleLearner.learn() könnte irgendwie so aussehen:

   def learn(self, unit):
        for pair in unit.pairs:
            guess = input(f'Translate {pair.word1}')
            correct = guess == pair.word2
            pair.stats.record(correct)
            if correct:
                print('Yeah')
            else:
                print(f'Incorrect, {pair.word1} translates to {pair.word2}')

Wir möchten einige Teile dieses Verhaltens anpassen:

Statt immer alle Paare durchzugehen, möchten wir andere Auswahl-Strategien ermöglichen:

  • zufällige Wahl eines Wortpaars
  • gewichtete Auswahl der Wortpaare: je schlechter der Score eines Paars, desto wahrscheinlicher dessen Wahl.

Statt unseren Code oben mit all diesen Varianten vollzukleistern, lagern wir die Auswahl in ein eigenes Objekt aus:

class LearningStrategy:
    def select(self, unit):
        pass  # has to be implemented by subclasses.

Hier ein Beispiel für das lineare Durchgehen der Paare, wie im ursprünglichen Code:

class LinearStrategy(LearningStrategy):
    """A learning strategy that selects all word pairs in a unit, in order."""
    def __init__(self):
        self.index = 0
 
    def select(self, unit):
        pair = unit.pairs[self.index % len(unit.pairs)]
        self.index += 1
        return pair

Wir passen ConsoleLearner.learn() so an, dass eine Strategie mitgeliefert werden kann, aber ein sinnvoller Default-Wert ausgewählt wird:

   def learn(self, unit, learning_strategy=LinearStrategy()):
        for _ in range(len(unit.pairs)):
            pair = learning_strategy.select(unit)
            guess = input(f'Translate {pair.word1}')
            correct = guess == pair.word2
            pair.stats.record(correct)
            if correct:
                print('Yeah')
            else:
                print(f'Incorrect, {pair.word1} translates to {pair.word2}')

Schreibe zwei Auswahl-Strategien mit folgenden Eigenschaften, und teste sie aus.

  • RandomStrategy: zufällige Wahl eines Wortpaars
  • ScoreStrategy: gewichtete Auswahl der Wortpaare: je schlechter der Score eines Paars, desto wahrscheinlicher dessen Wahl.

Du hast damit das Strategie-Entwurfsmuster kennengelernt.

Ähnlich wie die Entscheidung über das nächste Wortpaar möchten wir auch die Entscheidung, wie lange gelernt werden soll, abstrahieren.

Schreibe eine Klasse StopCriterion mit einer Methode should_stop(self, unit). Implementiere verschiedenen Kriterien als Unterklassen von StopCriterion, zum Beispiel:

  • ScoreCriterion: stoppt, wenn der Score des schlechtesten WortPaars über 90% (oder einem konfigurierbaren Wert) liegt.
  • TimeCriterion: stoppt, wenn eine definierte Zeit verstrichen ist.
  • CountingCriterion: stoppt nach einer definierten Anzahl Wortpaare.
  • OrCriterion: kombiniert mehrere Kriterien, stoppt, wenn mindestens eines der Kriterien stoppt.

Wie sieht die ConsoleLearner.learn Methode jetzt aus?

Wir möchten VocabularyUnits in eine Datei speichern und von dort wieder lesen können. Es bietet sich an, eine Unit als JSON-Objekt zu speichern. JSON (JavaScript Object Notation) sind Dictionaries, die als Keys nur Strings, als Werte ausschliesslich Strings, Zahlen, Boolean-Werte, Listen und wiederum JSON-Objekte enthalten. Ein WordPair könnte zum Beispiel so aussehen:

{"word1": "Baum", "word2": "tree", "correct": 26, "incorrect": 4, "score": 0.9999961480498314}

Eine ganze Unit wäre dann eine Liste solcher Objekte:

[
  {
    "word1": "Baum",
    "word2": "tree",
    "correct": 24,
    "incorrect": 4,
    "score": 0.9249845921993256
  },
  {
    "word1": "Blume",
    "word2": "flower",
    "correct": 30,
    "incorrect": 14,
    "score": 0.9999847523718017
  }
]
  • Füge eine Methode to_dict(self) zu WordPair hinzu, die ein Wort-Paar und seine Stats in ein Dictionary verwandelt und zurückgibt.
  • Füge eine Methode save_to(self, filename) zu VocabularyUnit hinzu, die die ganze Unit in die angegebene Datei speichert, in dem alle Wort-Paar-Dictionaries in eine Liste eingefügt werden. Diese kann dann mit json.dump in eine Datei geschrieben werden:
    def save_to(self, filename):
        json_list = [pair.to_dict() for pair in self.pairs]
        import json
        with open(filename, 'w') as out:
            json.dump(json_list, out, indent=2)  # indent=2 aligns the output nicely

Fürs Einlesen kommt die umgekehrte json.load Funktion zum Einsatz. Allerdings haben wir noch ein kleines Problem: Eine VocabularyUnit existiert ja noch gar nicht, wenn wir sie einlesen wollen aus der Datei. Wir benötigen also eine Funktion, die nicht an eine bestimmte Unit gebunden ist. Diese werden mit @staticmethod annotiert und haben keinen self Parameter. Statische Funktionen werden direkt über den Klassennamen aufgerufen.

class WordPair:
    ...
 
    @staticmethod
    def from_dict(data):
        """Creates a fresh word pair from json data."""
        pair = WordPair(data['word1'], data['word2'])
        # TODO also read stats if available
 
 
class VocabularyUnit:
    ...
 
    @staticmethod
    def read_from(filename):
        """Reads a vocabulary unit from a file."""
        import json
        pairs = []
        with open(filename, 'r') as infile:
            json_pairs = json.load(infile)
            pairs = [WordPair.from_dict(p) for p in json_pairs]
            return VocabularyUnit(pairs)

Füge statische Methoden zu VocabularyUnit und WordPair hinzu, um die gespeicherten Daten wieder einlesen zu können.

Ein Beispielprogramm für unseren Code könnte nun so aussehen:

from voci import *
 
filename = 'data/test.voci'
unit = VocabularyUnit.read_from(filename)
 
learner = ConsoleLearner()
try:
    learner.learn(unit)
    unit.print_stats()
finally:   # Code im finally-Block wird jedenfalls ausgeführt, auch wenn eine Exception passiert ist.
    unit.save_to(filename)

S. auch Flask Webserver und Web Apps.

  • talit/tutorial_oop3.txt
  • Zuletzt geändert: 2025-05-26 14:11
  • von hof