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:
nameist 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.returnliefert 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 wiezeige_raum(raum)startet ihn. -
Die Klammern beim Aufruf vergessen:
zeige_raumbezeichnet das Funktionsobjekt.zeige_raum(raum)ruft die Funktion auf. -
printundreturnverwechseln:print(...)schreibt etwas in die Ausgabe.returnliefert 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
returnerwarten: Sobaldreturnauf 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 vonmain(). -
Zu viele globale Variablen verwenden: Übergib benötigte Werte möglichst über Parameter und gib berechnete Ergebnisse mit
returnzurü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
if ist_besiegt(spieler["hp"]):
print("Du bist besiegt.")
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)
spieler["hp"] = heile(
spieler["hp"],
menge=20,
max_hp=spieler["max_hp"],
)
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'])}"
)
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!
Anmelden um einen Kommentar zu hinterlassen.