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, hat aber einen Nachteil: Daten und Verhalten liegen getrennt voneinander.
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 zusammengehörige Daten und das passende Verhalten
bündeln. Aus dem Dictionary spieler wird ein Spieler-Objekt, das seinen
eigenen State verwaltet und Methoden wie nimm(...), heile(...) oder
zeige_inventar() bereitstellen kann.
Klassen und Objekte
Eine Klasse beschreibt, welche Attribute und welches Verhalten eine bestimmte Art von Objekt besitzt.
Ein Objekt ist eine konkrete Instanz dieser Klasse.
class Spieler:
def __init__(self, name):
self.name = name
self.hp = 100
Mit einem Aufruf der Klasse erzeugen wir ein Objekt:
held = Spieler("Karl")
Spieler ist die Klasse. held verweist auf ein konkretes
Spieler-Objekt.
Wir können mehrere unabhängige Objekte derselben Klasse erzeugen:
held = Spieler("Karl")
magierin = Spieler("Lara")
print(held.name)
print(magierin.name)
Die Ausgabe lautet:
Karl
Lara
Beide Objekte folgen demselben Bauplan, besitzen aber ihren eigenen State.
held.hp = 75
print(held.hp)
print(magierin.hp)
Ausgabe:
75
100
Die Änderung an held beeinflusst magierin nicht.
Klassennamen werden großgeschrieben
Variablen und Funktionen schreiben wir üblicherweise in snake_case:
max_hp = 100
def zeige_status():
...
Für Klassen verwendet Python normalerweise PascalCase. Dabei beginnen alle Wörter mit einem Großbuchstaben und werden ohne Unterstriche verbunden:
class Spieler:
...
class Gegenstand:
...
class MagischerGegenstand:
...
Die Großschreibung hilft dabei, Klassen auf den ersten Blick von Funktionen und Variablen zu unterscheiden.
__init__: den Start-State einrichten
Die Methode __init__ wird automatisch aufgerufen, nachdem Python ein neues
Objekt erzeugt hat:
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 = []
Beim Aufruf
held = Spieler("Karl", max_hp=120, gold=50)
erhält das Objekt diese Attribute:
print(held.name)
print(held.hp)
print(held.max_hp)
print(held.gold)
print(held.inventar)
Ausgabe:
Karl
120
120
50
[]
__init__ wird häufig als Konstruktor bezeichnet. Technisch erzeugt jedoch
zunächst die Methode __new__ das Objekt; __init__ richtet anschließend
dessen Start-State ein. Für unseren normalen Anwendungscode müssen wir uns um
__new__ nicht kümmern.
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 üblicherweise self:
class Spieler:
def __init__(self, name):
self.name = name
self verweist auf das konkrete Objekt, für das 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
verbindet zwei unterschiedliche Dinge:
nameist der Parameter der Methode.self.nameist ein Attribut des Objekts.
Nach dem Ende von __init__ verschwindet der lokale Parameter name. Das
Attribut self.name bleibt dagegen Teil des Objekts.
Beim Erzeugen des Objekts übergeben wir kein Argument für self:
held = Spieler("Karl")
Python setzt das konkrete Objekt automatisch als erstes Argument ein.
self ist dabei kein reserviertes Keyword. Technisch wäre auch ein anderer
Name möglich. Die gesamte Python-Welt verwendet jedoch die Konvention self,
weshalb Du davon nicht abweichen solltest.
Attribute speichern den State
Ein Attribut ist ein Wert, der zu einem Objekt gehört:
held.name
held.hp
held.gold
held.inventar
Diese Attribute bilden gemeinsam den State unseres Spielerobjekts.
Wir greifen mit einem Punkt darauf zu:
print(held.name)
held.gold = held.gold + 10
Neue Attribute könnten grundsätzlich auch nachträglich angelegt werden:
held.level = 1
Üblicherweise werden die wesentlichen Instanzattribute aber bereits in
__init__ definiert. Dadurch ist an einer zentralen Stelle erkennbar, welchen
State ein Objekt besitzt.
Methoden: Verhalten am Objekt
Eine Methode ist eine Funktion, die innerhalb einer Klasse definiert ist.
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 das 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
Objekts.
Der Aufruf
held.nimm("Fackel")
entspricht konzeptionell ungefähr:
Spieler.nimm(held, "Fackel")
Python übergibt held beim Methodenaufruf automatisch als self.
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
Das Objekt verwaltet damit selbst die Regeln für seine Lebenspunkte:
hpkann nicht unter0fallen.hpkann nicht übermax_hpsteigen.
Außerhalb der Klasse müssen wir diese Regeln nicht bei jeder Änderung erneut implementieren.
Methoden können Werte zurückgeben
Methoden funktionieren in dieser Hinsicht wie normale Funktionen. Sie können
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 kann direkt in einer Bedingung verwendet werden:
if held.ist_besiegt():
print("Dein Abenteuer endet hier.")
Eine Methode muss den State nicht zwangsläufig verändern. Sie kann ihn auch nur auswerten und daraus ein Ergebnis berechnen.
__str__: ein Objekt als String darstellen
Geben wir ein Objekt ohne weitere Anpassung aus, erhalten wir eine wenig hilfreiche Darstellung:
held = Spieler("Karl")
print(held)
Die Ausgabe sieht ungefähr so aus:
<__main__.Spieler object at 0x7f41d23a7e10>
Sie zeigt die Klasse und eine interne Speicheradresse, sagt aber wenig über den Player State aus.
Mit der Dunder Method __str__ legen wir fest, wie das Objekt für Menschen als
String dargestellt werden 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(...) ruft intern str(...) auf. str(...) verwendet bei unserem Objekt
wiederum die Methode __str__.
Auch in einem f-String wird diese Darstellung verwendet:
print(f"Status: {held}")
Ausgabe:
Status: Karl | HP: 100/100 | Gold: 50 | Inventar: 0
__str__ muss immer einen String zurückgeben. Eine direkte Ausgabe mit
print(...) innerhalb der Methode wäre falsch.
__repr__: eine Darstellung für Entwickler
Neben __str__ existiert __repr__. Diese Methode soll eine möglichst
eindeutige und für Entwickler hilfreiche Darstellung liefern.
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.')
Für normale Ausgaben verwenden wir __str__. __repr__ ist besonders in der
REPL, in Listen und beim Debugging hilfreich.
Für den Einstieg ist __repr__ optional. __str__ reicht für unseren Dungeon
zunächst aus.
Objekte können andere Objekte enthalten
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 einen Gegenstand:
fackel = Gegenstand(
"Fackel",
"Eine rußige, aber noch brauchbare Fackel.",
)
Anschließend kann der Spieler dieses Objekt aufnehmen:
held.nimm(fackel)
Das Inventar enthält nun ein Gegenstand-Objekt:
print(held.inventar)
Ohne __repr__ sieht die Liste möglicherweise technisch aus. Für eine
menschenlesbare Ausgabe wandeln wir jedes Objekt mit str(...) um:
print(", ".join(str(gegenstand) for gegenstand in held.inventar))
Ausgabe:
Fackel
Dass Objekte andere Objekte enthalten, wird Composition genannt.
Unser Dungeon nutzt Composition an mehreren Stellen:
- Ein
Spielerbesitzt eine Liste mitGegenstand-Objekten. - Ein
Raumbesitzt ebenfalls eine Liste mitGegenstand-Objekten. - Das Dictionary
raeumeenthält mehrereRaum-Objekte.
Klassen ersetzen nicht jede Collection
Klassen und Dictionaries erfüllen unterschiedliche Aufgaben.
Ein Dictionary eignet sich weiterhin hervorragend, um über eine bekannte ID schnell einen 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 lediglich die Werte und Methoden, die gemeinsam einen Raum
beschreiben.
Python-Programme kombinieren Klassen fast immer mit Collections.
Mutable Default-Werte vermeiden
Betrachten wir diese scheinbar praktische Klasse:
class Raum:
def __init__(self, name, gegenstaende=[]):
self.name = name
self.gegenstaende = gegenstaende
Der Default-Wert [] wird nur einmal beim Definieren der Methode erzeugt.
Dadurch teilen sich alle ohne eigenes Argument erstellten Räume dieselbe Liste:
halle = Raum("Halle")
krypta = Raum("Krypta")
halle.gegenstaende.append("Fackel")
print(krypta.gegenstaende)
Überraschende Ausgabe:
['Fackel']
Die Krypta enthält dieselbe Liste wie die Halle.
Verwende deshalb None als Default:
class Raum:
def __init__(self, name, gegenstaende=None):
self.name = name
if gegenstaende is None:
gegenstaende = []
self.gegenstaende = gegenstaende
Kompakter geht es so:
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 kopieren wir übergebene Collections zusätzlich:
self.ausgaenge = dict(ausgaenge) if ausgaenge is not None else {}
self.gegenstaende = list(gegenstaende) if gegenstaende is not None else []
Dadurch erhält jeder Raum eigene äußere Collections.
Die häufig verwendete Schreibweise
self.gegenstaende = gegenstaende or []
ist nicht vollständig gleichwertig. Eine absichtlich übergebene leere Liste ist
falsy und würde deshalb durch eine neue Liste ersetzt. Die ausdrückliche Prüfung
mit is None beschreibt genauer, was gemeint ist.
Der Dungeon, Schritt 7
Wir ersetzen nun die Dictionaries für Spieler und einzelne Räume durch Objekte. Die äußere Struktur der Welt bleibt weiterhin ein Dictionary, damit wir Räume 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 inventar_text(self):
"""Gibt das Inventar als String zurück."""
if not self.inventar:
return "(leer)"
return ", ".join(str(gegenstand) for gegenstand in self.inventar)
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"
)
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. Die Daten werden nun aber nicht mehr über beliebige Dictionary-Schlüssel angesprochen.
Vorher:
spieler["inventar"].append(gegenstand)
Jetzt:
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(...) verstecken die konkrete interne
Struktur. Der Game-Loop muss nicht mehr wissen, dass die Ausgänge in einem
Dictionary gespeichert werden.
Der Spieler verwaltet seinen eigenen State
Der Player State wird beim Erzeugen des Objekts eingerichtet:
spieler = Spieler(
name=name,
max_hp=100,
gold=gold,
)
Der Zugriff erfolgt anschließend über Attribute:
spieler.name
spieler.hp
spieler.max_hp
spieler.gold
spieler.inventar
Passendes Verhalten liegt direkt in der Klasse:
spieler.nimm(gegenstand)
spieler.erleide_schaden(25)
spieler.heile(20)
spieler.ist_besiegt()
spieler.zeige_inventar()
Statt Daten und Funktionen getrennt zu verwalten, bildet das Objekt eine zusammengehörige Einheit.
Gegenstände wechseln zwischen Objekten
Zu Beginn befindet sich die Fackel im Raumobjekt der Halle:
halle.gegenstaende
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)
dasselbe Objekt in das Inventar des Spielers.
Es wird dabei keine neue Fackel erzeugt. Das vorhandene Objekt wechselt lediglich von einer Collection in eine andere.
Methoden bilden eine öffentliche Schnittstelle
Im Game-Loop könnten wir weiterhin direkt auf alle Attribute zugreifen:
raum.ausgaenge
raum.gegenstaende
spieler.inventar
Für häufige oder regelbehaftete Operationen verwenden wir jedoch Methoden:
raum.hat_ausgang(richtung)
raum.nimm_gegenstand(name)
spieler.nimm(gegenstand)
spieler.heile(menge)
Diese Methoden bilden eine kleine API der jeweiligen Klasse.
Später könnten wir die interne Speicherung ändern, ohne alle Aufrufe im Game-Loop anpassen zu müssen.
Beispielsweise könnte Raum seine Ausgänge irgendwann nicht mehr in einem
Dictionary speichern. Solange hat_ausgang(...) und ziel(...) gleich
funktionieren, müsste der Game-Loop davon nichts wissen.
Ist das bereits objektorientierte Programmierung?
Ja. Wir modellieren Bestandteile unseres Spiels als Objekte, die eigenen State und eigenes Verhalten besitzen.
Objektorientierte Programmierung umfasst allerdings noch weitere Konzepte:
- Encapsulation
- Inheritance
- Polymorphism
- Class Methods und Static Methods
- Properties
- abstrakte Basisklassen
Diese Techniken sind nicht notwendig, nur weil Klassen verwendet werden. Für unseren Dungeon reicht zunächst die wichtigste Idee:
Ein Objekt bündelt zusammengehörigen State und das Verhalten, das mit diesem State arbeitet.
Eine Klasse sollte nicht allein deshalb entstehen, weil irgendein Dictionary mehrere Werte enthält. Sie ist besonders sinnvoll, wenn die Daten eine erkennbare fachliche Einheit bilden und eigenes Verhalten besitzen.
Stolperfallen
selfvergessen: Innerhalb einer Methode greifst Du überselfauf Attribute des Objekts zu.
python
self.hp = self.hp - schaden
hp allein würde nach einer lokalen Variablen suchen.
-
selfbeim Aufruf übergeben: Beispieler.heile(20)setzt Python das Spielerobjekt automatisch alsselfein.spieler.heile(spieler, 20)wäre falsch. -
Die Dunder Method falsch schreiben: Es heißt
__init__, mit jeweils zwei Unterstrichen vor und nachinit. -
__init__etwas zurückgeben lassen:__init__richtet das Objekt ein und darf keinen anderen Wert alsNonezurückgeben. -
Mutable Default-Werte verwenden: Eine Liste oder ein Dictionary direkt als Default wird zwischen mehreren Objekten geteilt.
python
def __init__(self, inventar=[]):
...
Verwende stattdessen None.
-
wert or []mit einerNone-Prüfung gleichsetzen: Eine leere übergebene Collection ist ebenfalls falsy. Prüfe ausdrücklich mitis None. -
Klasse und Objekt verwechseln:
Spielerbezeichnet die Klasse.Spieler("Karl")erzeugt ein Objekt dieser Klasse. -
Methode und Methodenaufruf verwechseln:
spieler.zeige_inventarbezeichnet die Methode. Erstspieler.zeige_inventar()führt sie aus. -
__str__direkt ausgeben lassen:__str__muss einen String zurückgeben. Die Ausgabe übernimmt anschließendprint(...). -
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 Methode technisch in
Spielerstehen könnte, gehört sie nicht automatisch dorthin. Die Methode 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 Datengeplant
Warum Methoden wie __init__, __str__ und __repr__ funktionieren und wie
eigene Objekte sich wie eingebaute Datentypen verhalten:
Das Python-Datenmodell: Dunder Methodsgeplant
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 eine gültige Ganzzahl eingibt.
In Teil 8geplant 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.