Zum Inhalt springen

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 keine grundlegend neuen Fähigkeiten für das Spiel hinzu. Stattdessen geht es darum, typische Aufgaben so auszudrücken, 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 Schreibweisen aus anderen Programmiersprachen nachzubauen.

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 unter anderem mit Comprehensions, Truthiness, Slicing, enumerate, zip und Unpacking. Anschließend wenden wir diese Techniken auf unseren Dungeon an.

Comprehensions

Angenommen, wir möchten die Namen mehrerer Räume in Großbuchstaben umwandeln.

Mit einer gewöhnlichen Schleife könnten wir eine neue Liste Schritt für Schritt 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 allgemeine Form lautet:

[ausdruck for element in collection]

Für jedes Element der Collection wird der Ausdruck ausgewertet. Die Ergebnisse landen in einer neuen Liste.

In unserem Beispiel:

[raum_id.upper() for raum_id in raum_ids]

bedeutet das:

  1. Nimm nacheinander jede raum_id.
  2. Rufe darauf .upper() auf.
  3. Speichere jedes Ergebnis in einer neuen Liste.

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 lautet:

[ausdruck for element in collection if bedingung]

Die Bedingung entscheidet, welche Elemente berücksichtigt werden.

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 verwenden wir gleichzeitig eine Comprehension und Truthiness: Eine nicht leere Liste mit Gegenständen gilt als True, eine leere Liste als False.

List Comprehension oder normale Schleife?

Eine Comprehension eignet sich besonders, wenn Du:

  • aus einer Collection eine neue Collection erzeugst,
  • jedes Element auf dieselbe Weise verarbeitest,
  • Elemente mit einer einfachen Bedingung filterst.

Dieses Beispiel ist übersichtlich:

quadrate = [zahl ** 2 for zahl in range(1, 6)]

Die Ausgabe wäre:

[1, 4, 9, 16, 25]

Eine Comprehension ist aber nicht automatisch besser, nur weil sie kürzer ist.

Dieses Beispiel wird bereits schwerer lesbar:

werte = [
    zahl * 2
    for gruppe in gruppen
    for zahl in gruppe
    if zahl > 0
    if zahl % 2 == 0
]

In solchen Fällen kann eine normale Schleife klarer sein:

werte = []

for gruppe in gruppen:
    for zahl in gruppe:
        if zahl > 0 and zahl % 2 == 0:
            werte.append(zahl * 2)

Pythonic Code bevorzugt Lesbarkeit gegenüber maximaler Kürze.

Dict Comprehensions

Neben Listen kannst Du auch Dictionaries mit einer Comprehension erzeugen.

Aus diesem Dictionary:

lebenspunkte = {
    "Karl": 100,
    "Goblin": 30,
    "Drache": 999,
}

können wir ein neues Dictionary mit halbierten Lebenspunkten bauen:

halbe_lebenspunkte = {
    name: hp // 2
    for name, hp in lebenspunkte.items()
}

print(halbe_lebenspunkte)

Die Ausgabe lautet:

{'Karl': 50, 'Goblin': 15, 'Drache': 499}

Die allgemeine Form lautet:

{
    schluessel: wert
    for element in collection
}

Auch Dict Comprehensions können filtern:

starke_gegner = {
    name: hp
    for name, hp in lebenspunkte.items()
    if hp >= 100
}

Das Ergebnis lautet:

{
    "Karl": 100,
    "Drache": 999,
}

Set Comprehensions

Auch Sets lassen sich mit einer Comprehension erzeugen:

richtungen = ["Norden", "Süden", "NORDEN", "Osten"]

normalisiert = {
    richtung.lower()
    for richtung in richtungen
}

print(normalisiert)

Die Ausgabe enthält jeden Wert nur einmal:

{'norden', 'süden', 'osten'}

Die Reihenfolge eines Sets solltest Du dabei nicht als Teil Deiner Programmlogik betrachten.

Die Schreibweise ähnelt einer Dict Comprehension, enthält aber keinen Doppelpunkt:

{ausdruck for element in collection}

Truthiness: Werte als Bedingung verwenden

Python kann viele Werte direkt in einer Bedingung auswerten.

Diese Werte gelten normalerweise als False:

  • False
  • None
  • numerische Nullwerte wie 0 und 0.0
  • leere Strings wie ""
  • leere Listen wie []
  • leere Tupel wie ()
  • leere Dictionaries wie {}
  • leere Sets wie set()

Nicht leere oder von null verschiedene Werte gelten normalerweise als True.

Du kannst das mit bool(...) überprüfen:

>>> bool([])
False
>>> bool(["Fackel"])
True
>>> bool("")
False
>>> bool("Dungeon")
True
>>> bool(0)
False
>>> bool(25)
True
>>> bool(None)
False

Eigene Klassen können dieses Verhalten über besondere Methoden beeinflussen. Für die eingebauten Datentypen reichen die genannten Regeln zunächst aus.

Collections ohne len(...) prüfen

Angenommen, Du möchtest prüfen, ob das Inventar mindestens einen Gegenstand enthält.

Diese Schreibweise funktioniert:

if len(inventar) > 0:
    print("Du trägst etwas bei Dir.")

Pythonic ist jedoch:

if inventar:
    print("Du trägst etwas bei Dir.")

Die zweite Variante drückt direkt aus, worum es geht:

Ist das Inventar nicht leer?

Auch der umgekehrte Fall lässt sich direkt formulieren:

if not inventar:
    print("Dein Inventar ist leer.")

Statt:

if len(inventar) == 0:
    print("Dein Inventar ist leer.")

Truthiness mit Bedacht verwenden

Truthiness ist praktisch, aber nicht immer eindeutig genug.

Angenommen, gold kann entweder eine Zahl oder None enthalten:

gold = None

Diese Bedingung unterscheidet nicht zwischen None und 0:

if not gold:
    print("Kein Gold vorhanden.")

Sowohl None als auch 0 gelten als False.

Möchtest Du ausdrücklich auf None prüfen, verwendest Du:

if gold is None:
    print("Der Goldwert wurde noch nicht gesetzt.")

Und für einen echten Zahlenwert von 0:

if gold == 0:
    print("Du besitzt keine Goldmünzen.")

Die kürzere Truthiness-Prüfung ist nur dann besser, wenn alle falsy Werte dieselbe Bedeutung haben dürfen.

Bedingte Ausdrücke

Eine einfache Entscheidung lässt sich direkt innerhalb eines Ausdrucks formulieren:

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 typisches Beispiel aus unserem Dungeon:

inventar_text = ", ".join(inventar) if inventar else "(leer)"

Diese Form eignet sich für kurze und leicht erkennbare Entscheidungen.

Zu viele verschachtelte Bedingungen werden dagegen schnell unlesbar:

status = "tot" if hp <= 0 else "kritisch" if hp <= 25 else "bereit"

Hier ist eine normale if-elif-else-Struktur klarer.

Slicing: Ausschnitte aus Sequenzen

Mit Slicing kannst Du Teile einer Liste, eines Tupels oder eines Strings auswählen.

Die Grundform lautet:

collection[start:stop]

start ist enthalten, stop dagegen nicht.

zahlen = [0, 1, 2, 3, 4, 5]

print(zahlen[1:4])

Die Ausgabe lautet:

[1, 2, 3]

Der Index 4 gehört nicht mehr zum Ergebnis.

Grenzen weglassen

Lässt Du start weg, beginnt der Slice am Anfang:

zahlen[:3]

Ergebnis:

[0, 1, 2]

Lässt Du stop weg, reicht der Slice bis zum Ende:

zahlen[3:]

Ergebnis:

[3, 4, 5]

Beide Grenzen können fehlen:

kopie = zahlen[:]

Dadurch entsteht eine neue Liste mit denselben Elementen.

Negative Indizes

Negative Indizes zählen vom Ende:

zahlen = [0, 1, 2, 3, 4, 5]

print(zahlen[-2:])

Ausgabe:

[4, 5]

Ein weiteres Beispiel:

inventar = ["Brot", "Fackel", "Seil", "Schlüssel"]

letzte_funde = inventar[-2:]

letzte_funde enthält:

["Seil", "Schlüssel"]

Die Schrittweite beim Slicing

Slicing kann einen dritten Wert für die Schrittweite enthalten:

collection[start:stop:schritt]

Jedes zweite Element:

zahlen = [0, 1, 2, 3, 4, 5]

print(zahlen[::2])

Ausgabe:

[0, 2, 4]

Mit einer negativen Schrittweite kannst Du die Reihenfolge umdrehen:

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 eine flache Kopie

Bei Listen erzeugt ein Slice eine neue Liste:

inventar = ["Fackel", "Brot"]
kopie = inventar[:]

kopie.append("Schlüssel")

print(inventar)
print(kopie)

Ausgabe:

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

Das Hinzufügen des Schlüssels verändert nur die neue Liste.

Dabei handelt es sich allerdings um eine flache Kopie. Enthält die Liste 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

Beide Listen enthalten weiterhin eine Referenz auf dasselbe innere Dictionary.

enumerate: Index und Element gemeinsam

Manchmal benötigen wir beim Durchlaufen einer Collection zusätzlich eine laufende Nummer.

Eine mögliche Schreibweise wäre:

richtungen = ["norden", "osten", "westen"]

for index in range(len(richtungen)):
    print(f"{index + 1}) {richtungen[index]}")

Das funktioniert, ist aber unnötig umständlich. Python bietet dafür enumerate(...):

for nummer, richtung in enumerate(richtungen, start=1):
    print(f"{nummer}) {richtung}")

Die Ausgabe lautet:

1) norden
2) osten
3) westen

enumerate(...) liefert bei jedem Durchlauf zwei Werte:

  1. die laufende Nummer,
  2. das aktuelle Element.

Durch Unpacking landen beide Werte direkt in den passenden Variablen:

nummer, richtung

Ohne start=1 beginnt enumerate(...) standardmäßig bei 0.

for index, richtung in enumerate(richtungen):
    print(index, richtung)

Ausgabe:

0 norden
1 osten
2 westen

Verwende enumerate(...), wenn Du sowohl das Element als auch seine Position benötigst. Brauchst Du nur das Element, bleibt die direkte Schleife klarer:

for richtung in richtungen:
    print(richtung)

zip: Collections parallel durchlaufen

Mit zip(...) kannst Du mehrere Collections gleichzeitig 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 verbindet zip(...) die Elemente an derselben Position:

"Goblin"  + 30
"Ork"     + 50
"Drache"  + 999

Auch hier werden die Paare direkt entpackt:

for name, hp in zip(namen, lebenspunkte):

zip endet bei der kürzesten Collection

Sind die Collections unterschiedlich lang, endet zip(...) standardmäßig bei der kürzesten:

namen = ["Goblin", "Ork", "Drache"]
lebenspunkte = [30, 50]

for name, hp in zip(namen, lebenspunkte):
    print(name, hp)

Der Drache wird nicht verarbeitet. Python erzeugt dabei standardmäßig keinen Fehler.

Seit Python 3.10 kannst Du mit strict=True verlangen, dass alle Collections gleich lang sein müssen:

for name, hp in zip(namen, lebenspunkte, strict=True):
    print(name, hp)

Sind die Längen unterschiedlich, entsteht ein ValueError.

strict=True ist sinnvoll, wenn unterschiedlich lange Daten auf einen Fehler im Programm oder in den Eingangsdaten hindeuten.

Unpacking

Beim Unpacking verteilst Du die Elemente einer Collection auf mehrere Variablen:

position = (3, 7)

x, y = position

Danach gilt:

x == 3
y == 7

Die Anzahl der Variablen muss normalerweise zur Anzahl der Elemente passen:

x, y = (3, 7, 9)

Dieser Code führt zu einem ValueError, weil drei Werte auf nur zwei Variablen verteilt werden sollen.

Werte mit Unpacking tauschen

In Python kannst Du zwei Werte ohne Hilfsvariable tauschen:

x = 3
y = 7

x, y = y, x

print(x, y)

Ausgabe:

7 3

Python wertet zuerst die rechte Seite aus und weist die Werte anschließend auf der linken Seite zu.

Mehrere Werte mit * sammeln

Ein Stern sammelt übrig bleibende Werte in einer Liste:

erster, *mitte, letzter = [1, 2, 3, 4, 5]

print(erster)
print(mitte)
print(letzter)

Ausgabe:

1
[2, 3, 4]
5

Ein weiteres Beispiel:

befehl, *argumente = "nimm alte fackel".split()

print(befehl)
print(argumente)

Ausgabe:

nimm
['alte', 'fackel']

Innerhalb einer solchen Zuweisung darf nur ein mit * markierter Name vorkommen.

_ für nicht benötigte Werte

Der Unterstrich _ wird häufig als Name für einen Wert verwendet, den wir 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:

verb, _, argument = befehl.partition(" ")

Der Unterstrich besitzt dabei keine besondere technische Bedeutung. Er ist in einem normalen Skript lediglich ein gewöhnlicher Variablenname.

Die Schreibweise signalisiert anderen Entwicklern:

Diesen Wert benötigen wir nicht.

In der interaktiven REPL hat _ zusätzlich die besondere Bedeutung des zuletzt ausgegebenen Ergebnisses. Diese Funktion gilt nicht allgemein für normale Skripte.

Befehle mit partition(...) zerlegen

partition(" ") eignet sich gut für Befehle, 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 split() zerlegt partition(...) nur am ersten Vorkommen des Trennzeichens. Der Rest bleibt als gemeinsamer String erhalten.

Bei einer Eingabe ohne Leerzeichen:

eingabe = "inventar"
verb, _, argument = eingabe.partition(" ")

erhalten wir:

verb == "inventar"
argument == ""

Da ein leerer String falsy ist, können wir 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(...) gibt True zurück, wenn mindestens ein Element 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.")

all(...) gibt nur dann True zurück, wenn 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.")

In diesem Beispiel ist die Bedingung False, weil hat_karte den Wert False besitzt.

Für wenige feste Bedingungen ist ein gewöhnliches and oder or oft klarer:

if hat_schluessel and hat_fackel:
    print("Du kannst weitergehen.")

any(...) und all(...) sind besonders nützlich, wenn die Werte bereits in einer Collection vorliegen oder dynamisch erzeugt werden.

Der Dungeon, Schritt 6

Wir wenden die neuen Idiome nun auf den Dungeon aus Teil 5 an.

Dabei ändern wir unter anderem:

  • Ausgänge und Inventar werden mit enumerate(...) nummeriert.
  • Leere Collections werden über Truthiness geprüft.
  • Befehle werden mit partition(...) in Verb und Argument zerlegt.
  • Gegenstände können nun gezielt mit nimm Fackel aufgenommen werden.
  • Eine List Comprehension liefert Räume, in denen noch Loot liegt.
  • Kurze Ausgaben verwenden Conditional Expressions.

Ö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 argument in raum["ausgaenge"]:
                ort = raum["ausgaenge"][argument]
                raum_anzeigen = True
            else:
                print(f"Du kannst nicht nach {argument or 'dort'} 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"
            )


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 die wichtigsten Ä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}")

Der Code sagt direkt, dass wir jedes Element zusammen mit einer laufenden 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 die Gegenstände eines Raums werden direkt ausgewertet:

if raum["gegenstaende"]:
    print("Hier liegt:", ", ".join(raum["gegenstaende"]))

Die Absicht ist dadurch sofort erkennbar.

Early Return

In zeige_inventar(...) beenden wir die Funktion frühzeitig, wenn das Inventar leer ist:

if not inventar:
    print("Dein Inventar ist leer.")
    return

Der restliche Funktionskörper benötigt dadurch keinen zusätzlichen else-Block.

Input mit Unpacking zerlegen

Die Funktion teile_befehl(...) verwendet partition(...) und Unpacking:

verb, _, argument = eingabe.strip().lower().partition(" ")

Aus

nimm alte fackel

werden:

verb == "nimm"
argument == "alte fackel"

Das Trennzeichen selbst wird an _ gebunden, weil wir es nicht benötigen.

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

beschreibt direkt das gewünschte Ergebnis:

Erzeuge eine Liste mit den Namen aller Räume, deren Gegenstandsliste nicht leer ist.

Eine gewöhnliche Schleife wäre länger:

ergebnis = []

for raum in raeume.values():
    if raum["gegenstaende"]:
        ergebnis.append(raum["name"])

return ergebnis

Beide Varianten funktionieren. Die Comprehension ist hier kompakt und bleibt gut lesbar.

Conditional Expression für kurze Alternativen

Diese Zeile:

text = ", ".join(gegenstaende) if gegenstaende else "nichts Brauchbares"

ersetzt:

if gegenstaende:
    text = ", ".join(gegenstaende)
else:
    text = "nichts Brauchbares"

Da beide Alternativen kurz sind und nur einen Wert bestimmen, eignet sich die Conditional Expression hier gut.

Pythonic bedeutet nicht immer kürzer

Einige Stellen des Dungeon-Codes bleiben bewusst als normale Schleifen oder Bedingungen erhalten.

Die Funktion nimm_gegenstand(...) könnte beispielsweise mit einer Comprehension oder einer Generator Expression noch kompakter geschrieben werden. Die gewöhnliche Schleife macht den Ablauf aber sehr deutlich:

for gegenstand in raum["gegenstaende"]:
    if gegenstand.lower() == gesuchter_name:
        raum["gegenstaende"].remove(gegenstand)
        inventar.append(gegenstand)
        return gegenstand

Die Funktion:

  1. durchsucht die Gegenstände,
  2. entfernt den passenden Gegenstand,
  3. fügt ihn dem Inventar hinzu,
  4. gibt seinen Namen zurück.

Eine künstlich verkürzte Variante wäre nicht automatisch besser.

Pythonic Code verwendet Sprachmittel dort, wo sie die Absicht klarer machen. Er vermeidet sie, wenn sie den Code nur dichter und schwerer lesbar machen.

Stolperfallen

  • Comprehensions überladen: Mehrere verschachtelte for- und if-Teile können eine Comprehension schwer verständlich machen. Verwende dann eine normale Schleife.

  • Eine Comprehension nur wegen ihrer Kürze verwenden: Wenn der Ausdruck Seiteneffekte ausführt oder viele Schritte enthält, ist eine Schleife meistens klarer.

  • Truthiness zu allgemein einsetzen: 0, None, "" und leere Collections sind alle falsy. Prüfe ausdrücklich mit is None, wenn Du None von anderen Werten unterscheiden musst.

  • Slicing mit einer vollständigen Kopie verwechseln: liste[:] erzeugt nur eine flache Kopie. Enthaltene mutable Objekte bleiben gemeinsam referenziert.

  • Die exklusive Stop-Grenze vergessen: Bei werte[1:4] sind die Indizes 1, 2 und 3 enthalten, nicht aber 4.

  • enumerate(...) verwenden, obwohl der Index nicht benötigt wird: Wenn Du nur die Elemente brauchst, ist for element in collection klarer.

  • Von zip(...) gleich lange Collections erwarten: Ohne strict=True endet zip(...) bei der kürzesten Collection, ohne einen Fehler zu melden.

  • _ für besondere Syntax halten: In einem Skript ist _ ein gewöhnlicher Variablenname und nur eine Konvention für nicht benötigte Werte.

  • Bei partition(...) drei Rückgabewerte vergessen: Die Methode liefert immer den Teil davor, das Trennzeichen und den Teil danach.

  • Mehrere Wörter mit split() verlieren: split() zerlegt jedes Wort einzeln. partition(" ") lässt den restlichen Text nach dem ersten Leerzeichen zusammen.

  • Conditional Expressions verschachteln: Mehrere ineinander verschachtelte Bedingungen sind meist schlechter lesbar als ein normales if-elif-else.

Ü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)
Ausgabe:
['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}")
Ausgabe:
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)
Ausgabe:
['Fackel', 'Schlüssel']
Besitzt das Inventar weniger als zwei Elemente, entsteht kein Fehler. Der Slice enthält dann einfach alle vorhandenen Elemente.

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]
Lösung
for name, hp in zip(namen, lebenspunkte, strict=True):
    print(f"{name}: {hp} HP")
Ausgabe:
Goblin: 30 HP
Ork: 50 HP
Drache: 999 HP
Durch `strict=True` würde Python einen Fehler melden, falls die beiden Listen unterschiedlich lang wären.

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 das Verhalten auf mehrere Funktionen.

In Teil 7geplant bündeln wir Daten und Verhalten deshalb 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!