Zum Inhalt springen

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:

  • name ist der Parameter der Methode.
  • self.name ist 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:

  • hp kann nicht unter 0 fallen.
  • hp kann nicht über max_hp steigen.

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 Spieler besitzt eine Liste mit Gegenstand-Objekten.
  • Ein Raum besitzt ebenfalls eine Liste mit Gegenstand-Objekten.
  • Das Dictionary raeume enthält mehrere Raum-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

  • self vergessen: Innerhalb einer Methode greifst Du über self auf Attribute des Objekts zu.

python self.hp = self.hp - schaden

hp allein würde nach einer lokalen Variablen suchen.

  • self beim Aufruf übergeben: Bei spieler.heile(20) setzt Python das Spielerobjekt automatisch als self ein. spieler.heile(spieler, 20) wäre falsch.

  • Die Dunder Method falsch schreiben: Es heißt __init__, mit jeweils zwei Unterstrichen vor und nach init.

  • __init__ etwas zurückgeben lassen: __init__ richtet das Objekt ein und darf keinen anderen Wert als None zurü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 einer None-Prüfung gleichsetzen: Eine leere übergebene Collection ist ebenfalls falsy. Prüfe ausdrücklich mit is None.

  • Klasse und Objekt verwechseln: Spieler bezeichnet die Klasse. Spieler("Karl") erzeugt ein Objekt dieser Klasse.

  • Methode und Methodenaufruf verwechseln: spieler.zeige_inventar bezeichnet die Methode. Erst spieler.zeige_inventar() führt sie aus.

  • __str__ direkt ausgeben lassen: __str__ muss einen String zurückgeben. Die Ausgabe übernimmt anschließend print(...).

  • 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 Spieler stehen 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)
Verwendung:
spieler.erleide_schaden(25)

print(spieler.hp)
`max(...)` verhindert, dass die Lebenspunkte negativ werden.

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
Verwendung:
if raum.hat_ausgang("norden"):
    print("Du kannst nach Norden gehen.")
Der Mitgliedschaftstest ergibt bereits einen Boolean und kann deshalb direkt zurückgegeben werden.

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
    )
Verwendung:
if spieler.hat_gegenstand("schlüssel"):
    print("Du kannst die Tür aufschließen.")
`any(...)` gibt `True` zurück, sobald mindestens ein passender Gegenstand gefunden wurde.

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."
Verwendung:
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!