Skip to content

cat posts/en/python-lernen-06-pythonic-schreiben.md

Learning Python (Part 6): Writing Pythonic code

Comprehensions, slicing, enumerate, zip, unpacking, and truthiness: these idioms make our Python code more compact, clearer, and easier to read.

Pop-art comic: A hand pulls up the zipper of a blue jacket, next to a jagged comic explosion with “ZIP!” in front of a yellow halftone dot pattern.

The dungeon from part 5 is now neatly split into functions. The code works, it's understandable, and it can be extended.

Today only a few new game commands are added. Instead, we look at how to express typical tasks the way it's usually done in Python.

The term often used for this is Pythonic. Pythonic code uses the language's features and idioms instead of laboriously recreating patterns you know from other programming languages.

Keep this in mind:

Pythonic doesn't mean writing as few characters as possible. What counts is that the intent of the code stays easy to see.

We cover comprehensions, truthiness, slicing, enumerate, zip, unpacking, and a few other idioms. Then we apply some of them to our dungeon.

Comprehensions

Let's say we want to convert the IDs of several rooms to uppercase.

With a regular loop, we can build a new list step by step:

raum_ids = ["halle", "bibliothek", "krypta"]

namen = []

for raum_id in raum_ids:
    namen.append(raum_id.upper())

print(namen)

The output is:

['HALLE', 'BIBLIOTHEK', 'KRYPTA']

namen is German for names. Python offers a more compact notation for this: the list comprehension.

namen = [raum_id.upper() for raum_id in raum_ids]

The result is the same.

The basic form is:

[ausdruck for element in iterable]

The expression (ausdruck) is evaluated for each element. The results end up in a new list.

In our example:

[raum_id.upper() for raum_id in raum_ids]

you can read the line roughly like this:

  1. Take each raum_id in turn.
  2. Call .upper() on it.
  3. Put the results into a new list.

So in a comprehension, the expression comes before the loop. That feels a bit unusual the first time you read it, but you get used to it quickly.

Filtering elements in a comprehension

With an if at the end, you can keep only certain elements:

gegenstaende = ["bread", "torch", "rope", "key"]

lange_namen = [
    gegenstand
    for gegenstand in gegenstaende
    if len(gegenstand) > 4
]

print(lange_namen)

The output is:

['bread', 'torch']

lange_namen means long names. The general form is:

[ausdruck for element in iterable if bedingung]

The condition (bedingung) decides which elements make it into the new list.

For our dungeon world, we can, for example, find all rooms that still have items lying in them:

raeume_mit_loot = [
    raum["name"]
    for raum in raeume.values()
    if raum["gegenstaende"]
]

Two Python idioms meet here: a comprehension and truthiness. A non-empty list of items evaluates as true, an empty one as false. We'll look at what's behind that in more detail in a moment.

List comprehension or regular loop?

A comprehension is a good fit when you create new values from an iterable and the whole thing can be described with a short expression.

This example is compact and still easy to read:

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

The result (quadrate means squares) would be:

[1, 4, 9, 16, 25]

But a comprehension isn't automatically better just because it's shorter.

This example is already a lot denser:

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

As a regular loop, the flow is easier to follow:

werte = []

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

Pythonic code doesn't mean code golf. If the longer version is easier to understand, it's usually the better one, too.

Dict comprehensions

Besides lists, you can also create dictionaries with a comprehension.

From this dictionary:

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

we build a new dictionary with halved hit points (lebenspunkte is German for hit points):

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

print(halbe_lebenspunkte)

The output is:

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

The basic form looks like this:

{
    schluessel: wert
    for element in iterable
}

Dict comprehensions can filter too:

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

The result, the strong opponents, is:

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

Of course, both the key and the value can be calculated as well. So a dict comprehension is more than just a shorter way to copy a dictionary.

Set comprehensions

Sets can also be created with a comprehension:

richtungen = ["North", "South", "NORTH", "East"]

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

print(normalisiert)

Since a set contains each value at most once, "North" and "NORTH" merge into the same entry after .lower().

One possible output is:

{'north', 'south', 'east'}

The order might look different. Sets don't have an order your program logic can rely on.

The notation is similar to a dict comprehension, but without a colon:

{ausdruck for element in iterable}

Truthiness: using values as conditions

Python can evaluate practically any object directly as a Boolean.

The built-in values that count as False include:

  • False
  • None
  • numeric zero values such as 0 and 0.0
  • empty strings such as ""
  • empty lists such as []
  • empty tuples such as ()
  • empty dictionaries such as {}
  • empty sets such as set()

Non-empty collections and numbers other than 0, on the other hand, normally evaluate as True.

You can try this out with bool(...):

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

With your own classes, you can influence which Boolean their objects produce. For the data types we've used so far, the rules above are enough for now.

Checking collections without len(...)

Let's say you want to know whether the inventory contains at least one item.

This works:

if len(inventar) > 0:
    print("You're carrying something.")

In Python, you usually write it directly:

if inventar:
    print("You're carrying something.")

The code then says pretty much exactly what we want to know:

Is the inventory not empty?

For the opposite case, we use not:

if not inventar:
    print("Your inventory is empty.")

Instead of:

if len(inventar) == 0:
    print("Your inventory is empty.")

The shorter version doesn't merely save characters here. It expresses the intent more directly.

Using truthiness with care

Truthiness is handy, but it can lump together information that you might want to tell apart.

Let's say gold can contain either a number or None:

gold = None

This check doesn't distinguish between None and 0:

if not gold:
    print("No gold available.")

Both values are falsy.

If you explicitly want to know whether no gold value has been set yet, you check for None:

if gold is None:
    print("The gold value hasn't been set yet.")

For the numeric value 0, on the other hand, you use:

if gold == 0:
    print("You don't have any gold coins.")

So truthiness is the right choice when the different falsy values are allowed to mean the same thing to your code.

Conditional expressions

You can make a small decision directly inside an expression:

status = "alive" if hp > 0 else "defeated"

This notation is called a conditional expression.

It's equivalent to:

if hp > 0:
    status = "alive"
else:
    status = "defeated"

An example from our dungeon:

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

This form works well when both alternatives are short and only decide which value is used.

Several nested conditional expressions, on the other hand, quickly get unwieldy:

status = "dead" if hp <= 0 else "critical" if hp <= 25 else "ready"

You can read it, but a regular if-elif-else structure shows the cases more clearly.

Slicing: excerpts from sequences

With slicing, you can select excerpts from sequences such as lists, tuples, or strings.

The basic form is:

sequenz[start:stop]

start is included, stop isn't.

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

print(zahlen[1:4])

The output is:

[1, 2, 3]

So we start at index 1 and stop before index 4. zahlen is German for numbers.

Leaving out limits

If you leave out start, the slice begins at the beginning:

zahlen[:3]

Result:

[0, 1, 2]

If you leave out stop, it runs to the end:

zahlen[3:]

Result:

[3, 4, 5]

Both limits can be missing, too:

kopie = zahlen[:]

With a list, this creates a new list with the same elements.

Negative indexes

As with regular access to a sequence, you can use negative indexes when slicing. They count from the end:

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

print(zahlen[-2:])

Output:

[4, 5]

For an inventory, we could use this to select the two items added most recently, for example:

inventar = ["bread", "torch", "rope", "key"]

letzte_funde = inventar[-2:]

letzte_funde (latest finds) contains:

["rope", "key"]

If there are fewer than two elements, that's no problem either. A slice doesn't run off the end of the sequence with an IndexError. It returns the elements that are there.

The step when slicing

Slicing has a third value: the step.

sequenz[start:stop:schritt]

That lets us, for example, select every second element:

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

print(zahlen[::2])

Output:

[0, 2, 4]

A negative step runs in the other direction. This well-known shorthand reverses a sequence:

print(zahlen[::-1])

Output:

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

This also works with strings:

wort = "Dungeon"

print(wort[:3])
print(wort[::-1])

Output:

Dun
noegnuD

Slicing creates a shallow copy of a list

With a list, a slice such as [:] creates a new list:

inventar = ["torch", "bread"]
kopie = inventar[:]

kopie.append("key")

print(inventar)
print(kopie)

Output:

['torch', 'bread']
['torch', 'bread', 'key']

kopie (copy) and inventar are two different lists. So adding the key changes only kopie.

However, it's a shallow copy. If the elements themselves are mutable objects, they aren't copied as well:

raeume = [
    {"name": "Hall"},
    {"name": "Crypt"},
]

kopie = raeume[:]
kopie[0]["name"] = "Great hall"

print(raeume[0]["name"])

The output is:

Great hall

The two outer lists are different, but they contain references to the same dictionaries. So if we change one of these dictionaries, the change is visible through both lists.

enumerate: number and element together

Sometimes, when going through an iterable, we also need a running number.

We could work with indexes ourselves for that:

richtungen = ["north", "east", "west"]

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

That works, but it's more cumbersome than necessary. This is exactly what enumerate(...) is for:

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

The output is:

1) north
2) east
3) west

When you loop over it, enumerate(...) returns a pair of counter and current element each time. Thanks to unpacking, both land directly in the matching variables:

nummer, richtung

nummer and richtung mean number and direction. Without start=1, the counter starts at 0 by default:

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

Output:

0 north
1 east
2 west

Use enumerate(...) when you need the element and a running number. If the number doesn't matter, the direct loop stays simpler:

for richtung in richtungen:
    print(richtung)

zip: going through iterables in parallel

With zip(...), you can go through several iterables in parallel:

namen = ["Goblin", "Orc", "Dragon"]
lebenspunkte = [30, 50, 999]

for name, hp in zip(namen, lebenspunkte):
    print(f"{name}: {hp} HP")

The output is:

Goblin: 30 HP
Orc: 50 HP
Dragon: 999 HP

On each pass, zip(...) combines the elements at the same position:

"Goblin"  + 30
"Orc"     + 50
"Dragon"  + 999

More precisely, zip(...) creates tuples that we unpack again right away in the for loop:

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

zip(...) works lazily. It doesn't first build a complete list of all pairs. Instead, it delivers them one by one as you loop.

By default, zip stops at the shortest iterable

If the inputs have different lengths, zip(...) stops by default as soon as the shortest iterable is exhausted:

namen = ["Goblin", "Orc", "Dragon"]
lebenspunkte = [30, 50]

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

Output:

Goblin 30
Orc 50

The dragon isn't processed. Python doesn't report an error.

If different lengths would mean a bug in your data, you can use strict=True:

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

If the lengths differ, zip(...) raises a ValueError.

Especially when two series of data must always have the same number of elements, strict=True keeps missing values from being silently cut off.

Unpacking

With unpacking, you distribute values from an iterable across several names:

position = (3, 7)

x, y = position

After that:

x == 3
y == 7

Normally, the number of names has to match the number of values supplied:

x, y = (3, 7, 9)

Here, three values don't fit into two names. So Python raises a ValueError.

We've already used unpacking several times, for example with a dictionary's .items() or together with enumerate() and zip().

Swapping values with unpacking

In Python, you can swap two values without a helper variable:

x = 3
y = 7

x, y = y, x

print(x, y)

Output:

7 3

The values on the right-hand side are evaluated before they're assigned to the names on the left-hand side. That way, neither of the two old values is lost.

Collecting several values with *

When unpacking, a name marked with * can take up several leftover values:

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

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

Output:

1
[2, 3, 4]
5

The name marked with * gets a list. The names mean first, middle, and last.

You can use this to break up an input, for example:

befehl, *argumente = "take old torch".split()

print(befehl)
print(argumente)

Output:

take
['old', 'torch']

Only one name can be marked with * in such an assignment.

For our dungeon, though, a different method is more practical, because we want to keep the rest of the input as a single string.

_ for values you don't need

The underscore _ is often used as the name for a value we deliberately don't need.

The string method partition(...) splits a string into three parts:

befehl = "take torch"

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

After that:

verb == "take"
trennzeichen == " "
argument == "torch"

If we don't need the separator (trennzeichen), we usually write:

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

In a regular script, _ has no special technical meaning. It's a perfectly ordinary name. The notation only signals to other readers of the code:

We're deliberately not using this value.

In the interactive Python shell, _ can also stand for the result of the most recently evaluated expression. Don't rely on that in regular scripts.

Splitting commands with partition(...)

partition(" ") fits commands that consist of a verb and an optional argument:

eingabe = "take old torch"

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

print(verb)
print(argument)

Output:

take
old torch

Unlike a plain split(), partition(...) splits only at the first occurrence of the given separator. The rest of the text stays together. eingabe is German for input.

If the separator isn't found at all:

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

we get:

verb == "inventory"
argument == ""

partition(...) always returns three values. If the separator is missing, the second and third values here are empty strings.

Since an empty string is falsy, we can then check directly:

if not argument:
    print("No argument was given.")

any(...) and all(...)

Two more useful built-ins are any(...) and all(...).

any(...) checks whether at least one element of an iterable is truthy:

tuer_offen = False
fenster_offen = True
geheimgang_offen = False

if any([tuer_offen, fenster_offen, geheimgang_offen]):
    print("There's an open exit.")

The three names mean door open, window open, and secret passage open. Since fenster_offen is True, any(...) also gives True.

all(...), on the other hand, checks whether all elements are truthy:

hat_schluessel = True
hat_fackel = True
hat_karte = False

if all([hat_schluessel, hat_fackel, hat_karte]):
    print("You're fully equipped.")

Here, the result is False because hat_karte (has map) is False.

For a few fixed conditions, and and or are usually more direct:

if hat_schluessel and hat_fackel:
    print("You can move on.")

any(...) and all(...) become particularly handy when the values to check come from an iterable anyway or are generated dynamically.

With empty iterables, there's one more difference that can seem surprising at first:

>>> any([])
False
>>> all([])
True

any(...) can't find a single true value in an empty list. For all(...), on the other hand, there's also no value that violates the condition.

The dungeon, step 6

Now we apply some of the new idioms to the dungeon from part 5.

Among other things, we change the following:

  • Exits and inventory are numbered with enumerate(...).
  • We still check empty collections with truthiness—now we know what's behind it.
  • We split commands into a verb and an argument with partition(...).
  • With take torch, you can pick up individual items.
  • A list comprehension finds rooms that still have loot in them.
  • For short alternatives, we use a conditional expression.

Open dungeon.py and replace the previous content with the following code:

# Dungeon—Part 6: Writing Pythonic code
def erstelle_raeume():
    """Creates the dungeon's rooms and returns them."""
    return {
        "halle": {
            "name": "Hall",
            "beschreibung": (
                "You're standing in a dark hall with a musty smell."
            ),
            "ausgaenge": {
                "north": "bibliothek",
                "east": "krypta",
            },
            "gegenstaende": ["torch"],
        },
        "bibliothek": {
            "name": "Library",
            "beschreibung": (
                "Dusty shelves full of tattered books surround you."
            ),
            "ausgaenge": {
                "south": "halle",
            },
            "gegenstaende": ["key"],
        },
        "krypta": {
            "name": "Crypt",
            "beschreibung": (
                "Moisture glistens on the walls of the old crypt."
            ),
            "ausgaenge": {
                "west": "halle",
            },
            "gegenstaende": [],
        },
    }


def zeige_raum(raum):
    """Shows the current room, its exits, and visible items."""
    print()
    print(f"Location: {raum['name']}")
    print(raum["beschreibung"])

    print("Exits:")

    for nummer, richtung in enumerate(raum["ausgaenge"], start=1):
        print(f"  {nummer}) {richtung}")

    if raum["gegenstaende"]:
        print("Lying here:", ", ".join(raum["gegenstaende"]))


def zeige_gegenstaende(raum):
    """Shows the items in the current room."""
    gegenstaende = raum["gegenstaende"]

    text = ", ".join(gegenstaende) if gegenstaende else "nothing useful"
    print(f"Lying here: {text}")


def zeige_inventar(inventar):
    """Shows the inventory as a numbered list."""
    if not inventar:
        print("Your inventory is empty.")
        return

    print("Inventory:")

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


def nimm_gegenstand(raum, inventar, name):
    """Picks up a specific item and returns its name."""
    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):
    """Creates a compact status line for the player."""
    return (
        f"{spieler['name']} | "
        f"HP: {spieler['hp']}/{spieler['max_hp']} | "
        f"Gold: {spieler['gold']} | "
        f"Inventory: {len(spieler['inventar'])}"
    )


def zeige_status(spieler, besuchte_raeume):
    """Shows the player state and a few statistics."""
    print(erstelle_statuszeile(spieler))
    print(f"Rooms visited: {len(besuchte_raeume)}")


def teile_befehl(eingabe):
    """Splits the input into a verb and an optional argument."""
    verb, _, argument = eingabe.strip().lower().partition(" ")

    return verb, argument.strip()


def finde_raeume_mit_loot(raeume):
    """Returns the names of all rooms that have items in them."""
    return [
        raum["name"]
        for raum in raeume.values()
        if raum["gegenstaende"]
    ]


def main():
    """Starts the dungeon and runs the game loop."""
    raeume = erstelle_raeume()

    print("You stand before the rusty gate of a forgotten dungeon.")
    print("A cold draft blows toward you.")

    spieler = {
        "name": input("What's your name, adventurer? ").strip(),
        "hp": 100,
        "max_hp": 100,
        "gold": int(input("How many gold coins are you bringing? ")),
        "inventar": [],
    }

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

    print()
    print(f"Welcome, {spieler['name']}!")
    print(
        "Commands: look, take <item>, inventory, status, "
        "loot, go <direction>, quit"
    )

    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 == "quit":
            print("You leave the dungeon. Until next time.")
            break

        elif verb == "look":
            zeige_gegenstaende(raum)

        elif verb == "take":
            if not argument:
                print("What do you want to take?")
                continue

            gegenstand = nimm_gegenstand(
                raum,
                spieler["inventar"],
                argument,
            )

            if gegenstand:
                print(f"You take the {gegenstand}.")
            else:
                print(f"There's no item called {argument} here.")

        elif verb == "inventory":
            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 "(none)"

            print("Rooms with loot:", text)

        elif verb == "go":
            if not argument:
                print("Which direction do you want to go?")
                continue

            if argument in raum["ausgaenge"]:
                ort = raum["ausgaenge"][argument]
                raum_anzeigen = True
            else:
                print(f"You can't go {argument}.")

        elif verb in raum["ausgaenge"] and not argument:
            ort = raum["ausgaenge"][verb]
            raum_anzeigen = True

        else:
            print("I don't understand that.")
            print("Possible exits:", ", ".join(raum["ausgaenge"]))
            print(
                "Other commands: look, take <item>, inventory, "
                "status, loot, go <direction>, quit"
            )


if __name__ == "__main__":
    main()

Run the program:

python dungeon.py

One possible run looks like this:

You stand before the rusty gate of a forgotten dungeon.
A cold draft blows toward you.
What's your name, adventurer? Karl
How many gold coins are you bringing? 50

Welcome, Karl!
Commands: look, take <item>, inventory, status, loot, go <direction>, quit

Location: Hall
You're standing in a dark hall with a musty smell.
Exits:
  1) north
  2) east
Lying here: torch

> take torch
You take the torch.

> inventory
Inventory:
  1) torch

> go north

Location: Library
Dusty shelves full of tattered books surround you.
Exits:
  1) south
Lying here: key

> take key
You take the key.

> status
Karl | HP: 100/100 | Gold: 50 | Inventory: 2
Rooms visited: 2

> loot
Rooms with loot: (none)

> quit
You leave the dungeon. Until next time.

What became Pythonic?

Let's take a closer look at some of the changes.

Numbered exits with enumerate(...)

Instead of managing a counter ourselves:

nummer = 1

for richtung in raum["ausgaenge"]:
    print(f"{nummer}) {richtung}")
    nummer = nummer + 1

we use:

for nummer, richtung in enumerate(raum["ausgaenge"], start=1):
    print(f"  {nummer}) {richtung}")

We don't have to create the counter ourselves or increase it. The code says directly that we also need a running number while looping.

Truthiness for empty collections

The inventory is checked without len(...):

if not inventar:
    print("Your inventory is empty.")
    return

For a room's items, the collection itself is also enough as a condition:

if raum["gegenstaende"]:
    print("Lying here:", ", ".join(raum["gegenstaende"]))

In both cases, all we care about is whether the collection is empty.

Early return

In zeige_inventar(...), we end the function immediately if there's nothing to show:

if not inventar:
    print("Your inventory is empty.")
    return

After that, we can write the normal case without an additional else block:

print("Inventory:")

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

This keeps the function body flatter.

Splitting input with partition(...) and unpacking

The teile_befehl(...) function (split command) contains this line:

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

This turns

take old torch

into:

verb == "take"
argument == "old torch"

The separator itself ends up in _ because we don't need it.

Since we also call .lower() before partition(...), both the verb and the argument are normalized. "TAKE TORCH" and "take torch" are treated the same.

Finding rooms with loot using a comprehension

The function

def finde_raeume_mit_loot(raeume):
    return [
        raum["name"]
        for raum in raeume.values()
        if raum["gegenstaende"]
    ]

reads almost like a description of the desired result (its name means find rooms with loot):

Create a list with the names of all rooms whose list of items isn't empty.

As a regular loop, the same thing would look like this:

ergebnis = []

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

return ergebnis

Both versions are correct. Here, the comprehension stays easy to follow and covers exactly one transformation with a simple filter condition.

Conditional expression for short alternatives

In zeige_gegenstaende(...), we use:

text = ", ".join(gegenstaende) if gegenstaende else "nothing useful"

Written out, that would be:

if gegenstaende:
    text = ", ".join(gegenstaende)
else:
    text = "nothing useful"

Since we're only choosing between two short values, the conditional expression stays easy to read.

We do the same for the loot command:

text = ", ".join(raeume_mit_loot) if raeume_mit_loot else "(none)"

Pythonic doesn't always mean shorter

Some parts of the dungeon code deliberately stay as regular loops and conditions.

nimm_gegenstand(...) (take item), for example, searches the items step by step:

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

As soon as a matching item is found, it's removed from the room, put into the inventory, and returned. The return that follows ends the loop immediately as well.

You could write this code more compactly with other Python features. But that wouldn't automatically make it more understandable for beginners.

Pythonic code uses idioms where they express the intent more clearly. A regular loop isn't bad Python code just because there's a one-liner to be found somewhere.

Pitfalls

  • Overloading comprehensions: Several nested for and if parts can make a comprehension hard to understand. A regular loop is then often the better choice.

  • Using a comprehension only because it's short: Comprehensions are mainly suited to creating new collections. Complicated flows and side effects usually belong in a regular loop.

  • Using truthiness too broadly: 0, None, "", and empty collections are all falsy. Use is None, for example, if you have to tell None apart from 0 explicitly.

  • Confusing slicing with a full copy: liste[:] creates a shallow copy. Mutable objects inside the list aren't copied as well.

  • Forgetting the exclusive stop limit: With werte[1:4], the indexes 1, 2, and 3 are included, but index 4 isn't.

  • Using enumerate(...) even though the number isn't needed: If you only need the elements, for element in iterable is simpler.

  • Overlooking inputs of different lengths with zip(...): Without strict=True, zip(...) stops at the shortest iterable. The remaining elements aren't processed.

  • Thinking _ is special syntax: In a regular script, _ is an ordinary name. Its meaning of “I don't need this value” is a convention.

  • Forgetting the three return values of partition(...): The method always returns the part before the separator, the separator itself, and the part after it. If the separator isn't found, the last two strings are empty.

  • Treating split() and partition() the same: split() can break an input into any number of parts. partition(" ") splits exactly at the first space it finds and keeps the rest together.

  • Nesting conditional expressions: As soon as several conditions are nested inside each other, a regular if-elif-else is usually easier to read.

Exercises

1. Filter long item names

Use a list comprehension to create a new list from the following list that contains only items with more than four characters:

gegenstaende = ["torch", "bread", "rope", "key"]
Solution
lange_namen = [
    gegenstand
    for gegenstand in gegenstaende
    if len(gegenstand) > 4
]

print(lange_namen)
Output:
['torch', 'bread']

2. Print the inventory as a numbered list

Print the inventory numbered with enumerate(...). The numbering should start at 1.

Solution
inventar = ["torch", "key", "bread"]

for nummer, gegenstand in enumerate(inventar, start=1):
    print(f"{nummer}. {gegenstand}")
Output:
1. torch
2. key
3. bread

3. Show the last two items

Use slicing to print only the last two items in the inventory.

Solution
inventar = ["bread", "rope", "torch", "key"]

letzte_funde = inventar[-2:]

print(letzte_funde)
Output:
['torch', 'key']
If the inventory has fewer than two elements, there's no error. The slice then simply contains all the elements there are.

4. Combine names and hit points

Combine the following lists with zip(...) and print each opponent together with their hit points:

namen = ["Goblin", "Orc", "Dragon"]
lebenspunkte = [30, 50, 999]

The two lists should always be the same length.

Solution
for name, hp in zip(namen, lebenspunkte, strict=True):
    print(f"{name}: {hp} HP")
Output:
Goblin: 30 HP
Orc: 50 HP
Dragon: 999 HP
With `strict=True`, Python raises a `ValueError` if the two lists have different lengths. Since we expect exactly one HP value for each opponent here, that's the behavior we want.

Going deeper

Some aspects of Pythonic code can be checked and formatted automatically:

ruff: Linter and formatter in one

Next up

Our rooms and the player state still consist of nested dictionaries. That works, but it allows typos in keys such as "max_hp", for example, and it spreads data and the behavior that belongs to it across several places in the program.

In part 7, we bundle both into classes. The player state, the rooms, and the items become objects of their own, with matching attributes and methods.

0 comments

No comments yet. Be the first!