Zum Inhalt springen

cat posts/python-lernen-05-funktionen.md

Python lernen (Teil 5): Funktionen

Funktionen zerlegen Code in benannte, wiederverwendbare Blöcke. Mit Parametern, Rückgabewerten und Scope wird unser Game-Loop wieder übersichtlich.

In Teil 4 haben wir die Räume unseres Dungeons als Daten beschrieben und den Player State in einem Dictionary zusammengefasst. Dadurch müssen wir nicht mehr für jeden Raum eigenen Code schreiben.

Unser Game-Loop funktioniert, enthält inzwischen aber viele unterschiedliche Aufgaben: Er zeigt Räume an, verwaltet das Inventar, gibt den Player State aus und verarbeitet die Befehle des Spielers.

Heute zerlegen wir diese Logik in Funktionen. Eine Funktion bündelt zusammengehörigen Code unter einem Namen. Dadurch können wir ihn gezielt aufrufen, wiederverwenden und unabhängig vom restlichen Programm weiterentwickeln.

Eine Funktion definieren

Eine Funktion wird mit dem Keyword def definiert:

def begruesse():
    print("Willkommen im Verlies!")

Nach def folgen:

  1. der Name der Funktion,
  2. runde Klammern,
  3. ein Doppelpunkt,
  4. der eingerückte Funktionskörper.

Die Definition allein führt den Funktionskörper noch nicht aus:

def begruesse():
    print("Willkommen im Verlies!")

Erst der Aufruf mit runden Klammern startet die Funktion:

begruesse()

Die Ausgabe lautet:

Willkommen im Verlies!

Du kannst die Funktion beliebig oft aufrufen:

begruesse()
begruesse()
begruesse()

Dabei wird ihr Funktionskörper bei jedem Aufruf erneut ausgeführt.

Funktionen sinnvoll benennen

Der Name einer Funktion sollte möglichst deutlich machen, was sie tut.

Gute Funktionsnamen beginnen häufig mit einem Verb:

zeige_raum()
berechne_schaden()
nimm_gegenstaende()
erstelle_dungeon()

Wenig aussagekräftig wären dagegen Namen wie:

mach()
funktion1()
daten()

Für Funktionsnamen gelten dieselben grundlegenden Regeln wie für Variablennamen. Mehrere Wörter werden üblicherweise in snake_case geschrieben:

zeige_player_state()
berechne_verbleibende_hp()

Parameter: Werte an eine Funktion übergeben

Die bisherige Funktion gibt immer denselben Text aus. Soll sie mit unterschiedlichen Werten arbeiten, erhält sie Parameter:

def begruesse(name):
    print(f"Willkommen, {name}!")

Beim Aufruf übergeben wir einen konkreten Wert:

begruesse("Karl")
begruesse("Lara")

Die Ausgabe lautet:

Willkommen, Karl!
Willkommen, Lara!

Innerhalb der Funktion verhält sich name wie eine lokale Variable. Bei jedem Aufruf erhält sie den jeweils übergebenen Wert.

Genau genommen unterscheiden wir zwischen Parameter und Argument:

  • name ist der Parameter in der Funktionsdefinition.
  • "Karl" ist das Argument beim Funktionsaufruf.

Im Alltag werden beide Begriffe allerdings häufig nicht streng voneinander getrennt.

Mehrere Parameter

Eine Funktion kann mehrere Parameter besitzen:

def zeige_status(name, hp, max_hp):
    print(f"{name} | HP: {hp}/{max_hp}")

Beim Aufruf werden die Argumente in derselben Reihenfolge zugeordnet:

zeige_status("Karl", 75, 100)

Die Ausgabe lautet:

Karl | HP: 75/100

Die Zuordnung sieht dabei so aus:

Parameter Argument
name "Karl"
hp 75
max_hp 100

Diese Form wird als Übergabe über die Position bezeichnet.

Keyword Arguments

Du kannst Argumente beim Aufruf auch ausdrücklich über den Namen des Parameters zuordnen:

zeige_status(name="Karl", hp=75, max_hp=100)

Solche Argumente heißen Keyword Arguments.

Bei ihnen spielt die Reihenfolge keine Rolle:

zeige_status(max_hp=100, name="Karl", hp=75)

Das Ergebnis ist dasselbe.

Keyword Arguments können längere Aufrufe leichter lesbar machen:

zeige_status(
    name="Karl",
    hp=75,
    max_hp=100,
)

Du kannst Positionsargumente und Keyword Arguments kombinieren. Dabei müssen die Positionsargumente zuerst stehen:

zeige_status("Karl", hp=75, max_hp=100)

Diese Schreibweise wäre dagegen ungültig:

zeige_status(name="Karl", 75, 100)

Nach einem Keyword Argument dürfen keine einfachen Positionsargumente mehr folgen.

Default-Werte

Parameter können einen Default-Wert besitzen. Dieser wird verwendet, wenn beim Aufruf kein eigenes Argument angegeben wird:

def verursache_schaden(menge=10):
    print(f"Du verlierst {menge} Lebenspunkte.")

Ohne Argument verwendet die Funktion den Default-Wert:

verursache_schaden()

Ausgabe:

Du verlierst 10 Lebenspunkte.

Du kannst ihn beim Aufruf überschreiben:

verursache_schaden(25)

Ausgabe:

Du verlierst 25 Lebenspunkte.

Auch ein Keyword Argument ist möglich:

verursache_schaden(menge=25)

Parameter ohne Default-Wert müssen vor Parametern mit Default-Wert stehen:

def zeige_status(name, hp, max_hp=100):
    print(f"{name} | HP: {hp}/{max_hp}")

Jetzt können wir max_hp beim Aufruf weglassen:

zeige_status("Karl", 75)

Python verwendet automatisch den Wert 100.

Ein Pflichtparameter darf nicht hinter einem Parameter mit Default-Wert stehen:

# Ungültig:
def zeige_status(max_hp=100, name):
    print(f"{name} | HP: {max_hp}")

Python könnte sonst Positionsargumente nicht eindeutig zuordnen.

return: Ein Ergebnis zurückgeben

Eine Funktion kann nicht nur etwas ausgeben, sondern auch ein Ergebnis an die aufrufende Stelle zurückgeben.

Dafür verwenden wir return:

def erstelle_statuszeile(name, hp, max_hp=100):
    return f"{name} | HP: {hp}/{max_hp}"

Beim Aufruf erhalten wir den zurückgegebenen String:

status = erstelle_statuszeile("Karl", 75)

print(status)

Die Ausgabe lautet:

Karl | HP: 75/100

Der wichtige Unterschied:

  • print(...) zeigt einen Wert im Terminal an.
  • return gibt einen Wert an den aufrufenden Code zurück.

Mit einem Return Value kannst Du anschließend weiterarbeiten:

status = erstelle_statuszeile("Karl", 75)
laenge = len(status)

print(f"Die Statuszeile ist {laenge} Zeichen lang.")

Eine Funktion, die nur etwas ausgibt, stellt dem aufrufenden Code dagegen keinen solchen Wert zur weiteren Verarbeitung bereit.

return beendet die Funktion

Sobald Python ein return erreicht, wird die Funktion sofort beendet:

def pruefe_hp(hp):
    if hp <= 0:
        return "besiegt"

    return "lebendig"

Bei hp <= 0 wird der erste Wert zurückgegeben. Das zweite return wird in diesem Fall nicht mehr erreicht.

print(pruefe_hp(0))
print(pruefe_hp(75))

Ausgabe:

besiegt
lebendig

Dieses frühe Beenden einer Funktion wird häufig als Early Return bezeichnet.

Dadurch lassen sich unnötig tiefe Verschachtelungen vermeiden:

def pruefe_hp(hp):
    if hp <= 0:
        return "besiegt"

    if hp <= 25:
        return "kritisch"

    return "bereit"

Python prüft die Bedingungen von oben nach unten. Sobald ein return ausgeführt wird, endet die Funktion.

Funktionen ohne ausdrückliches return

Eine Funktion ohne return gibt automatisch den besonderen Wert None zurück:

def begruesse(name):
    print(f"Willkommen, {name}!")

Speichern wir den Return Value, wird das sichtbar:

ergebnis = begruesse("Karl")

print(ergebnis)

Die Ausgabe lautet:

Willkommen, Karl!
None

Die Funktion hat zwar einen Text ausgegeben, aber keinen eigenen Wert zurückgegeben. Deshalb enthält ergebnis den Wert None.

None bedeutet in Python, dass kein eigentlicher Wert vorhanden ist.

Docstrings

Direkt unter einer Funktionsdefinition kann ein Docstring stehen:

def erstelle_statuszeile(name, hp, max_hp=100):
    """Erstellt eine Statuszeile für den Spieler."""
    return f"{name} | HP: {hp}/{max_hp}"

Ein Docstring beschreibt kurz, wofür eine Funktion gedacht ist.

Anders als ein Kommentar ist er Teil der Funktion und kann zur Laufzeit abgerufen werden:

help(erstelle_statuszeile)

Oder direkt über das Attribut __doc__:

print(erstelle_statuszeile.__doc__)

Für sehr einfache Funktionen ist ein Docstring nicht immer notwendig. Sobald eine Funktion jedoch Parameter, besondere Bedingungen oder ein nicht offensichtliches Verhalten besitzt, kann eine kurze Beschreibung sehr hilfreich sein.

Ein Docstring sollte vor allem erklären:

  • was die Funktion tut,
  • was sie zurückgibt,
  • welche Besonderheiten zu beachten sind.

Scope: Wo Variablen sichtbar sind

Variablen besitzen einen bestimmten Scope, also einen Geltungsbereich.

Eine Variable, die innerhalb einer Funktion angelegt wird, ist normalerweise nur in dieser Funktion sichtbar:

def wuerfeln():
    ergebnis = 6
    return ergebnis

Innerhalb der Funktion können wir auf ergebnis zugreifen. Außerhalb existiert dieser lokale Name nicht:

wuerfeln()

print(ergebnis)

Das führt zu einem NameError.

Um den Wert außerhalb zu verwenden, speichern wir den Return Value:

wurf = wuerfeln()

print(wurf)

Die Variable wurf gehört zum Scope außerhalb der Funktion. Die Variable ergebnis bleibt lokal in wuerfeln().

Parameter sind ebenfalls lokal

Auch Parameter gehören zum lokalen Scope der Funktion:

def verdopple(zahl):
    ergebnis = zahl * 2
    return ergebnis

Sowohl zahl als auch ergebnis existieren nur innerhalb von verdopple().

Das Argument beim Aufruf kann aus einer anders benannten Variablen stammen:

hp = 50
neue_hp = verdopple(hp)

print(neue_hp)

Die Namen außerhalb und innerhalb der Funktion müssen nicht übereinstimmen.

Lokale und globale Variablen

Eine Variable, die außerhalb aller Funktionen angelegt wird, liegt im globalen Scope:

max_hp = 100


def zeige_max_hp():
    print(max_hp)

Die Funktion kann den globalen Wert lesen:

zeige_max_hp()

Trotzdem sollten Funktionen nicht unnötig von globalen Variablen abhängen.

Diese Variante ist meist klarer:

def zeige_max_hp(max_hp):
    print(max_hp)


max_hp = 100
zeige_max_hp(max_hp)

Der benötigte Wert wird ausdrücklich als Argument übergeben. Dadurch ist direkt erkennbar, welche Daten die Funktion verwendet.

Auch Tests und spätere Änderungen werden einfacher, wenn Funktionen ihre Abhängigkeiten über Parameter erhalten.

Was bei mutable Objekten passiert

Listen und Dictionaries sind mutable: Ihr Inhalt kann verändert werden.

Übergibst Du ein solches Objekt an eine Funktion, erhält der lokale Parameter Zugriff auf dasselbe Objekt:

def fuege_hinzu(inventar, gegenstand):
    inventar.append(gegenstand)


spieler_inventar = ["Fackel"]
fuege_hinzu(spieler_inventar, "Schlüssel")

print(spieler_inventar)

Die Ausgabe lautet:

['Fackel', 'Schlüssel']

Die Funktion hat nicht den äußeren Variablennamen spieler_inventar verändert. Sie hat die Liste verändert, auf die sowohl spieler_inventar als auch der lokale Parameter inventar verweisen.

Das gilt genauso für Dictionaries:

def verletze(spieler, schaden):
    spieler["hp"] = spieler["hp"] - schaden


spieler = {
    "name": "Karl",
    "hp": 100,
}

verletze(spieler, 25)

print(spieler["hp"])

Ausgabe:

75

Zuweisen ist nicht dasselbe wie Verändern

Eine neue Zuweisung an den lokalen Parameter ersetzt nur den lokalen Namen:

def leere_inventar(inventar):
    inventar = []

Nach diesem Aufruf bleibt die ursprüngliche Liste unverändert:

spieler_inventar = ["Fackel", "Schlüssel"]
leere_inventar(spieler_inventar)

print(spieler_inventar)

Ausgabe:

['Fackel', 'Schlüssel']

Die Funktion hat lediglich ihren lokalen Namen inventar auf eine neue Liste gesetzt.

Diese Funktion verändert dagegen die bestehende Liste:

def leere_inventar(inventar):
    inventar.clear()

Nun ist die Änderung auch außerhalb sichtbar:

spieler_inventar = ["Fackel", "Schlüssel"]
leere_inventar(spieler_inventar)

print(spieler_inventar)

Ausgabe:

[]

Dieser Unterschied ist wichtig:

  • inventar = [] weist dem lokalen Namen eine neue Liste zu.
  • inventar.clear() verändert die bereits vorhandene Liste.

Immutable Werte und Rückgabewerte

Ganzzahlen, Strings, Booleans und Tupel sind immutable. Ihr bestehender Wert kann nicht direkt verändert werden.

Betrachten wir diese Funktion:

def heile(hp, menge):
    hp = hp + menge

Der Aufruf verändert die äußere Variable nicht:

hp = 50
heile(hp, 20)

print(hp)

Ausgabe:

50

Innerhalb der Funktion wurde lediglich der lokale Name hp auf einen neuen Integer gesetzt.

Deshalb sollte die Funktion den neuen Wert zurückgeben:

def heile(hp, menge, max_hp=100):
    return min(hp + menge, max_hp)

Beim Aufruf speichern wir den Return Value:

hp = 50
hp = heile(hp, 20)

print(hp)

Ausgabe:

70

Diese Schreibweise macht ausdrücklich sichtbar, dass ein neuer Wert berechnet und anschließend im Player State gespeichert wird.

Kleine Funktionen mit einer klaren Aufgabe

Eine Funktion sollte möglichst eine klar erkennbare Aufgabe erfüllen.

Diese Funktion übernimmt gleich mehrere unterschiedliche Aufgaben:

def alles_machen():
    # Raum anzeigen
    # Input lesen
    # Gegenstände aufnehmen
    # Player State verändern
    # Spiel beenden
    ...

Dadurch wäre zwar Code in einer Funktion untergebracht, aber kaum etwas gewonnen.

Besser sind mehrere gezielte Funktionen:

zeige_raum()
zeige_inventar()
nimm_alles()
erstelle_statuszeile()

Der Name beschreibt jeweils, wofür die Funktion zuständig ist.

Das bedeutet nicht, dass jede einzelne Codezeile eine eigene Funktion braucht. Eine gute Funktion fasst mehrere Anweisungen zusammen, die gemeinsam eine verständliche Aufgabe erledigen.

Der Dungeon, Schritt 5

Wir zerlegen nun den Dungeon aus Teil 4 in einzelne Funktionen.

Öffne dungeon.py und ersetze den bisherigen Inhalt durch folgenden Code:

# Dungeon – Teil 5: Funktionen
def erstelle_raeume():
    """Erstellt die Räume des Dungeons und gibt sie zurück."""
    return {
        "halle": {
            "name": "Halle",
            "beschreibung": (
                "Du stehst in einer dunklen Halle mit moderigem Geruch."
            ),
            "ausgaenge": {
                "norden": "bibliothek",
                "osten": "krypta",
            },
            "gegenstaende": ["Fackel"],
        },
        "bibliothek": {
            "name": "Bibliothek",
            "beschreibung": (
                "Staubige Regale voller zerfledderter Bücher umgeben Dich."
            ),
            "ausgaenge": {
                "sueden": "halle",
            },
            "gegenstaende": ["Schlüssel"],
        },
        "krypta": {
            "name": "Krypta",
            "beschreibung": (
                "Feuchtigkeit glänzt auf den Wänden der alten Krypta."
            ),
            "ausgaenge": {
                "westen": "halle",
            },
            "gegenstaende": [],
        },
    }


def zeige_raum(raum):
    """Zeigt den Namen, die Beschreibung und die Ausgänge eines Raums."""
    print()
    print(f"Ort: {raum['name']}")
    print(raum["beschreibung"])
    print("Ausgänge:", ", ".join(raum["ausgaenge"]))


def zeige_gegenstaende(raum):
    """Zeigt die Gegenstände im aktuellen Raum."""
    if raum["gegenstaende"]:
        print("Hier liegt:", ", ".join(raum["gegenstaende"]))
    else:
        print("Hier liegt nichts Brauchbares.")


def nimm_alles(raum, inventar):
    """Legt alle Gegenstände des Raums ins Inventar und gibt ihre Anzahl zurück."""
    gegenstaende = raum["gegenstaende"]

    if not gegenstaende:
        return 0

    anzahl = len(gegenstaende)

    inventar.extend(gegenstaende)
    gegenstaende.clear()

    return anzahl


def zeige_inventar(inventar):
    """Zeigt den Inhalt des Inventars."""
    if inventar:
        print("Inventar:", ", ".join(inventar))
    else:
        print("Dein Inventar ist leer.")


def erstelle_statuszeile(spieler):
    """Erstellt eine kompakte Statuszeile für den Spieler."""
    return (
        f"{spieler['name']} | "
        f"HP: {spieler['hp']}/{spieler['max_hp']} | "
        f"Gold: {spieler['gold']}"
    )


def zeige_status(spieler, besuchte_raeume):
    """Zeigt den Player State und einige Statistiken."""
    print(erstelle_statuszeile(spieler))
    print(f"Besuchte Räume: {len(besuchte_raeume)}")
    print(f"Gegenstände: {len(spieler['inventar'])}")


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.")

    spieler = {
        "name": input("Wie heißt Du, Abenteurer? ").strip(),
        "hp": 100,
        "max_hp": 100,
        "gold": int(input("Wie viele Goldmünzen bringst Du mit? ")),
        "inventar": [],
    }

    ort = "halle"
    besuchte_raeume = set()
    raum_anzeigen = True

    print()
    print(f"Willkommen, {spieler['name']}!")
    print("Befehle: umsehen, nimm, inventar, status, ende")

    while True:
        raum = raeume[ort]

        if raum_anzeigen:
            besuchte_raeume.add(ort)
            zeige_raum(raum)
            raum_anzeigen = False

        befehl = input("\n> ").strip().lower()

        if befehl == "ende":
            print("Du verlässt das Verlies. Bis zum nächsten Mal.")
            break

        elif befehl == "umsehen":
            zeige_gegenstaende(raum)

        elif befehl == "nimm":
            anzahl = nimm_alles(raum, spieler["inventar"])

            if anzahl > 0:
                print(f"Du nimmst {anzahl} Gegenstand auf.")
            else:
                print("Hier gibt es nichts zu nehmen.")

        elif befehl == "inventar":
            zeige_inventar(spieler["inventar"])

        elif befehl == "status":
            zeige_status(spieler, besuchte_raeume)

        elif befehl in raum["ausgaenge"]:
            ort = raum["ausgaenge"][befehl]
            raum_anzeigen = True

        else:
            print("Das geht hier nicht.")
            print("Mögliche Ausgänge:", ", ".join(raum["ausgaenge"]))
            print("Weitere Befehle: umsehen, nimm, inventar, status, 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, inventar, status, ende

Ort: Halle
Du stehst in einer dunklen Halle mit moderigem Geruch.
Ausgänge: norden, osten

> umsehen
Hier liegt: Fackel

> nimm
Du nimmst 1 Gegenstand auf.

> inventar
Inventar: Fackel

> norden

Ort: Bibliothek
Staubige Regale voller zerfledderter Bücher umgeben Dich.
Ausgänge: sueden

> status
Karl | HP: 100/100 | Gold: 50
Besuchte Räume: 2
Gegenstände: 1

> ende
Du verlässt das Verlies. Bis zum nächsten Mal.

Der Programmstart mit main()

Fast der gesamte eigentliche Programmablauf steckt nun in einer Funktion namens main:

def main():
    ...

Am Ende der Datei wird sie aufgerufen:

main()

Python verarbeitet die Datei von oben nach unten. Dabei werden zunächst alle Funktionen definiert. Der jeweilige Funktionskörper wird noch nicht ausgeführt.

Erst beim abschließenden Aufruf von main() beginnt das eigentliche Spiel.

Der Name main besitzt in Python keine besondere technische Bedeutung. Er ist eine verbreitete Konvention für die Funktion, die den Hauptablauf eines Programms enthält.

Räume über eine Funktion erstellen

Die Funktion

def erstelle_raeume():
    return {
        ...
    }

erstellt das Dictionary mit der Dungeon-Welt und gibt es zurück.

In main() speichern wir diesen Return Value:

raeume = erstelle_raeume()

Dadurch ist die umfangreiche Raumdefinition vom eigentlichen Game-Loop getrennt.

Später können wir die Räume in eine eigene Datei auslagern oder aus einer Datei laden, ohne den restlichen Programmablauf grundlegend umzubauen.

Funktionen für Output

Mehrere Funktionen sind ausschließlich für bestimmte Ausgaben zuständig:

zeige_raum(raum)
zeige_gegenstaende(raum)
zeige_inventar(inventar)
zeige_status(spieler, besuchte_raeume)

Der Game-Loop muss dadurch nicht mehr wissen, wie jede einzelne Ausgabe zusammengesetzt wird.

Statt mehrerer print-Anweisungen steht dort nur noch:

zeige_raum(raum)

Der Funktionsname beschreibt bereits, was an dieser Stelle geschieht.

Einen String zurückgeben

erstelle_statuszeile(...) gibt einen String zurück, zeigt ihn aber nicht selbst an:

def erstelle_statuszeile(spieler):
    return (
        f"{spieler['name']} | "
        f"HP: {spieler['hp']}/{spieler['max_hp']} | "
        f"Gold: {spieler['gold']}"
    )

Die Funktion zeige_status(...) verwendet diesen Return Value:

print(erstelle_statuszeile(spieler))

Diese Trennung ist nützlich: Die Statuszeile könnte später nicht nur im Terminal angezeigt, sondern beispielsweise auch in einer Logdatei gespeichert oder über ein Netzwerk verschickt werden.

Eine Funktion, die einen String erzeugt, ist flexibler als eine Funktion, die ihn ausschließlich mit print(...) ausgibt.

Den State über mutable Objekte verändern

Die Funktion nimm_alles(...) erhält zwei mutable Objekte:

def nimm_alles(raum, inventar):
  • raum verweist auf das Dictionary des aktuellen Raums.
  • inventar verweist auf die Liste im Player State.

Die Funktion erweitert das Inventar:

inventar.extend(gegenstaende)

Anschließend leert sie die Liste im Raum:

gegenstaende.clear()

Beide Änderungen bleiben nach dem Funktionsaufruf erhalten, weil die vorhandenen Listen verändert wurden.

Zurückgegeben wird lediglich die Anzahl der aufgenommenen Gegenstände:

return anzahl

Der Game-Loop kann daraufhin eine passende Meldung ausgeben:

anzahl = nimm_alles(raum, spieler["inventar"])

if anzahl > 0:
    print(f"Du nimmst {anzahl} Gegenstand auf.")
else:
    print("Hier gibt es nichts zu nehmen.")

Die Funktion erledigt damit eine klar begrenzte Aufgabe:

  1. Gegenstände ins Inventar übertragen,
  2. den Raum leeren,
  3. die Anzahl der übertragenen Gegenstände zurückgeben.

Der Game-Loop wird lesbarer

Vergleichen wir einen Ausschnitt aus Teil 4:

elif befehl == "inventar":
    if spieler["inventar"]:
        print("Inventar:", ", ".join(spieler["inventar"]))
    else:
        print("Dein Inventar ist leer.")

Nach der Auslagerung in eine Funktion bleibt im Game-Loop:

elif befehl == "inventar":
    zeige_inventar(spieler["inventar"])

Die Details befinden sich weiterhin im Programm, stehen aber an einer eindeutigen Stelle unter einem verständlichen Namen.

Der Game-Loop liest sich dadurch zunehmend wie eine Beschreibung des Spielablaufs:

if befehl == "ende":
    ...
elif befehl == "umsehen":
    zeige_gegenstaende(raum)
elif befehl == "nimm":
    ...
elif befehl == "inventar":
    zeige_inventar(spieler["inventar"])
elif befehl == "status":
    zeige_status(spieler, besuchte_raeume)

Vorsicht bei mutablen Default-Werten

Listen und Dictionaries sollten normalerweise nicht direkt als Default-Wert verwendet werden.

Diese Funktion wirkt zunächst praktisch:

def fuege_gegenstand_hinzu(gegenstand, inventar=[]):
    inventar.append(gegenstand)
    return inventar

Der Default-Wert wird jedoch nur einmal beim Definieren der Funktion erzeugt. Dadurch verwenden mehrere Aufrufe dieselbe Liste:

print(fuege_gegenstand_hinzu("Fackel"))
print(fuege_gegenstand_hinzu("Schlüssel"))

Die Ausgabe lautet:

['Fackel']
['Fackel', 'Schlüssel']

Der zweite Aufruf beginnt nicht mit einer neuen leeren Liste.

Eine sichere Variante verwendet None:

def fuege_gegenstand_hinzu(gegenstand, inventar=None):
    if inventar is None:
        inventar = []

    inventar.append(gegenstand)
    return inventar

Nun wird bei jedem Aufruf ohne eigenes Inventar eine neue Liste erstellt.

Unveränderbare Default-Werte wie Zahlen, Strings, Booleans oder None sind dagegen unproblematisch:

def heile(hp, menge=20, max_hp=100):
    return min(hp + menge, max_hp)

Stolperfallen

  • Eine Funktion nur definieren: Ein def-Block legt die Funktion an, führt sie aber nicht aus. Erst ein Aufruf wie zeige_raum(raum) startet ihren Funktionskörper.

  • Die Klammern beim Aufruf vergessen: zeige_raum bezeichnet die Funktion selbst. zeige_raum(raum) ruft sie auf.

  • print und return verwechseln: print(...) erzeugt Output im Terminal. return gibt einen Wert an die aufrufende Stelle zurück.

  • Einen Return Value nicht speichern: Gibt eine Funktion einen neuen Integer oder String zurück, musst Du diesen Wert verwenden:

python hp = heile(hp, 20)

  • Code nach return erwarten: Sobald return ausgeführt wurde, endet die Funktion. Nachfolgende Anweisungen desselben Pfads werden nicht mehr erreicht.

  • Auf lokale Variablen von außen zugreifen: Variablen, die innerhalb einer Funktion angelegt werden, gehören zu deren lokalem Scope.

  • Zuweisung mit Mutation verwechseln: inventar = [] ersetzt nur den lokalen Namen. inventar.clear() verändert die bestehende Liste.

  • Mutable Default-Werte verwenden: Eine Liste oder ein Dictionary als Default-Wert wird zwischen mehreren Aufrufen geteilt.

  • Eine Funktion zu früh aufrufen: Das zugehörige def muss bereits ausgeführt worden sein, bevor die Funktion aufgerufen wird. Deshalb steht main() am Ende der Datei.

  • Zu viele globale Variablen verwenden: Übergib benötigte Werte möglichst als Argumente und gib berechnete Ergebnisse mit return zurück.

Übungen

1. Prüfen, ob der Spieler besiegt ist

Schreibe eine Funktion ist_besiegt(hp). Sie soll True zurückgeben, wenn der Spieler keine Lebenspunkte mehr besitzt, andernfalls False.

Lösung
def ist_besiegt(hp):
    """Prüft, ob der Spieler keine Lebenspunkte mehr besitzt."""
    return hp <= 0
Der Vergleich `hp <= 0` ergibt bereits einen Boolean. Deshalb kann das Ergebnis direkt zurückgegeben werden:
if ist_besiegt(spieler["hp"]):
    print("Du bist besiegt.")
Ein zusätzliches `if` innerhalb der Funktion ist nicht notwendig.

2. Eine Heilfunktion schreiben

Schreibe eine Funktion heile(...), die aktuelle Lebenspunkte, eine Heilmenge und die maximalen Lebenspunkte erhält.

Die neuen Lebenspunkte dürfen max_hp nicht überschreiten.

Lösung
def heile(hp, menge, max_hp=100):
    """Berechnet die Lebenspunkte nach einer Heilung."""
    return min(hp + menge, max_hp)
Verwende den Return Value, um den Player State zu aktualisieren:
spieler["hp"] = heile(
    spieler["hp"],
    menge=20,
    max_hp=spieler["max_hp"],
)
Die Funktion verändert den Integer nicht direkt, sondern gibt einen neuen Wert zurück.

3. Die Statuszeile erweitern

Erweitere erstelle_statuszeile(...), sodass zusätzlich die Anzahl der Gegenstände im Inventar angezeigt wird.

Die Ausgabe soll beispielsweise so aussehen:

Karl | HP: 75/100 | Gold: 50 | Inventar: 2
Lösung
def erstelle_statuszeile(spieler):
    """Erstellt eine kompakte Statuszeile für den Spieler."""
    return (
        f"{spieler['name']} | "
        f"HP: {spieler['hp']}/{spieler['max_hp']} | "
        f"Gold: {spieler['gold']} | "
        f"Inventar: {len(spieler['inventar'])}"
    )
Da das Inventar bereits Teil des Player States ist, benötigt die Funktion keinen zusätzlichen Parameter.

Im nächsten Teil

Unser Code ist nun in überschaubare Funktionen aufgeteilt. Einige Stellen sind aber noch ausführlicher, als sie in Python sein müssten.

In Teil 6 geht es deshalb um pythonic Code: Comprehensions, Slicing, enumerate, Unpacking und weitere Idiome, mit denen sich typische Aufgaben klar und kompakt ausdrücken lassen.

0 Kommentare

Noch keine Kommentare. Sei der/die Erste!