cat posts/python-lernen-06-pythonic-schreiben.md
Python lernen (Teil 6): Pythonic schreiben
Comprehensions, Slicing, enumerate, zip, Unpacking und Truthiness – mit diesen Idiomen wird unser Python-Code kompakter, klarer und leichter zu lesen.
Der Dungeon aus Teil 5 ist inzwischen sauber in Funktionen aufgeteilt. Der Code funktioniert, ist verständlich und lässt sich erweitern.
Heute kommen nur wenige neue Spielbefehle hinzu. Stattdessen schauen wir uns an, wie sich typische Aufgaben so ausdrücken lassen, wie es in Python üblich ist.
Dafür wird häufig der Begriff pythonic verwendet. Pythonic Code nutzt die Möglichkeiten und Idiome der Sprache, statt umständlich Schreibweisen nachzubauen, die man aus anderen Programmiersprachen kennt.
Dabei gilt:
Pythonic bedeutet nicht, möglichst wenige Zeichen zu schreiben. Entscheidend ist, dass die Absicht des Codes schnell erkennbar bleibt.
Wir beschäftigen uns mit Comprehensions, Truthiness, Slicing, enumerate,
zip, Unpacking und einigen weiteren Idiomen. Anschließend wenden wir einen
Teil davon auf unseren Dungeon an.
Comprehensions
Angenommen, wir möchten die IDs mehrerer Räume in Großbuchstaben umwandeln.
Mit einer normalen Schleife können wir dafür Schritt für Schritt eine neue Liste aufbauen:
raum_ids = ["halle", "bibliothek", "krypta"]
namen = []
for raum_id in raum_ids:
namen.append(raum_id.upper())
print(namen)
Die Ausgabe lautet:
['HALLE', 'BIBLIOTHEK', 'KRYPTA']
Python bietet dafür eine kompaktere Schreibweise: die List Comprehension.
namen = [raum_id.upper() for raum_id in raum_ids]
Das Ergebnis ist dasselbe.
Die Grundform lautet:
[ausdruck for element in iterable]
Für jedes Element wird der Ausdruck ausgewertet. Die Ergebnisse landen in einer neuen Liste.
In unserem Beispiel:
[raum_id.upper() for raum_id in raum_ids]
kannst Du die Zeile ungefähr so lesen:
- Nimm nacheinander jede
raum_id. - Rufe darauf
.upper()auf. - Packe die Ergebnisse in eine neue Liste.
Der Ausdruck steht bei einer Comprehension also vor der Schleife. Das wirkt beim ersten Lesen etwas ungewohnt, wird aber schnell vertraut.
Elemente in einer Comprehension filtern
Mit einem angehängten if kannst Du nur bestimmte Elemente übernehmen:
gegenstaende = ["Brot", "Fackel", "Seil", "Schlüssel"]
lange_namen = [
gegenstand
for gegenstand in gegenstaende
if len(gegenstand) > 5
]
print(lange_namen)
Die Ausgabe lautet:
['Fackel', 'Schlüssel']
Die allgemeine Form ist:
[ausdruck for element in iterable if bedingung]
Die Bedingung entscheidet, welche Elemente in die neue Liste gelangen.
Für unsere Dungeon-Welt können wir beispielsweise alle Räume finden, in denen noch Gegenstände liegen:
raeume_mit_loot = [
raum["name"]
for raum in raeume.values()
if raum["gegenstaende"]
]
Hier treffen gleich zwei Python-Idiome aufeinander: eine Comprehension und Truthiness. Eine nicht leere Gegenstandsliste wird als wahr ausgewertet, eine leere als falsch. Was dahintersteckt, sehen wir gleich noch genauer.
List Comprehension oder normale Schleife?
Eine Comprehension passt gut, wenn Du aus einem Iterable neue Werte erzeugst und das Ganze mit einem kurzen Ausdruck beschrieben werden kann.
Dieses Beispiel ist kompakt und trotzdem gut lesbar:
quadrate = [zahl ** 2 for zahl in range(1, 6)]
Das Ergebnis wäre:
[1, 4, 9, 16, 25]
Eine Comprehension ist aber nicht automatisch besser, nur weil sie kürzer ist.
Dieses Beispiel wird bereits deutlich dichter:
werte = [
zahl * 2
for gruppe in gruppen
for zahl in gruppe
if zahl > 0
if zahl % 2 == 0
]
Als normale Schleife ist der Ablauf leichter zu verfolgen:
werte = []
for gruppe in gruppen:
for zahl in gruppe:
if zahl > 0 and zahl % 2 == 0:
werte.append(zahl * 2)
Pythonic Code bedeutet nicht Code-Golf. Wenn die längere Variante leichter zu verstehen ist, ist sie meistens auch die bessere.
Dict Comprehensions
Neben Listen kannst Du auch Dictionaries mit einer Comprehension erzeugen.
Aus diesem Dictionary:
lebenspunkte = {
"Karl": 100,
"Goblin": 30,
"Drache": 999,
}
bauen wir ein neues Dictionary mit halbierten Lebenspunkten:
halbe_lebenspunkte = {
name: hp // 2
for name, hp in lebenspunkte.items()
}
print(halbe_lebenspunkte)
Die Ausgabe lautet:
{'Karl': 50, 'Goblin': 15, 'Drache': 499}
Die Grundform sieht so aus:
{
schluessel: wert
for element in iterable
}
Auch Dict Comprehensions können filtern:
starke_gegner = {
name: hp
for name, hp in lebenspunkte.items()
if hp >= 100
}
Das Ergebnis ist:
{
"Karl": 100,
"Drache": 999,
}
Schlüssel und Wert dürfen dabei natürlich ebenfalls berechnet werden. Eine Dict Comprehension ist also mehr als nur eine kürzere Schreibweise zum Kopieren eines Dictionaries.
Set Comprehensions
Sets lassen sich ebenfalls mit einer Comprehension erzeugen:
richtungen = ["Norden", "Süden", "NORDEN", "Osten"]
normalisiert = {
richtung.lower()
for richtung in richtungen
}
print(normalisiert)
Da ein Set jeden Wert höchstens einmal enthält, verschmelzen "Norden" und
"NORDEN" nach .lower() zu demselben Eintrag.
Eine mögliche Ausgabe lautet:
{'norden', 'süden', 'osten'}
Die Reihenfolge kann anders aussehen. Sets besitzen keine für Deine Programmlogik verlässliche Reihenfolge.
Die Schreibweise ähnelt einer Dict Comprehension, enthält aber keinen Doppelpunkt:
{ausdruck for element in iterable}
Truthiness: Werte als Bedingung verwenden
Python kann praktisch jedes Objekt direkt als Boolean auswerten.
Zu den eingebauten Werten, die als False gelten, gehören:
FalseNone- numerische Nullwerte wie
0und0.0 - leere Strings wie
"" - leere Listen wie
[] - leere Tupel wie
() - leere Dictionaries wie
{} - leere Sets wie
set()
Nicht leere Collections und Zahlen ungleich 0 werden dagegen normalerweise als
True ausgewertet.
Du kannst das mit bool(...) ausprobieren:
>>> bool([])
False
>>> bool(["Fackel"])
True
>>> bool("")
False
>>> bool("Dungeon")
True
>>> bool(0)
False
>>> bool(25)
True
>>> bool(None)
False
Bei eigenen Klassen lässt sich beeinflussen, welchen Boolean ihre Objekte ergeben. Für unsere bisherigen Datentypen reichen die genannten Regeln zunächst aus.
Collections ohne len(...) prüfen
Angenommen, Du möchtest wissen, ob das Inventar mindestens einen Gegenstand enthält.
Das hier funktioniert:
if len(inventar) > 0:
print("Du trägst etwas bei Dir.")
In Python schreibt man üblicherweise direkt:
if inventar:
print("Du trägst etwas bei Dir.")
Damit steht im Code ziemlich genau das, was wir wissen möchten:
Ist das Inventar nicht leer?
Für den umgekehrten Fall verwenden wir not:
if not inventar:
print("Dein Inventar ist leer.")
Statt:
if len(inventar) == 0:
print("Dein Inventar ist leer.")
Die kürzere Variante spart hier nicht bloß Zeichen, sondern drückt die Absicht direkter aus.
Truthiness mit Bedacht verwenden
Truthiness ist praktisch, kann aber Informationen zusammenfassen, die Du vielleicht unterscheiden möchtest.
Angenommen, gold kann entweder eine Zahl oder None enthalten:
gold = None
Diese Prüfung unterscheidet nicht zwischen None und 0:
if not gold:
print("Kein Gold vorhanden.")
Beide Werte sind falsy.
Möchtest Du ausdrücklich wissen, ob noch gar kein Goldwert gesetzt wurde,
prüfst Du auf None:
if gold is None:
print("Der Goldwert wurde noch nicht gesetzt.")
Für den Zahlenwert 0 verwendest Du dagegen:
if gold == 0:
print("Du besitzt keine Goldmünzen.")
Truthiness ist also dann passend, wenn die verschiedenen falsy Werte für Deinen Code dieselbe Bedeutung haben dürfen.
Bedingte Ausdrücke
Eine kleine Entscheidung lässt sich direkt innerhalb eines Ausdrucks treffen:
status = "lebendig" if hp > 0 else "besiegt"
Diese Schreibweise heißt Conditional Expression.
Sie entspricht inhaltlich:
if hp > 0:
status = "lebendig"
else:
status = "besiegt"
Ein Beispiel aus unserem Dungeon:
inventar_text = ", ".join(inventar) if inventar else "(leer)"
Diese Form eignet sich gut, wenn beide Alternativen kurz sind und lediglich entscheiden, welcher Wert verwendet wird.
Mehrere ineinander verschachtelte Conditional Expressions werden dagegen schnell unangenehm:
status = "tot" if hp <= 0 else "kritisch" if hp <= 25 else "bereit"
Das lässt sich zwar lesen, aber eine normale if-elif-else-Struktur zeigt
die Fälle klarer.
Slicing: Ausschnitte aus Sequenzen
Mit Slicing kannst Du Ausschnitte aus Sequenzen wie Listen, Tupeln oder Strings auswählen.
Die Grundform lautet:
sequenz[start:stop]
start ist enthalten, stop nicht.
zahlen = [0, 1, 2, 3, 4, 5]
print(zahlen[1:4])
Die Ausgabe lautet:
[1, 2, 3]
Wir beginnen also bei Index 1 und hören vor Index 4 auf.
Grenzen weglassen
Lässt Du start weg, beginnt der Slice am Anfang:
zahlen[:3]
Ergebnis:
[0, 1, 2]
Lässt Du stop weg, reicht er bis zum Ende:
zahlen[3:]
Ergebnis:
[3, 4, 5]
Auch beide Grenzen dürfen fehlen:
kopie = zahlen[:]
Bei einer Liste entsteht dadurch eine neue Liste mit denselben Elementen.
Negative Indizes
Wie beim normalen Zugriff auf eine Sequenz kannst Du beim Slicing negative Indizes verwenden. Sie zählen vom Ende:
zahlen = [0, 1, 2, 3, 4, 5]
print(zahlen[-2:])
Ausgabe:
[4, 5]
Für ein Inventar könnten wir damit beispielsweise die beiden zuletzt hinzugefügten Gegenstände auswählen:
inventar = ["Brot", "Fackel", "Seil", "Schlüssel"]
letzte_funde = inventar[-2:]
letzte_funde enthält:
["Seil", "Schlüssel"]
Sind weniger als zwei Elemente vorhanden, ist das ebenfalls kein Problem. Ein
Slice läuft nicht mit einem IndexError aus der Sequenz heraus, sondern liefert
die vorhandenen Elemente.
Die Schrittweite beim Slicing
Slicing kennt noch einen dritten Wert: die Schrittweite.
sequenz[start:stop:schritt]
Damit können wir zum Beispiel jedes zweite Element auswählen:
zahlen = [0, 1, 2, 3, 4, 5]
print(zahlen[::2])
Ausgabe:
[0, 2, 4]
Eine negative Schrittweite läuft in die andere Richtung. Diese bekannte Kurzform dreht eine Sequenz um:
print(zahlen[::-1])
Ausgabe:
[5, 4, 3, 2, 1, 0]
Das funktioniert auch mit Strings:
wort = "Verlies"
print(wort[:3])
print(wort[::-1])
Ausgabe:
Ver
seilreV
Slicing erzeugt bei Listen eine flache Kopie
Bei einer Liste erzeugt ein Slice wie [:] eine neue Liste:
inventar = ["Fackel", "Brot"]
kopie = inventar[:]
kopie.append("Schlüssel")
print(inventar)
print(kopie)
Ausgabe:
['Fackel', 'Brot']
['Fackel', 'Brot', 'Schlüssel']
kopie und inventar sind zwei verschiedene Listen. Das Hinzufügen des
Schlüssels verändert deshalb nur kopie.
Allerdings handelt es sich um eine flache Kopie. Sind die Elemente selbst mutable Objekte, werden diese nicht ebenfalls kopiert:
raeume = [
{"name": "Halle"},
{"name": "Krypta"},
]
kopie = raeume[:]
kopie[0]["name"] = "Große Halle"
print(raeume[0]["name"])
Die Ausgabe lautet:
Große Halle
Die beiden äußeren Listen sind verschieden, enthalten aber Referenzen auf dieselben Dictionaries. Ändern wir eines dieser Dictionaries, ist die Änderung deshalb über beide Listen sichtbar.
enumerate: Nummer und Element gemeinsam
Manchmal brauchen wir beim Durchlaufen eines Iterables zusätzlich eine laufende Nummer.
Wir könnten dafür selbst mit Indizes arbeiten:
richtungen = ["norden", "osten", "westen"]
for index in range(len(richtungen)):
print(f"{index + 1}) {richtungen[index]}")
Das funktioniert, ist aber umständlicher als nötig. Genau dafür gibt es
enumerate(...):
for nummer, richtung in enumerate(richtungen, start=1):
print(f"{nummer}) {richtung}")
Die Ausgabe lautet:
1) norden
2) osten
3) westen
enumerate(...) liefert beim Durchlaufen jeweils ein Paar aus Zähler und
aktuellem Element. Durch Unpacking landen beide direkt in den passenden
Variablen:
nummer, richtung
Ohne start=1 beginnt der Zähler standardmäßig bei 0:
for index, richtung in enumerate(richtungen):
print(index, richtung)
Ausgabe:
0 norden
1 osten
2 westen
Verwende enumerate(...), wenn Du Element und laufende Nummer brauchst. Ist die
Nummer egal, bleibt die direkte Schleife einfacher:
for richtung in richtungen:
print(richtung)
zip: Iterables parallel durchlaufen
Mit zip(...) kannst Du mehrere Iterables parallel durchlaufen:
namen = ["Goblin", "Ork", "Drache"]
lebenspunkte = [30, 50, 999]
for name, hp in zip(namen, lebenspunkte):
print(f"{name}: {hp} HP")
Die Ausgabe lautet:
Goblin: 30 HP
Ork: 50 HP
Drache: 999 HP
Bei jedem Durchlauf kombiniert zip(...) die Elemente an derselben Position:
"Goblin" + 30
"Ork" + 50
"Drache" + 999
Genauer gesagt erzeugt zip(...) Tupel, die wir in der for-Schleife sofort
wieder entpacken:
for name, hp in zip(namen, lebenspunkte):
zip(...) arbeitet dabei lazy. Es baut nicht zuerst eine komplette Liste
aller Paare, sondern liefert sie beim Durchlaufen nach und nach.
zip endet standardmäßig beim kürzesten Iterable
Sind die Eingaben unterschiedlich lang, endet zip(...) standardmäßig, sobald
das kürzeste Iterable erschöpft ist:
namen = ["Goblin", "Ork", "Drache"]
lebenspunkte = [30, 50]
for name, hp in zip(namen, lebenspunkte):
print(name, hp)
Ausgabe:
Goblin 30
Ork 50
Der Drache wird nicht verarbeitet. Python meldet dabei keinen Fehler.
Wenn unterschiedlich lange Daten bei Dir auf einen Fehler hindeuten würden,
kannst Du strict=True verwenden:
for name, hp in zip(namen, lebenspunkte, strict=True):
print(name, hp)
Sind die Längen verschieden, löst zip(...) einen ValueError aus.
Gerade wenn zwei Datenreihen logisch immer gleich viele Elemente enthalten
müssen, verhindert strict=True, dass fehlende Werte unbemerkt abgeschnitten
werden.
Unpacking
Beim Unpacking verteilst Du Werte aus einem Iterable auf mehrere Namen:
position = (3, 7)
x, y = position
Danach gilt:
x == 3
y == 7
Normalerweise muss die Anzahl der Namen zur Anzahl der gelieferten Werte passen:
x, y = (3, 7, 9)
Hier sollen drei Werte auf nur zwei Namen verteilt werden. Python beendet die
Zuweisung deshalb mit einem ValueError.
Unpacking haben wir bereits mehrfach verwendet, etwa bei .items() eines
Dictionaries oder zusammen mit enumerate() und zip().
Werte mit Unpacking tauschen
Zwei Werte lassen sich in Python ohne Hilfsvariable tauschen:
x = 3
y = 7
x, y = y, x
print(x, y)
Ausgabe:
7 3
Die Werte auf der rechten Seite werden ausgewertet, bevor die Zuweisung an die Namen auf der linken Seite erfolgt. Dadurch geht keiner der beiden alten Werte verloren.
Mehrere Werte mit * sammeln
Beim Unpacking kann ein mit * markierter Name mehrere übrig bleibende Werte
aufnehmen:
erster, *mitte, letzter = [1, 2, 3, 4, 5]
print(erster)
print(mitte)
print(letzter)
Ausgabe:
1
[2, 3, 4]
5
Der mit * markierte Name erhält dabei eine Liste.
Das lässt sich beispielsweise zum Zerlegen einer Eingabe verwenden:
befehl, *argumente = "nimm alte fackel".split()
print(befehl)
print(argumente)
Ausgabe:
nimm
['alte', 'fackel']
Innerhalb einer solchen Zuweisung darf nur ein Name mit * markiert sein.
Für unseren Dungeon ist eine andere Methode allerdings praktischer, weil wir den Rest der Eingabe als zusammenhängenden String behalten möchten.
_ für nicht benötigte Werte
Der Unterstrich _ wird häufig als Name für einen Wert verwendet, den wir
bewusst nicht benötigen.
Die String-Methode partition(...) zerlegt einen String in drei Teile:
befehl = "nimm Fackel"
verb, trennzeichen, argument = befehl.partition(" ")
Danach gilt:
verb == "nimm"
trennzeichen == " "
argument == "Fackel"
Benötigen wir das Trennzeichen nicht, schreiben wir üblicherweise:
verb, _, argument = befehl.partition(" ")
_ besitzt in einem normalen Skript dabei keine besondere technische
Bedeutung. Es ist ein ganz gewöhnlicher Name. Die Schreibweise signalisiert
anderen Lesern des Codes lediglich:
Diesen Wert verwenden wir absichtlich nicht.
In der interaktiven Python-Shell kann _ zusätzlich für das Ergebnis des
zuletzt ausgewerteten Ausdrucks stehen. Darauf solltest Du Dich in normalen
Skripten nicht verlassen.
Befehle mit partition(...) zerlegen
partition(" ") passt gut zu Befehlen, die aus einem Verb und einem optionalen
Argument bestehen:
eingabe = "nimm alte Fackel"
verb, _, argument = eingabe.partition(" ")
print(verb)
print(argument)
Ausgabe:
nimm
alte Fackel
Im Gegensatz zu einem einfachen split() zerlegt partition(...) nur am
ersten Vorkommen des angegebenen Trennzeichens. Der restliche Text bleibt
zusammen.
Wird das Trennzeichen gar nicht gefunden:
eingabe = "inventar"
verb, _, argument = eingabe.partition(" ")
erhalten wir:
verb == "inventar"
argument == ""
partition(...) liefert immer drei Werte. Fehlt das Trennzeichen, sind der
zweite und dritte Wert hier leere Strings.
Da ein leerer String falsy ist, können wir anschließend direkt prüfen:
if not argument:
print("Es wurde kein Argument angegeben.")
any(...) und all(...)
Zwei weitere nützliche Built-ins sind any(...) und all(...).
any(...) prüft, ob mindestens ein Element eines Iterables truthy ist:
tuer_offen = False
fenster_offen = True
geheimgang_offen = False
if any([tuer_offen, fenster_offen, geheimgang_offen]):
print("Es gibt einen offenen Ausgang.")
Da fenster_offen True ist, ergibt any(...) ebenfalls True.
all(...) prüft dagegen, ob alle Elemente truthy sind:
hat_schluessel = True
hat_fackel = True
hat_karte = False
if all([hat_schluessel, hat_fackel, hat_karte]):
print("Du bist vollständig ausgerüstet.")
Hier lautet das Ergebnis False, weil hat_karte False ist.
Für wenige feste Bedingungen sind and und or meist direkter:
if hat_schluessel and hat_fackel:
print("Du kannst weitergehen.")
any(...) und all(...) werden besonders praktisch, wenn die zu prüfenden
Werte ohnehin aus einem Iterable stammen oder dynamisch erzeugt werden.
Bei leeren Iterables gibt es noch einen Unterschied, der zunächst überraschend wirken kann:
>>> any([])
False
>>> all([])
True
any(...) findet in einer leeren Liste keinen einzigen wahren Wert. Für
all(...) gibt es dagegen auch keinen Wert, der die Bedingung verletzt.
Der Dungeon, Schritt 6
Jetzt wenden wir einige der neuen Idiome auf den Dungeon aus Teil 5 an.
Dabei ändern wir unter anderem:
- Ausgänge und Inventar werden mit
enumerate(...)nummeriert. - Leere Collections prüfen wir weiterhin über Truthiness – jetzt wissen wir, was dahintersteckt.
- Befehle zerlegen wir mit
partition(...)in Verb und Argument. - Mit
nimm Fackellassen sich einzelne Gegenstände aufnehmen. - Eine List Comprehension findet Räume, in denen noch Loot liegt.
- Für kurze Alternativen verwenden wir eine Conditional Expression.
Öffne dungeon.py und ersetze den bisherigen Inhalt durch folgenden Code:
# Dungeon – Teil 6: Pythonic schreiben
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, seine Ausgänge und sichtbare Gegenstände."""
print()
print(f"Ort: {raum['name']}")
print(raum["beschreibung"])
print("Ausgänge:")
for nummer, richtung in enumerate(raum["ausgaenge"], start=1):
print(f" {nummer}) {richtung}")
if raum["gegenstaende"]:
print("Hier liegt:", ", ".join(raum["gegenstaende"]))
def zeige_gegenstaende(raum):
"""Zeigt die Gegenstände im aktuellen Raum."""
gegenstaende = raum["gegenstaende"]
text = ", ".join(gegenstaende) if gegenstaende else "nichts Brauchbares"
print(f"Hier liegt: {text}")
def zeige_inventar(inventar):
"""Zeigt das Inventar als nummerierte Liste."""
if not inventar:
print("Dein Inventar ist leer.")
return
print("Inventar:")
for nummer, gegenstand in enumerate(inventar, start=1):
print(f" {nummer}) {gegenstand}")
def nimm_gegenstand(raum, inventar, name):
"""Nimmt einen bestimmten Gegenstand auf und gibt seinen Namen zurück."""
gesuchter_name = name.strip().lower()
for gegenstand in raum["gegenstaende"]:
if gegenstand.lower() == gesuchter_name:
raum["gegenstaende"].remove(gegenstand)
inventar.append(gegenstand)
return gegenstand
return None
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'])}"
)
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)}")
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 zurück, in denen Gegenstände liegen."""
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.")
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 <Gegenstand>, inventar, status, "
"loot, geh <Richtung>, ende"
)
while True:
raum = raeume[ort]
if raum_anzeigen:
besuchte_raeume.add(ort)
zeige_raum(raum)
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":
zeige_gegenstaende(raum)
elif verb == "nimm":
if not argument:
print("Was möchtest Du nehmen?")
continue
gegenstand = nimm_gegenstand(
raum,
spieler["inventar"],
argument,
)
if gegenstand:
print(f"Du nimmst {gegenstand}.")
else:
print(f"Hier liegt kein Gegenstand namens {argument}.")
elif verb == "inventar":
zeige_inventar(spieler["inventar"])
elif verb == "status":
zeige_status(spieler, 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 argument in raum["ausgaenge"]:
ort = raum["ausgaenge"][argument]
raum_anzeigen = True
else:
print(f"Du kannst nicht nach {argument} gehen.")
elif verb in raum["ausgaenge"] and not argument:
ort = raum["ausgaenge"][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"
)
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 <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.
> 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.
> 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 wurde pythonic?
Schauen wir uns einige der Änderungen genauer an.
Nummerierte Ausgänge mit enumerate(...)
Statt selbst einen Zähler zu verwalten:
nummer = 1
for richtung in raum["ausgaenge"]:
print(f"{nummer}) {richtung}")
nummer = nummer + 1
verwenden wir:
for nummer, richtung in enumerate(raum["ausgaenge"], start=1):
print(f" {nummer}) {richtung}")
Wir müssen den Zähler weder selbst anlegen noch erhöhen. Im Code steht direkt, dass wir beim Durchlaufen zusätzlich eine laufende Nummer benötigen.
Truthiness für leere Collections
Das Inventar wird ohne len(...) geprüft:
if not inventar:
print("Dein Inventar ist leer.")
return
Auch bei den Gegenständen eines Raums reicht die Collection selbst als Bedingung:
if raum["gegenstaende"]:
print("Hier liegt:", ", ".join(raum["gegenstaende"]))
In beiden Fällen interessiert uns schlicht, ob die Collection leer ist.
Early Return
In zeige_inventar(...) beenden wir die Funktion sofort, wenn es nichts
anzuzeigen gibt:
if not inventar:
print("Dein Inventar ist leer.")
return
Danach können wir den normalen Fall ohne zusätzlichen else-Block schreiben:
print("Inventar:")
for nummer, gegenstand in enumerate(inventar, start=1):
print(f" {nummer}) {gegenstand}")
Der Funktionskörper bleibt dadurch flacher.
Input mit partition(...) und Unpacking zerlegen
Die Funktion teile_befehl(...) enthält diese Zeile:
verb, _, argument = eingabe.strip().lower().partition(" ")
Aus
nimm alte fackel
werden dadurch:
verb == "nimm"
argument == "alte fackel"
Das Trennzeichen selbst landet in _, weil wir es nicht brauchen.
Da wir vor dem partition(...) außerdem .lower() aufrufen, werden sowohl Verb
als auch Argument normalisiert. "NIMM FACKEL" und "nimm fackel" werden
dadurch gleich behandelt.
Räume mit Loot über eine Comprehension finden
Die Funktion
def finde_raeume_mit_loot(raeume):
return [
raum["name"]
for raum in raeume.values()
if raum["gegenstaende"]
]
lässt sich fast wie eine Beschreibung des gewünschten Ergebnisses lesen:
Erzeuge eine Liste mit den Namen aller Räume, deren Gegenstandsliste nicht leer ist.
Als normale Schleife sähe dasselbe so aus:
ergebnis = []
for raum in raeume.values():
if raum["gegenstaende"]:
ergebnis.append(raum["name"])
return ergebnis
Beide Varianten sind korrekt. Hier bleibt die Comprehension übersichtlich und fasst genau eine Transformation mit einer einfachen Filterbedingung zusammen.
Conditional Expression für kurze Alternativen
In zeige_gegenstaende(...) verwenden wir:
text = ", ".join(gegenstaende) if gegenstaende else "nichts Brauchbares"
Ausgeschrieben wäre das:
if gegenstaende:
text = ", ".join(gegenstaende)
else:
text = "nichts Brauchbares"
Da lediglich zwischen zwei kurzen Werten gewählt wird, bleibt die Conditional Expression gut lesbar.
Das Gleiche machen wir beim loot-Befehl:
text = ", ".join(raeume_mit_loot) if raeume_mit_loot else "(keine)"
Pythonic bedeutet nicht immer kürzer
Einige Stellen des Dungeon-Codes bleiben bewusst als normale Schleifen und Bedingungen erhalten.
nimm_gegenstand(...) durchsucht beispielsweise die Gegenstände Schritt für
Schritt:
for gegenstand in raum["gegenstaende"]:
if gegenstand.lower() == gesuchter_name:
raum["gegenstaende"].remove(gegenstand)
inventar.append(gegenstand)
return gegenstand
Sobald ein passender Gegenstand gefunden wurde, wird er aus dem Raum entfernt,
ins Inventar gelegt und zurückgegeben. Durch das anschließende return endet
auch die Schleife sofort.
Man könnte diesen Code mit weiteren Python-Sprachmitteln kompakter schreiben. Dadurch würde er für Einsteiger aber nicht automatisch verständlicher.
Pythonic Code setzt Idiome dort ein, wo sie die Absicht klarer ausdrücken. Eine normale Schleife ist kein schlechter Python-Code, nur weil sich irgendwo noch ein Einzeiler finden ließe.
Stolperfallen
-
Comprehensions überladen: Mehrere verschachtelte
for- undif-Teile können eine Comprehension schwer verständlich machen. Eine normale Schleife ist dann oft die bessere Wahl. -
Eine Comprehension nur wegen ihrer Kürze verwenden: Comprehensions eignen sich vor allem zum Erzeugen neuer Collections. Komplizierte Abläufe und Seiteneffekte gehören meist besser in eine normale Schleife.
-
Truthiness zu allgemein einsetzen:
0,None,""und leere Collections sind alle falsy. Verwende beispielsweiseis None, wenn DuNoneausdrücklich von0unterscheiden musst. -
Slicing mit einer vollständigen Kopie verwechseln:
liste[:]erzeugt eine flache Kopie. Mutable Objekte innerhalb der Liste werden nicht zusätzlich kopiert. -
Die exklusive Stop-Grenze vergessen: Bei
werte[1:4]sind die Indizes1,2und3enthalten, Index4dagegen nicht. -
enumerate(...)verwenden, obwohl die Nummer nicht gebraucht wird: Wenn Du nur die Elemente benötigst, istfor element in iterableeinfacher. -
Unterschiedlich lange Eingaben bei
zip(...)übersehen: Ohnestrict=Trueendetzip(...)beim kürzesten Iterable. Die übrigen Elemente werden nicht verarbeitet. -
_für besondere Syntax halten: In einem normalen Skript ist_ein gewöhnlicher Name. Seine Bedeutung als „diesen Wert brauche ich nicht“ ist eine Konvention. -
Bei
partition(...)drei Return Values vergessen: Die Methode liefert immer den Teil vor dem Trennzeichen, das Trennzeichen selbst und den Teil danach. Wird es nicht gefunden, sind die letzten beiden Strings leer. -
split()undpartition()gleich behandeln:split()kann eine Eingabe in beliebig viele Teile zerlegen.partition(" ")trennt genau am ersten gefundenen Leerzeichen und lässt den Rest zusammen. -
Conditional Expressions verschachteln: Sobald mehrere Bedingungen ineinander stecken, ist ein normales
if-elif-elsemeistens leichter zu lesen.
Übungen
1. Lange Gegenstandsnamen filtern
Erzeuge mit einer List Comprehension aus der folgenden Liste eine neue Liste, die nur Gegenstände mit mehr als fünf Zeichen enthält:
gegenstaende = ["Fackel", "Brot", "Seil", "Schlüssel"]
Lösung
lange_namen = [
gegenstand
for gegenstand in gegenstaende
if len(gegenstand) > 5
]
print(lange_namen)
['Fackel', 'Schlüssel']
2. Das Inventar nummeriert ausgeben
Gib das Inventar mit enumerate(...) nummeriert aus. Die Nummerierung soll bei
1 beginnen.
Lösung
inventar = ["Fackel", "Schlüssel", "Brot"]
for nummer, gegenstand in enumerate(inventar, start=1):
print(f"{nummer}. {gegenstand}")
1. Fackel
2. Schlüssel
3. Brot
3. Die letzten beiden Gegenstände anzeigen
Verwende Slicing, um nur die letzten beiden Gegenstände des Inventars auszugeben.
Lösung
inventar = ["Brot", "Seil", "Fackel", "Schlüssel"]
letzte_funde = inventar[-2:]
print(letzte_funde)
['Fackel', 'Schlüssel']
4. Namen und Lebenspunkte verbinden
Verbinde die folgenden Listen mit zip(...) und gib jeden Gegner zusammen mit
seinen Lebenspunkten aus:
namen = ["Goblin", "Ork", "Drache"]
lebenspunkte = [30, 50, 999]
Die beiden Listen sollen immer gleich lang sein.
Lösung
for name, hp in zip(namen, lebenspunkte, strict=True):
print(f"{name}: {hp} HP")
Goblin: 30 HP
Ork: 50 HP
Drache: 999 HP
Vertiefung
Pythonic Code lässt sich teilweise automatisch prüfen und formatieren:
ruff: Linter und Formatter in einem
Im nächsten Teil
Unsere Räume und der Player State bestehen weiterhin aus verschachtelten
Dictionaries. Das funktioniert, erlaubt aber beispielsweise Tippfehler in
Schlüsseln wie "max_hp" und verteilt Daten und zugehöriges Verhalten auf
mehrere Stellen im Programm.
In Teil 7 bündeln wir beides in Klassen. Aus dem Player State, den Räumen und den Gegenständen werden eigene Objekte mit passenden Attributen und Methoden.
0 Kommentare
Noch keine Kommentare. Sei der/die Erste!
Anmelden um einen Kommentar zu hinterlassen.