# 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?
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...
# 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:
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 [[wpde>Attribut_(Programmierung)|Attribute]] (Objekt-Variablen) und [[wpde>Methode_(Programmierung)|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 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 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` und `anschae`.
* Füge das `.gitignore` für Python hinzu (von [[https://github.com/github/gitignore/blob/main/Python.gitignore|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 [[wp>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:
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)}')
### 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:
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 [[https://docs.python.org/3/reference/datamodel.html#basic-customization|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)
### 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 [[https://docs.python.org/3/howto/sorting.html|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 Wortpaars
* `ScoreStrategy`: gewichtete Auswahl der Wortpaare: je schlechter der Score eines Paars, desto wahrscheinlicher dessen Wahl.
* s. [[https://docs.python.org/3/library/random.html#random.choices|random.choices()]]
Du hast damit das [[wp>Strategy_pattern|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.
{{:talit:tutorial_oop3:pasted:20250513-173205.png?nolink&400}}
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)` 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 [[https://docs.python.org/3/library/json.html#basic-usage|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 [[talit:flask_webserver]] und [[ef_informatik:webapps:start]].