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()
Klassen und Objekte
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.“
Aufgabe A
- Erstelle ein neues github-Repository
ksr_talit_vocabulary
und clone es lokal in VS Code. - Teile es mit
tkilla77
undanschae
. - 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 WordPair
s 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
Objekte und Klassen in Python
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:
Sogar Funktionen sind Objekte:
Vererbung
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:
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:
Überschreiben wir die __str__
Methode, können wir bestimmen, wie Objekte in Strings umgewandelt werden:
Aufgabe B: Statistik
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
Refactoring
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:
Auswahl eines Wortes
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}')
Aufgabe C: Bessere Auswahlstrategien
Schreibe zwei Auswahl-Strategien mit folgenden Eigenschaften, und teste sie aus.
RandomStrategy
: zufällige Wahl eines WortpaarsScoreStrategy
: gewichtete Auswahl der Wortpaare: je schlechter der Score eines Paars, desto wahrscheinlicher dessen Wahl.
Du hast damit das Strategie-Entwurfsmuster kennengelernt.
Aufgabe D: Wie lange lernen?
Ä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?
Speichern & Lesen
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 } ]
Aufgabe E: JSON-Serialisierung
- Füge eine Methode
to_dict(self)
zuWordPair
hinzu, die ein Wort-Paar und seine Stats in ein Dictionary verwandelt und zurückgibt. - Füge eine Methode
save_to(self, filename)
zuVocabularyUnit
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
Statische Methoden
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)
Aufgabe F
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)
Aufgabe G - Webapp
S. auch Flask Webserver und Web Apps.