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, übernimmt inzwischen aber ziemlich viele 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. Wir können diesen Code anschließend gezielt aufrufen, wiederverwenden und getrennt vom restlichen Programm bearbeiten.

Eine Funktion definieren

Eine Funktion wird mit dem Keyword def definiert:

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

Nach def folgen der Name der Funktion, runde Klammern und ein Doppelpunkt. Darunter steht eingerückt der Funktionskörper:

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

Die Definition allein führt den Funktionskörper noch nicht aus. Erst ein Aufruf mit runden Klammern startet die Funktion:

begruesse()

Die Ausgabe lautet:

Willkommen im Verlies!

Du kannst dieselbe Funktion beliebig oft aufrufen:

begruesse()
begruesse()
begruesse()

Der Funktionskörper wird 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()

Je besser der Name gewählt ist, desto weniger musst Du später darüber nachdenken, was ein Aufruf wie zeige_raum(raum) eigentlich macht.

Parameter: Werte an eine Funktion übergeben

Unsere bisherige Funktion gibt immer denselben Text aus. Soll sie mit unterschiedlichen Werten arbeiten, bekommt 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 steht name jeweils für den übergebenen Wert.

Genau genommen unterscheiden wir zwischen Parameter und Argument:

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

Die Begriffe beschreiben also zwei Seiten desselben Vorgangs: Die Funktion definiert, welche Werte sie erwartet, und der Aufruf liefert diese Werte.

Mehrere Parameter

Eine Funktion kann mehrere Parameter besitzen:

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

Beim Aufruf können die Argumente über ihre Position zugeordnet werden:

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

"Karl" gehört zum ersten Parameter name, 75 zum zweiten Parameter hp und 100 zu max_hp.

Diese Werte werden als Positionsargumente übergeben.

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.

Da die Zuordnung über die Namen erfolgt, spielt ihre Reihenfolge hier keine Rolle:

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

Das Ergebnis ist dasselbe.

Gerade bei Funktionen mit mehreren ähnlich aussehenden Argumenten kann diese Schreibweise verständlicher sein:

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

Bei den einfachen Funktionsdefinitionen, die wir bisher verwenden, lassen sich Positionsargumente und Keyword Arguments auch kombinieren. Die Positionsargumente müssen dabei zuerst stehen:

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

Diese Schreibweise funktioniert dagegen nicht:

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

Ein einfaches Positionsargument darf nicht hinter einem Keyword Argument stehen.

Default-Werte

Parameter können einen Default-Wert besitzen. Er wird verwendet, wenn beim Aufruf kein eigenes Argument für diesen Parameter 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.

Beim Aufruf kannst Du einen anderen Wert übergeben:

verursache_schaden(25)

Ausgabe:

Du verlierst 25 Lebenspunkte.

Natürlich geht das auch als Keyword Argument:

verursache_schaden(menge=25)

Bei der einfachen Parameterform, die wir hier verwenden, müssen Pflichtparameter vor Parametern mit Default-Werten 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 dann automatisch den Wert 100.

Diese Definition ist dagegen ungültig:

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

Python meldet hier bereits beim Einlesen des Codes einen SyntaxError.

return: Ein Ergebnis zurückgeben

Eine Funktion kann nicht nur etwas ausgeben, sondern auch ein Ergebnis, den Rückgabewert (Return Value), an den aufrufenden Code 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 und können ihn beispielsweise in einer Variablen speichern:

status = erstelle_statuszeile("Karl", 75)

print(status)

Die Ausgabe lautet:

Karl | HP: 75/100

Der Unterschied zwischen print(...) und return ist dabei wichtig:

  • print(...) schreibt etwas in die Ausgabe.
  • return liefert 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.")

Die Funktion entscheidet hier nur, wie die Statuszeile aufgebaut wird. Was anschließend mit diesem String passiert, entscheidet der aufrufende Code.

return beendet die Funktion

Sobald Python ein return ausführt, endet der aktuelle Funktionsaufruf:

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

    return "lebendig"

Bei hp <= 0 wird "besiegt" 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.

Damit 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, ist dieser Funktionsaufruf beendet.

Funktionen ohne ausdrückliches return

Auch eine Funktion ohne return liefert einen Wert zurück: None.

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 Return Value festgelegt. Python gibt deshalb automatisch None zurück.

None ist ein eigener Wert in Python und wird häufig verwendet, um auszudrücken, dass kein anderer Wert vorhanden ist.

Docstrings

Direkt am Anfang eines Funktionskörpers 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 dokumentiert, wofür die Funktion gedacht ist.

Anders als ein normaler Kommentar ist er Teil der Funktion und kann von Python und anderen Werkzeugen ausgelesen werden:

help(erstelle_statuszeile)

Du kannst auch direkt auf das Attribut __doc__ zugreifen:

print(erstelle_statuszeile.__doc__)

Bei einer kleinen Funktion mit eindeutigem Namen wäre ein Docstring wie

"""Zeigt den Raum."""

oft kaum hilfreicher als der Funktionsname selbst. Bei komplexeren Funktionen kann die Dokumentation dagegen erklären, was erwartet wird, was zurückkommt oder welches Verhalten nicht sofort aus dem Code hervorgeht.

Scope: Wo Namen sichtbar sind

Namen besitzen einen bestimmten Geltungsbereich (Scope).

Ein Name, der innerhalb einer Funktion durch eine Zuweisung entsteht, ist normalerweise lokal für diese Funktion:

def wuerfeln():
    ergebnis = 6
    return ergebnis

Innerhalb der Funktion können wir auf ergebnis zugreifen. Außerhalb ist dieser lokale Name nicht verfügbar:

wuerfeln()

print(ergebnis)

Das führt zu einem NameError.

Möchten wir den Wert außerhalb verwenden, geben wir ihn zurück und speichern den Return Value:

wurf = wuerfeln()

print(wurf)

ergebnis ist der lokale Name innerhalb von wuerfeln(). wurf gehört dagegen zum Scope außerhalb der Funktion.

Parameter sind ebenfalls lokal

Auch Parameter sind lokale Namen der Funktion:

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

Sowohl zahl als auch ergebnis gehören hier zum lokalen Scope von verdopple().

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

hp = 50
neue_hp = verdopple(hp)

print(neue_hp)

Außerhalb heißt der Wert hp, innerhalb der Funktion zahl. Die Namen müssen nicht übereinstimmen.

Lokale und globale Namen

Ein Name, der auf Modulebene, also außerhalb aller Funktionen, definiert wird, liegt im globalen Scope dieses Moduls:

max_hp = 100


def zeige_max_hp():
    print(max_hp)

Die Funktion kann diesen globalen Namen lesen:

zeige_max_hp()

Das funktioniert, trotzdem sollten Funktionen nicht unnötig von globalen Variablen abhängen.

Oft ist diese Variante klarer:

def zeige_max_hp(max_hp):
    print(max_hp)


max_hp = 100
zeige_max_hp(max_hp)

Nun ist bereits am Funktionsaufruf sichtbar, welche Information die Funktion benötigt. Das macht sie außerdem leichter mit anderen Werten wiederverwendbar.

Was beim Übergeben von Objekten passiert

Beim Aufruf einer Funktion werden keine neuen Kopien unserer Listen oder Dictionaries erzeugt. Ein Parameter kann deshalb auf dasselbe Objekt verweisen wie ein Name außerhalb der Funktion.

Bei einem mutable Objekt kann die Funktion dieses gemeinsame Objekt verändern:

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']

Der lokale Parameter inventar und der äußere Name spieler_inventar verweisen während des Funktionsaufrufs auf dieselbe Liste. append(...) verändert genau diese Liste.

Das funktioniert genauso mit 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 Parameter verändert dagegen nur, worauf dieser lokale Name verweist:

def leere_inventar(inventar):
    inventar = []

Die ursprüngliche Liste bleibt davon unberührt:

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

print(spieler_inventar)

Ausgabe:

['Fackel', 'Schlüssel']

Innerhalb der Funktion verweist inventar nach der Zuweisung auf eine neue leere Liste. spieler_inventar verweist weiterhin auf die ursprüngliche Liste.

Diese Funktion verändert dagegen das vorhandene Objekt:

def leere_inventar(inventar):
    inventar.clear()

Jetzt ist die Änderung auch außerhalb der Funktion sichtbar:

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

print(spieler_inventar)

Ausgabe:

[]

Der Unterschied steckt also darin, was wir tun:

inventar = []

bindet den lokalen Namen an eine neue Liste.

inventar.clear()

verändert die bestehende Liste.

Immutable Werte und Return Values

Integer, Strings, Booleans und Tupel sind immutable. Das Objekt selbst lässt sich nach seiner Erstellung nicht verändern.

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

Die Addition erzeugt einen neuen Integer. Der lokale Name hp innerhalb der Funktion wird anschließend an diesen neuen Wert gebunden. Der äußere Name hp bleibt davon unberührt.

Wenn wir den berechneten Wert außerhalb benötigen, geben wir ihn zurück:

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

Beim Aufruf übernehmen wir den Return Value:

hp = 50
hp = heile(hp, 20)

print(hp)

Ausgabe:

70

Damit ist im Code klar erkennbar: heile(...) berechnet einen neuen Wert und der aufrufende Code entscheidet, wo dieser gespeichert wird.

Kleine Funktionen mit einer klaren Aufgabe

Code einfach nur in irgendeine Funktion zu verschieben, macht ihn noch nicht automatisch besser.

Diese Funktion übernimmt beispielsweise alles gleichzeitig:

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

Der Code steckt nun zwar in einer Funktion, übersichtlicher ist er dadurch aber kaum geworden.

Hilfreicher sind Funktionen mit klar abgegrenzten Aufgaben:

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

Der Name sagt jeweils recht genau, wofür die Funktion zuständig ist.

Das bedeutet nicht, dass jede einzelne Codezeile eine eigene Funktion braucht. Eine Funktion sollte eine zusammenhängende Aufgabe so kapseln, dass ihr Aufruf im restlichen Programm verständlich bleibt.

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 aktuellen Raum und seine Ausgänge."""
    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."""
    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 == 1:
                print("Du nimmst einen Gegenstand auf.")
            elif anzahl > 1:
                print(f"Du nimmst {anzahl} Gegenstände 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")


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, 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 einen 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():
    ...

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.

Am Ende unserer Datei steht:

if __name__ == "__main__":
    main()

Den Aufbau von if kennst Du bereits aus Teil 3. Neu ist __name__.

Python setzt diese Variable für jedes Modul. Wird dungeon.py direkt als Programm gestartet, hat __name__ den Wert "__main__". Die Bedingung ist damit erfüllt und main() wird aufgerufen.

Das wirkt im Moment etwas ausführlicher als ein einzelnes

main()

und beim direkten Start wäre das Ergebnis tatsächlich dasselbe. Der Unterschied wird sichtbar, sobald wir dungeon.py später aus einer anderen Python-Datei importieren: Dann wird main() nicht automatisch ausgeführt.

Unsere Funktionen können dadurch später auch von anderem Code verwendet werden, ohne dass beim Import sofort das Spiel startet.

Python verarbeitet die Datei weiterhin von oben nach unten. Zunächst werden die Funktionsdefinitionen ausgeführt und die Funktionen dabei angelegt. Ihre Funktionskörper laufen erst, wenn die jeweilige Funktion tatsächlich aufgerufen wird.

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önnten wir die Räume in eine eigene Datei auslagern oder aus einer Datei laden, ohne den Game-Loop deshalb komplett umzubauen.

Funktionen für Output

Mehrere Funktionen kümmern sich um bestimmte Ausgaben:

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

Der Game-Loop muss dadurch nicht mehr wissen, aus welchen einzelnen print-Anweisungen eine Raumbeschreibung besteht.

Stattdessen steht dort nur noch:

zeige_raum(raum)

Schon der Funktionsname verrät recht genau, was an dieser Stelle passiert.

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

Das macht erstelle_statuszeile(...) flexibler. Der zurückgegebene String könnte später ebenso in einer Datei gespeichert, in einem anderen Text weiterverwendet oder an eine andere Funktion übergeben werden.

Eine Funktion, die Daten erzeugt und zurückgibt, ist nicht darauf festgelegt, diese Daten sofort mit print(...) auszugeben.

Den State über mutable Objekte verändern

Die Funktion nimm_alles(...) bekommt zwei mutable Objekte:

def nimm_alles(raum, inventar):

raum verweist auf das Dictionary des aktuellen Raums, inventar auf die Liste im Player State.

Zuerst speichern wir einen weiteren Namen für die Gegenstandsliste des Raums:

gegenstaende = raum["gegenstaende"]

gegenstaende verweist damit auf dieselbe Liste wie raum["gegenstaende"].

Anschließend erweitern wir das Inventar:

inventar.extend(gegenstaende)

und leeren die Gegenstandsliste des Raums:

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 entscheidet anhand dieses Return Values, welche Meldung angezeigt wird:

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

if anzahl == 1:
    print("Du nimmst einen Gegenstand auf.")
elif anzahl > 1:
    print(f"Du nimmst {anzahl} Gegenstände auf.")
else:
    print("Hier gibt es nichts zu nehmen.")

nimm_alles(...) kümmert sich also um das Verschieben der Gegenstände. Wie das Ergebnis dem Spieler präsentiert wird, bleibt Aufgabe des Game-Loops.

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 bleibt im Game-Loop:

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

Die Details sind weiterhin im Programm vorhanden, stehen jetzt aber an einer eigenen 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)

Das ist einer der großen Vorteile von Funktionen: Die Details verschwinden nicht, aber sie müssen nicht überall gleichzeitig sichtbar sein.

Vorsicht bei mutablen Default-Werten

Bei Default-Werten gibt es eine Besonderheit, die bei Listen und Dictionaries schnell zu unerwartetem Verhalten führt.

Diese Funktion wirkt zunächst praktisch:

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

Der Default-Wert [] wird jedoch nicht bei jedem Aufruf neu erzeugt. Python wertet ihn aus, wenn die Funktionsdefinition ausgeführt wird. Spätere Aufrufe ohne eigenes inventar verwenden deshalb dieselbe Liste.

Das wird hier sichtbar:

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

Die Ausgabe lautet:

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

Beim zweiten Aufruf ist die Fackel noch vorhanden.

Wenn bei jedem Aufruf ohne eigenes Inventar eine neue Liste entstehen soll, verwenden wir stattdessen None als Default-Wert:

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

    inventar.append(gegenstand)
    return inventar

Jetzt wird die Liste innerhalb der Funktion neu erzeugt, wenn kein Inventar übergeben wurde.

Default-Werte wie Zahlen, Strings, Booleans oder None verursachen dieses Problem nicht, weil die Funktion sie nicht auf dieselbe Weise verändern kann:

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

Mutable Default-Werte sind also nicht grundsätzlich verboten. Du solltest sie aber nur verwenden, wenn das gemeinsame Objekt über mehrere Aufrufe tatsächlich beabsichtigt ist. In normalem Anwendungscode ist das eher selten der Fall.

Stolperfallen

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

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

  • print und return verwechseln: print(...) schreibt etwas in die Ausgabe. return liefert einen Wert an den aufrufenden Code zurück.

  • Einen Return Value ignorieren: Berechnet eine Funktion einen neuen Integer oder String, musst Du den zurückgegebenen Wert verwenden, wenn Du ihn behalten möchtest:

python hp = heile(hp, 20)

  • Code nach return erwarten: Sobald return auf dem aktuellen Ausführungspfad erreicht wird, endet der Funktionsaufruf. Nachfolgende Anweisungen auf diesem Pfad werden nicht mehr ausgeführt.

  • Auf lokale Namen von außen zugreifen: Namen, die nur innerhalb einer Funktion existieren, sind außerhalb ihres lokalen Scopes nicht direkt verfügbar.

  • Zuweisung und Mutation verwechseln: inventar = [] bindet den lokalen Namen an eine neue Liste. inventar.clear() verändert die bestehende Liste.

  • Mutable Default-Werte verwenden, ohne es zu wollen: Eine Liste oder ein Dictionary als Default-Wert kann zwischen mehreren Aufrufen geteilt werden.

  • Eine Funktion aufrufen, bevor ihre Definition ausgeführt wurde: Python muss die entsprechende def-Anweisung bereits verarbeitet haben. Deshalb stehen unsere Funktionsdefinitionen vor dem Aufruf von main().

  • Zu viele globale Variablen verwenden: Übergib benötigte Werte möglichst über Parameter und gib berechnete Ergebnisse mit return zurück. Dadurch ist leichter zu erkennen, wovon eine Funktion abhängt.

Ü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 können wir das Ergebnis direkt zurückgeben:
if ist_besiegt(spieler["hp"]):
    print("Du bist besiegt.")
Ein zusätzliches `if` innerhalb von `ist_besiegt(...)` ist nicht nötig.

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 berechnet einen neuen Wert und gibt ihn 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 zum Player State gehört, benötigt die Funktion dafür 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!