cat posts/python-lernen-07-klassen-und-objekte.md
Python lernen (Teil 7): Klassen und Objekte
Klassen bündeln State und Verhalten: Spieler, Räume und Gegenstände werden zu Objekten mit eigenen Attributen und Methoden.
In Teil 6 haben wir den Dungeon pythonic überarbeitet. Der Player State steckt weiterhin in einem Dictionary, jeder Raum besteht aus einem weiteren Dictionary und die dazugehörige Logik ist auf mehrere Funktionen verteilt.
Das funktioniert gut. Je größer unser Programm wird, desto mehr müssen wir aber selbst darauf achten, welche Daten und Funktionen zusammengehören.
Der Player State sieht beispielsweise so aus:
spieler = {
"name": "Karl",
"hp": 100,
"max_hp": 100,
"gold": 50,
"inventar": [],
}
Die Funktionen, die mit diesen Daten arbeiten, stehen an anderer Stelle:
zeige_inventar(spieler["inventar"])
erstelle_statuszeile(spieler)
nimm_gegenstand(raum, spieler["inventar"], name)
Mit Klassen können wir Daten und das dazugehörige Verhalten zu eigenen
Objekten zusammenfassen. Aus dem Dictionary spieler wird ein Spieler-Objekt,
das seinen State verwaltet und Methoden wie nimm(...), heile(...) oder
zeige_inventar() bereitstellt.
Klassen und Objekte
Eine Klasse definiert einen neuen Objekttyp. Sie beschreibt, welche Daten Objekte dieses Typs besitzen können und welches Verhalten ihnen zur Verfügung steht.
Ein Objekt ist eine konkrete Instanz dieser Klasse.
class Spieler:
def __init__(self, name):
self.name = name
self.hp = 100
Durch einen Aufruf der Klasse erzeugen wir ein Objekt:
held = Spieler("Karl")
Spieler ist die Klasse. held verweist auf eine konkrete Instanz dieser
Klasse.
Wir können beliebig viele voneinander unabhängige Spieler erzeugen:
held = Spieler("Karl")
magierin = Spieler("Lara")
print(held.name)
print(magierin.name)
Die Ausgabe lautet:
Karl
Lara
Beide Objekte gehören zur Klasse Spieler, besitzen aber ihren eigenen State:
held.hp = 75
print(held.hp)
print(magierin.hp)
Ausgabe:
75
100
Die Änderung an held beeinflusst magierin nicht. hp ist hier ein
Instanzattribut und gehört damit jeweils zu einem bestimmten Objekt.
Klassennamen werden großgeschrieben
Variablen und Funktionen schreiben wir üblicherweise in snake_case:
max_hp = 100
def zeige_status():
...
Für Klassennamen empfiehlt der Style Guide PEP 8 dagegen die CapWords-Schreibweise, die auch häufig PascalCase genannt wird. Jedes Wort beginnt mit einem Großbuchstaben und die Wörter werden ohne Unterstriche verbunden:
class Spieler:
...
class Gegenstand:
...
class MagischerGegenstand:
...
So lässt sich im Code meist schon am Namen erkennen, ob gerade eine Klasse, Funktion oder Variable gemeint ist.
__init__: den Start-State einrichten
Eine Klasse kann die besondere Methode __init__ definieren:
class Spieler:
def __init__(self, name, max_hp=100, gold=0):
self.name = name
self.hp = max_hp
self.max_hp = max_hp
self.gold = gold
self.inventar = []
Wenn wir anschließend ein Objekt erzeugen:
held = Spieler("Karl", max_hp=120, gold=50)
ruft Python __init__ automatisch für die neue Instanz auf. Danach besitzt
held die eingerichteten Attribute:
print(held.name)
print(held.hp)
print(held.max_hp)
print(held.gold)
print(held.inventar)
Ausgabe:
Karl
120
120
50
[]
__init__ wird umgangssprachlich oft als Konstruktor bezeichnet. Technisch
ist die Sache etwas genauer: __new__ ist für das Erzeugen der neuen Instanz
zuständig, __init__ initialisiert anschließend das bereits erzeugte Objekt.
Für normalen Anwendungscode müssen wir uns um __new__ fast nie selbst
kümmern. Für unseren Dungeon reicht es, __init__ als die Stelle zu betrachten,
an der ein neues Objekt seinen Start-State erhält.
Der Name __init__ beginnt und endet mit jeweils zwei Unterstrichen. Solche
Methoden werden häufig Dunder Methods genannt – kurz für double
underscore.
Was bedeutet self?
Der erste Parameter einer normalen Instanzmethode heißt nach Python-Konvention
self:
class Spieler:
def __init__(self, name):
self.name = name
self verweist auf die konkrete Instanz, für die die Methode gerade ausgeführt
wird.
Bei
held = Spieler("Karl")
verweist self während des __init__-Aufrufs auf das neu erzeugte
Spieler-Objekt.
Diese Zuweisung
self.name = name
verwendet zwei unterschiedliche Namen:
nameist der Parameter von__init__.self.nameist ein Attribut des Objekts.
Der lokale Parameter name wird nur während dieses Methodenaufrufs benötigt.
self.name bleibt anschließend als Teil des Objekts erhalten.
Beim Erzeugen des Spielers übergeben wir kein Argument für self:
held = Spieler("Karl")
Python kümmert sich beim gebundenen Methodenaufruf darum, die jeweilige Instanz als erstes Argument einzusetzen.
self ist dabei kein reserviertes Keyword. Technisch könnte der Parameter auch
anders heißen. Die Konvention ist in Python jedoch so etabliert, dass Du bei
normalen Instanzmethoden immer self verwenden solltest.
Attribute speichern den State
Werte, die zu einem Objekt gehören, speichern wir in Attributen:
held.name
held.hp
held.gold
held.inventar
Diese Instanzattribute bilden gemeinsam den State unseres Spielerobjekts.
Wir greifen mit einem Punkt darauf zu:
print(held.name)
held.gold = held.gold + 10
Bei normalen Python-Klassen können neue Attribute grundsätzlich auch später angelegt werden:
held.level = 1
Meist ist es übersichtlicher, die wesentlichen Instanzattribute bereits in
__init__ einzurichten. So ist an einer zentralen Stelle erkennbar, mit welchem
State ein neues Objekt startet.
Methoden: Verhalten am Objekt
Eine Methode ist vereinfacht gesagt eine Funktion, die über eine Klasse und ihre Instanzen zur Verfügung steht:
class Spieler:
def __init__(self, name, max_hp=100):
self.name = name
self.hp = max_hp
self.max_hp = max_hp
self.inventar = []
def nimm(self, gegenstand):
self.inventar.append(gegenstand)
def ist_besiegt(self):
return self.hp <= 0
Wir rufen die Methoden über ein Objekt auf:
held = Spieler("Karl")
held.nimm("Fackel")
print(held.inventar)
print(held.ist_besiegt())
Ausgabe:
['Fackel']
False
Die Methode nimm(...) benötigt das Inventar nicht als zusätzliches Argument.
Über self.inventar hat sie bereits Zugriff auf den State des konkreten
Spielerobjekts.
Der Aufruf
held.nimm("Fackel")
entspricht in diesem Fall:
Spieler.nimm(held, "Fackel")
Beim Zugriff über held wird aus der Funktion Spieler.nimm eine an diese
Instanz gebundene Methode. Beim Aufruf setzt Python held automatisch als
erstes Argument ein.
Methoden können den State verändern
Eine Methode kann Attribute des Objekts lesen und verändern:
class Spieler:
def __init__(self, name, max_hp=100):
self.name = name
self.hp = max_hp
self.max_hp = max_hp
def erleide_schaden(self, menge):
self.hp = max(self.hp - menge, 0)
def heile(self, menge):
self.hp = min(self.hp + menge, self.max_hp)
Verwendung:
held = Spieler("Karl")
held.erleide_schaden(30)
print(held.hp)
held.heile(20)
print(held.hp)
Ausgabe:
70
90
Die Regeln für die Lebenspunkte stecken nun direkt im Verhalten des Spielerobjekts:
- Schaden lässt
hpnicht unter0fallen. - Heilung lässt
hpnicht übermax_hpsteigen.
Der aufrufende Code muss nicht jedes Mal selbst daran denken, diese Grenzen einzuhalten.
Methoden können Werte zurückgeben
Methoden können genau wie normale Funktionen mit return einen Wert
zurückgeben:
class Spieler:
def __init__(self, name, hp=100):
self.name = name
self.hp = hp
def ist_besiegt(self):
return self.hp <= 0
Die Methode lässt sich direkt in einer Bedingung verwenden:
if held.ist_besiegt():
print("Dein Abenteuer endet hier.")
Eine Methode muss den State also nicht verändern. Sie kann ihn auch nur auswerten und daraus ein Ergebnis liefern.
__str__: ein Objekt als String darstellen
Geben wir ein eigenes Objekt ohne weitere Anpassung aus, erhalten wir zunächst eine technische Darstellung:
held = Spieler("Karl")
print(held)
Sie sieht beispielsweise so aus:
<__main__.Spieler object at 0x7f41d23a7e10>
Für uns ist daraus kaum etwas Interessantes über den Spieler abzulesen.
Mit der Dunder Method __str__ können wir festlegen, welche menschenlesbare
String-Darstellung unser Objekt besitzen soll:
class Spieler:
def __init__(self, name, max_hp=100, gold=0):
self.name = name
self.hp = max_hp
self.max_hp = max_hp
self.gold = gold
self.inventar = []
def __str__(self):
return (
f"{self.name} | "
f"HP: {self.hp}/{self.max_hp} | "
f"Gold: {self.gold} | "
f"Inventar: {len(self.inventar)}"
)
Nun funktioniert:
held = Spieler("Karl", gold=50)
print(held)
Ausgabe:
Karl | HP: 100/100 | Gold: 50 | Inventar: 0
print(...) verwendet für Objekte deren String-Darstellung. Bei unserem
Spieler kommt dadurch __str__ zum Einsatz.
Auch in einem f-String ohne besondere Formatangabe erhalten wir diese Darstellung:
print(f"Status: {held}")
Ausgabe:
Status: Karl | HP: 100/100 | Gold: 50 | Inventar: 0
__str__ muss einen String zurückgeben. Die Methode selbst sollte für
diesen Zweck nicht einfach mit print(...) etwas ausgeben.
__repr__: eine Darstellung für Entwickler
Neben __str__ gibt es __repr__.
Während __str__ vor allem eine angenehme Darstellung für Menschen liefern
soll, dient __repr__ eher einer eindeutigen und für Entwickler hilfreichen
Darstellung des Objekts.
class Gegenstand:
def __init__(self, name, beschreibung=""):
self.name = name
self.beschreibung = beschreibung
def __str__(self):
return self.name
def __repr__(self):
return (
f"Gegenstand("
f"name={self.name!r}, "
f"beschreibung={self.beschreibung!r}"
f")"
)
Verwendung:
fackel = Gegenstand(
"Fackel",
"Eine rußige, aber noch brauchbare Fackel.",
)
print(str(fackel))
print(repr(fackel))
Ausgabe:
Fackel
Gegenstand(name='Fackel', beschreibung='Eine rußige, aber noch brauchbare Fackel.')
Das !r im f-String verwendet für den jeweiligen Wert dessen repr(...).
Dadurch erscheinen Strings beispielsweise mit ihren Anführungszeichen.
Wenn es sinnvoll möglich ist, wird __repr__ häufig so gestaltet, dass die
Darstellung einem gültigen Python-Ausdruck ähnelt, mit dem sich ein
gleichwertiges Objekt erzeugen ließe. Das ist aber keine zwingende Voraussetzung.
__repr__ begegnet Dir unter anderem in der REPL, beim Debugging und bei der
Darstellung von Objekten innerhalb vieler Collections.
Für unseren Dungeon ist __str__ wichtiger. __repr__ nehmen wir trotzdem
mit, weil der Unterschied bei eigenen Objekten schnell praktisch wird.
Objekte können mit anderen Objekten zusammenarbeiten
Der Spieler soll keine einfachen Strings mehr in seinem Inventar speichern,
sondern echte Gegenstand-Objekte:
class Gegenstand:
def __init__(self, name, beschreibung=""):
self.name = name
self.beschreibung = beschreibung
def __str__(self):
return self.name
Wir erzeugen eine Fackel:
fackel = Gegenstand(
"Fackel",
"Eine rußige, aber noch brauchbare Fackel.",
)
Anschließend kann der Spieler dieses Objekt aufnehmen – mit der Spieler-Klasse
aus dem Abschnitt „Methoden: Verhalten am Objekt“, die nimm(...) besitzt:
held.nimm(fackel)
Das Inventar enthält nun ein Gegenstand-Objekt und nicht den String
"Fackel".
Für eine menschenlesbare Ausgabe können wir die Gegenstände mit str(...)
umwandeln:
print(", ".join(str(gegenstand) for gegenstand in held.inventar))
Ausgabe:
Fackel
Objekte aus anderen Objekten zusammenzusetzen, wird in der objektorientierten Programmierung häufig als Composition bezeichnet. Oft spricht man dabei auch von einer has-a-Beziehung: Ein Spieler hat Gegenstände in seinem Inventar.
Bei unserem Dungeon gibt es allerdings eine kleine Feinheit. Die Fackel wird unabhängig vom Spieler erzeugt und kann zwischen einem Raum und dem Inventar wechseln. In engerer OOP-Terminologie lässt sich dieses Verhältnis deshalb auch als Aggregation bezeichnen. Für unseren Code ist vor allem die praktische Idee wichtig: Unsere Objekte können Referenzen auf andere Objekte speichern und mit ihnen arbeiten.
Das nutzen wir an mehreren Stellen:
- Ein
Spielerbesitzt eine Liste mitGegenstand-Objekten. - Ein
Raumbesitzt ebenfalls eine Liste mitGegenstand-Objekten. - Das Dictionary
raeumeverweist auf mehrereRaum-Objekte.
So entsteht nach und nach ein Netz miteinander verbundener Objekte.
Klassen ersetzen nicht jede Collection
Klassen und Collections erfüllen unterschiedliche Aufgaben.
Ein Dictionary eignet sich weiterhin hervorragend, um über eine bekannte ID schnell den passenden Raum zu finden:
raeume = {
"halle": Raum(...),
"bibliothek": Raum(...),
"krypta": Raum(...),
}
Auch die Ausgänge eines Raums lassen sich sinnvoll als Dictionary speichern:
{
"norden": "bibliothek",
"osten": "krypta",
}
Die Klasse Raum ersetzt also nicht automatisch alle Dictionaries und Listen.
Sie bündelt den State und das Verhalten, die gemeinsam einen Raum ausmachen.
Innerhalb von Objekten werden wir weiterhin ständig Listen, Dictionaries, Sets und andere Collections verwenden.
Mutable Default-Werte vermeiden
Bei __init__ gelten dieselben Regeln für Default-Werte wie bei jeder anderen
Funktion oder Methode.
Diese Klasse enthält deshalb eine bekannte Falle:
class Raum:
def __init__(self, name, gegenstaende=[]):
self.name = name
self.gegenstaende = gegenstaende
Der Default-Wert [] wird beim Ausführen der Funktionsdefinition nur einmal
erzeugt. Alle Aufrufe ohne eigenes gegenstaende verwenden anschließend
dieselbe Liste:
halle = Raum("Halle")
krypta = Raum("Krypta")
halle.gegenstaende.append("Fackel")
print(krypta.gegenstaende)
Überraschende Ausgabe:
['Fackel']
Halle und Krypta verweisen hier auf dieselbe Liste.
Die übliche Lösung verwendet None als Default:
class Raum:
def __init__(self, name, gegenstaende=None):
self.name = name
if gegenstaende is None:
gegenstaende = []
self.gegenstaende = gegenstaende
Kompakter können wir schreiben:
class Raum:
def __init__(self, name, gegenstaende=None):
self.name = name
self.gegenstaende = [] if gegenstaende is None else gegenstaende
Für unseren vollständigen Dungeon gehen wir noch einen kleinen Schritt weiter und erstellen Kopien der übergebenen Collections:
self.ausgaenge = dict(ausgaenge) if ausgaenge is not None else {}
self.gegenstaende = list(gegenstaende) if gegenstaende is not None else []
Damit erhält jeder Raum eigene äußere Collections. Übergibt der aufrufende Code beispielsweise eine Liste mit Gegenständen, speichert der Raum nicht genau dieselbe Liste, sondern eine neue Liste mit denselben Elementen.
Das ist weiterhin nur eine flache Kopie. Die darin enthaltenen
Gegenstand-Objekte werden nicht dupliziert – und genau das wollen wir hier.
Die häufig anzutreffende Kurzform
self.gegenstaende = gegenstaende or []
ist nicht vollständig gleichwertig. Auch eine absichtlich übergebene leere Liste ist falsy und würde deshalb durch eine andere neue Liste ersetzt.
Mit
gegenstaende is None
prüfen wir genau den Fall, den wir tatsächlich meinen.
Der Dungeon, Schritt 7
Wir ersetzen nun die Dictionaries für Spieler und einzelne Räume durch eigene Objekte. Die äußere Dungeon-Welt bleibt ein Dictionary, damit wir Räume weiterhin bequem über ihre ID finden können.
Öffne dungeon.py und ersetze den bisherigen Inhalt durch folgenden Code:
# Dungeon – Teil 7: Klassen und Objekte
class Gegenstand:
"""Ein Gegenstand, der in einem Raum oder Inventar liegen kann."""
def __init__(self, name, beschreibung=""):
self.name = name
self.beschreibung = beschreibung
def __str__(self):
return self.name
def __repr__(self):
return (
f"Gegenstand("
f"name={self.name!r}, "
f"beschreibung={self.beschreibung!r}"
f")"
)
class Spieler:
"""Verwaltet den State und das Verhalten des Spielers."""
def __init__(self, name, max_hp=100, gold=0):
self.name = name
self.hp = max_hp
self.max_hp = max_hp
self.gold = gold
self.inventar = []
def nimm(self, gegenstand):
"""Legt einen Gegenstand ins Inventar."""
self.inventar.append(gegenstand)
def erleide_schaden(self, menge):
"""Verringert die HP, aber nicht unter null."""
self.hp = max(self.hp - menge, 0)
def heile(self, menge):
"""Erhöht die HP, aber nicht über max_hp."""
self.hp = min(self.hp + menge, self.max_hp)
def ist_besiegt(self):
"""Prüft, ob der Spieler keine HP mehr besitzt."""
return self.hp <= 0
def zeige_inventar(self):
"""Zeigt das Inventar als nummerierte Liste."""
if not self.inventar:
print("Dein Inventar ist leer.")
return
print("Inventar:")
for nummer, gegenstand in enumerate(self.inventar, start=1):
print(f" {nummer}) {gegenstand}")
def __str__(self):
return (
f"{self.name} | "
f"HP: {self.hp}/{self.max_hp} | "
f"Gold: {self.gold} | "
f"Inventar: {len(self.inventar)}"
)
class Raum:
"""Ein Raum mit Beschreibung, Ausgängen und Gegenständen."""
def __init__(
self,
name,
beschreibung,
ausgaenge=None,
gegenstaende=None,
):
self.name = name
self.beschreibung = beschreibung
self.ausgaenge = dict(ausgaenge) if ausgaenge is not None else {}
self.gegenstaende = (
list(gegenstaende)
if gegenstaende is not None
else []
)
def beschreibe(self):
"""Zeigt den Raum, seine Ausgänge und sichtbare Gegenstände."""
print()
print(f"Ort: {self.name}")
print(self.beschreibung)
print("Ausgänge:")
for nummer, richtung in enumerate(self.ausgaenge, start=1):
print(f" {nummer}) {richtung}")
if self.gegenstaende:
print(
"Hier liegt:",
", ".join(
str(gegenstand)
for gegenstand in self.gegenstaende
),
)
def zeige_gegenstaende(self):
"""Zeigt die Gegenstände des Raums."""
if not self.gegenstaende:
print("Hier liegt nichts Brauchbares.")
return
print(
"Hier liegt:",
", ".join(
str(gegenstand)
for gegenstand in self.gegenstaende
),
)
def hat_ausgang(self, richtung):
"""Prüft, ob der Raum einen Ausgang in die Richtung besitzt."""
return richtung in self.ausgaenge
def ziel(self, richtung):
"""Gibt die ID des Zielraums oder None zurück."""
return self.ausgaenge.get(richtung)
def nimm_gegenstand(self, name):
"""Entfernt einen passenden Gegenstand und gibt ihn zurück."""
gesuchter_name = name.strip().lower()
for gegenstand in self.gegenstaende:
if gegenstand.name.lower() == gesuchter_name:
self.gegenstaende.remove(gegenstand)
return gegenstand
return None
def erstelle_raeume():
"""Erstellt die Dungeon-Welt aus Raum- und Gegenstand-Objekten."""
fackel = Gegenstand(
"Fackel",
"Eine rußige, aber noch brauchbare Fackel.",
)
schluessel = Gegenstand(
"Schlüssel",
"Ein schwerer Eisenschlüssel mit rostigen Zähnen.",
)
return {
"halle": Raum(
name="Halle",
beschreibung=(
"Du stehst in einer dunklen Halle mit moderigem Geruch."
),
ausgaenge={
"norden": "bibliothek",
"osten": "krypta",
},
gegenstaende=[fackel],
),
"bibliothek": Raum(
name="Bibliothek",
beschreibung=(
"Staubige Regale voller zerfledderter Bücher umgeben Dich."
),
ausgaenge={
"sueden": "halle",
},
gegenstaende=[schluessel],
),
"krypta": Raum(
name="Krypta",
beschreibung=(
"Feuchtigkeit glänzt auf den Wänden der alten Krypta."
),
ausgaenge={
"westen": "halle",
},
),
}
def teile_befehl(eingabe):
"""Zerlegt den Input in ein Verb und ein optionales Argument."""
verb, _, argument = eingabe.strip().lower().partition(" ")
return verb, argument.strip()
def finde_raeume_mit_loot(raeume):
"""Gibt die Namen aller Räume mit Gegenständen zurück."""
return [
raum.name
for raum in raeume.values()
if raum.gegenstaende
]
def main():
"""Startet den Dungeon und führt den Game-Loop aus."""
raeume = erstelle_raeume()
print("Du stehst vor dem rostigen Tor eines vergessenen Verlieses.")
print("Ein kalter Luftzug weht Dir entgegen.")
name = input("Wie heißt Du, Abenteurer? ").strip()
gold = int(input("Wie viele Goldmünzen bringst Du mit? "))
spieler = Spieler(
name=name,
max_hp=100,
gold=gold,
)
ort = "halle"
besuchte_raeume = set()
raum_anzeigen = True
print()
print(f"Willkommen, {spieler.name}!")
print(
"Befehle: umsehen, nimm <Gegenstand>, inventar, status, "
"loot, geh <Richtung>, ende"
)
while True:
raum = raeume[ort]
if raum_anzeigen:
besuchte_raeume.add(ort)
raum.beschreibe()
raum_anzeigen = False
verb, argument = teile_befehl(input("\n> "))
if verb == "ende":
print("Du verlässt das Verlies. Bis zum nächsten Mal.")
break
elif verb == "umsehen":
raum.zeige_gegenstaende()
elif verb == "nimm":
if not argument:
print("Was möchtest Du nehmen?")
continue
gegenstand = raum.nimm_gegenstand(argument)
if gegenstand is None:
print(f"Hier liegt kein Gegenstand namens {argument}.")
continue
spieler.nimm(gegenstand)
print(f"Du nimmst {gegenstand}.")
if gegenstand.beschreibung:
print(gegenstand.beschreibung)
elif verb == "inventar":
spieler.zeige_inventar()
elif verb == "status":
print(spieler)
print(f"Besuchte Räume: {len(besuchte_raeume)}")
elif verb == "loot":
raeume_mit_loot = finde_raeume_mit_loot(raeume)
text = (
", ".join(raeume_mit_loot)
if raeume_mit_loot
else "(keine)"
)
print("Räume mit Loot:", text)
elif verb == "geh":
if not argument:
print("In welche Richtung möchtest Du gehen?")
continue
if not raum.hat_ausgang(argument):
print(f"Du kannst nicht nach {argument} gehen.")
continue
ort = raum.ziel(argument)
raum_anzeigen = True
elif not argument and raum.hat_ausgang(verb):
ort = raum.ziel(verb)
raum_anzeigen = True
else:
print("Das verstehe ich nicht.")
print("Mögliche Ausgänge:", ", ".join(raum.ausgaenge))
print(
"Weitere Befehle: umsehen, nimm <Gegenstand>, inventar, "
"status, loot, geh <Richtung>, ende"
)
if __name__ == "__main__":
main()
Führe das Programm aus:
python dungeon.py
Ein möglicher Durchlauf sieht so aus:
Du stehst vor dem rostigen Tor eines vergessenen Verlieses.
Ein kalter Luftzug weht Dir entgegen.
Wie heißt Du, Abenteurer? Karl
Wie viele Goldmünzen bringst Du mit? 50
Willkommen, Karl!
Befehle: umsehen, nimm <Gegenstand>, inventar, status, loot, geh <Richtung>, ende
Ort: Halle
Du stehst in einer dunklen Halle mit moderigem Geruch.
Ausgänge:
1) norden
2) osten
Hier liegt: Fackel
> nimm fackel
Du nimmst Fackel.
Eine rußige, aber noch brauchbare Fackel.
> inventar
Inventar:
1) Fackel
> geh norden
Ort: Bibliothek
Staubige Regale voller zerfledderter Bücher umgeben Dich.
Ausgänge:
1) sueden
Hier liegt: Schlüssel
> nimm schlüssel
Du nimmst Schlüssel.
Ein schwerer Eisenschlüssel mit rostigen Zähnen.
> status
Karl | HP: 100/100 | Gold: 50 | Inventar: 2
Besuchte Räume: 2
> loot
Räume mit Loot: (keine)
> ende
Du verlässt das Verlies. Bis zum nächsten Mal.
Was hat sich gegenüber Teil 6 geändert?
Der grundsätzliche Ablauf des Spiels ist gleich geblieben. Statt mit Dictionary-Schlüsseln arbeiten wir an vielen Stellen jetzt mit Attributen und Methoden.
Vorher:
gegenstand = nimm_gegenstand(raum, spieler["inventar"], argument)
Jetzt:
gegenstand = raum.nimm_gegenstand(argument)
spieler.nimm(gegenstand)
Vorher:
raum["gegenstaende"]
Jetzt:
raum.gegenstaende
Vorher:
if argument in raum["ausgaenge"]:
ort = raum["ausgaenge"][argument]
Jetzt:
if raum.hat_ausgang(argument):
ort = raum.ziel(argument)
Methoden wie hat_ausgang(...) und ziel(...) bilden eine Schnittstelle zum
Raum. Der Game-Loop muss für diese Operationen nicht mehr wissen, dass die
Ausgänge intern in einem Dictionary gespeichert sind.
Der Spieler verwaltet seinen eigenen State
Der Player State wird beim Erzeugen des Objekts eingerichtet:
spieler = Spieler(
name=name,
max_hp=100,
gold=gold,
)
Anschließend greifen wir über Attribute darauf zu:
spieler.name
spieler.hp
spieler.max_hp
spieler.gold
spieler.inventar
Das dazugehörige Verhalten steckt in Methoden der Klasse:
spieler.nimm(gegenstand)
spieler.erleide_schaden(25)
spieler.heile(20)
spieler.ist_besiegt()
spieler.zeige_inventar()
State und das Verhalten, das unmittelbar damit arbeitet, liegen damit an derselben Stelle im Programm.
Das bedeutet nicht, dass künftig jede Funktion zu einer Methode werden muss.
teile_befehl(...) gehört beispielsweise weder fachlich zu einem Spieler noch
zu einem Raum und bleibt deshalb eine normale Funktion.
Gegenstände wechseln zwischen Objekten
Zu Beginn liegt die Fackel in der Gegenstandsliste der Halle.
Der Aufruf
gegenstand = raum.nimm_gegenstand("fackel")
entfernt das Gegenstand-Objekt aus der Liste des Raums und gibt genau dieses
Objekt zurück.
Anschließend legt
spieler.nimm(gegenstand)
dieselbe Fackel in das Inventar des Spielers.
Es wird dabei kein neues Gegenstand-Objekt erzeugt. Die vorhandene Instanz
wechselt lediglich von einer Collection in eine andere.
Das ist ein wichtiger Unterschied zu unserem früheren Dungeon, in dem
Gegenstände lediglich Strings wie "Fackel" waren. Die Fackel kann jetzt
eigenen State besitzen – beispielsweise eine Beschreibung und später vielleicht
Gewicht, Zustand oder Brenndauer.
Methoden bilden eine Schnittstelle
Im Game-Loop könnten wir weiterhin direkt auf viele Attribute zugreifen:
raum.ausgaenge
raum.gegenstaende
spieler.inventar
Für häufige Operationen oder Regeln verwenden wir stattdessen Methoden:
raum.hat_ausgang(richtung)
raum.nimm_gegenstand(name)
spieler.nimm(gegenstand)
spieler.heile(menge)
Diese Methoden bilden eine kleine API der jeweiligen Klasse.
Dadurch hängt der restliche Code weniger stark davon ab, wie ein Objekt seine Daten intern speichert.
Beispielsweise könnte Raum seine Ausgänge irgendwann anders organisieren.
Solange
raum.hat_ausgang(richtung)
raum.ziel(richtung)
weiterhin dasselbe Verhalten anbieten, müsste der Game-Loop dafür nicht zwangsläufig verändert werden.
Unsere Klassen sind dabei noch keineswegs vollständig gekapselt. Attribute wie
raum.ausgaenge oder spieler.hp bleiben direkt zugänglich. Python setzt bei
solchen Schnittstellen häufig stärker auf Konventionen als auf erzwungene
Zugriffsbeschränkungen.
Ist das bereits objektorientierte Programmierung?
Ja. Wir modellieren Teile unseres Spiels als Objekte mit eigenem State und passendem Verhalten.
Objektorientierte Programmierung bietet darüber hinaus viele weitere Konzepte und Techniken, zum Beispiel:
- Encapsulation
- Inheritance
- Polymorphism
- Class Methods und Static Methods
- Properties
- Abstract Base Classes
Die müssen wir aber nicht automatisch verwenden, nur weil unser Programm Klassen enthält.
Für den Dungeon reicht zunächst eine deutlich einfachere Idee:
Eine Klasse kann Daten und das Verhalten bündeln, die fachlich zusammengehören.
Auch daraus folgt nicht, dass aus jedem Dictionary sofort eine Klasse werden sollte. Eine Klasse bietet sich vor allem dann an, wenn etwas im Programm eine erkennbare eigene Einheit darstellt und mit diesen Daten auch eigenes Verhalten verbunden ist.
Stolperfallen
selfvergessen: Innerhalb einer Instanzmethode greifst Du überselfauf die Attribute des jeweiligen Objekts zu.
python
self.hp = self.hp - schaden
hp allein wäre ein anderer Name und würde hier nicht automatisch das
Instanzattribut meinen.
-
selfbeim normalen Methodenaufruf selbst übergeben: Beispieler.heile(20)bindet Python die Instanz automatisch anself.spieler.heile(spieler, 20)würde deshalb ein Argument zu viel übergeben. -
Die Dunder Method falsch schreiben: Es heißt
__init__, mit jeweils zwei Unterstrichen vor und nachinit. -
Aus
__init__einen eigenen Wert zurückgeben:__init__initialisiert eine Instanz und mussNonezurückgeben. Einreturnohne Wert wäre zwar möglich, ein anderer Return Value führt jedoch zu einemTypeError. -
Mutable Default-Werte verwenden: Eine Liste oder ein Dictionary direkt als Default-Wert wird nicht für jede Instanz neu erzeugt.
python
def __init__(self, inventar=[]):
...
Verwende normalerweise None und erzeuge die Collection innerhalb der
Methode.
-
wert or []mit einerNone-Prüfung gleichsetzen: Eine absichtlich übergebene leere Collection ist ebenfalls falsy.is Noneprüft genauer, ob überhaupt kein Wert übergeben wurde. -
Klasse und Objekt verwechseln:
Spielerist die Klasse.Spieler("Karl")erzeugt eine Instanz dieser Klasse. -
Methode und Methodenaufruf verwechseln:
spieler.zeige_inventarliefert das Methodenobjekt. Erstspieler.zeige_inventar()ruft die Methode auf. -
__str__direkt ausgeben lassen:__str__muss einen String zurückgeben. Die Ausgabe übernimmt anschließend beispielsweiseprint(...). -
Composition und Aggregation für zwingende Python-Konzepte halten: Das sind Begriffe zur Beschreibung von Beziehungen zwischen Objekten, keine besondere Python-Syntax.
-
Alle Daten zwanghaft in Klassen verpacken: Listen, Sets und Dictionaries bleiben weiterhin sinnvoll. Klassen ergänzen Collections, sie ersetzen sie nicht.
-
Zu viel Verhalten in eine Klasse legen: Nur weil eine Funktion technisch eine Methode von
Spielersein könnte, gehört sie dort nicht automatisch hin. Das Verhalten sollte fachlich zum Objekt passen.
Übungen
1. Schaden verursachen
Ergänze die Klasse Spieler um eine Methode erleide_schaden(menge). Sie soll
die HP verringern, aber nicht unter 0 fallen lassen.
Lösung
def erleide_schaden(self, menge):
"""Verringert die HP, aber nicht unter null."""
self.hp = max(self.hp - menge, 0)
spieler.erleide_schaden(25)
print(spieler.hp)
2. Einen Ausgang prüfen
Ergänze die Klasse Raum um eine Methode hat_ausgang(richtung), die True
zurückgibt, wenn der Raum einen passenden Ausgang besitzt.
Lösung
def hat_ausgang(self, richtung):
"""Prüft, ob der Raum einen Ausgang in die Richtung besitzt."""
return richtung in self.ausgaenge
if raum.hat_ausgang("norden"):
print("Du kannst nach Norden gehen.")
3. Gegenstände im Inventar suchen
Ergänze Spieler um eine Methode hat_gegenstand(name). Groß- und
Kleinschreibung sollen keine Rolle spielen.
Lösung
def hat_gegenstand(self, name):
"""Prüft, ob ein Gegenstand im Inventar liegt."""
gesuchter_name = name.strip().lower()
return any(
gegenstand.name.lower() == gesuchter_name
for gegenstand in self.inventar
)
if spieler.hat_gegenstand("schlüssel"):
print("Du kannst die Tür aufschließen.")
4. Einen Gegenstand genauer darstellen
Ergänze Gegenstand um eine Methode untersuche(), die entweder die
Beschreibung oder einen Default-Text zurückgibt.
Lösung
def untersuche(self):
"""Gibt die Beschreibung des Gegenstands zurück."""
if self.beschreibung:
return self.beschreibung
return f"An {self.name} fällt Dir nichts Besonderes auf."
print(fackel.untersuche())
Vertiefung
Für Klassen, die hauptsächlich Daten speichern, kann Python viel Boilerplate automatisch erzeugen:
Dataclasses: Klassen für strukturierte Daten
Warum Methoden wie __init__, __str__ und __repr__ funktionieren und wie
eigene Objekte sich wie eingebaute Datentypen verhalten:
Das Python-Datenmodell: Dunder Methods
Im nächsten Teil
Unser Dungeon besitzt jetzt echte Objekte mit eigenem State und Verhalten. Bei der Eingabe des Goldwerts vertraut das Programm aber weiterhin darauf, dass der Spieler tatsächlich einen gültigen Integer eingibt.
In Teil 8 geht es deshalb um
Fehlerbehandlung: Exceptions, try, except, else und finally. Damit
reagiert der Dungeon kontrolliert auf ungültigen Input und andere Probleme,
statt mit einem Traceback abzubrechen.
0 Kommentare
Noch keine Kommentare. Sei der/die Erste!
Anmelden um einen Kommentar zu hinterlassen.