Skip to content

cat posts/en/python-lernen-04-sammlungen.md

Learning Python (Part 4): Collections

Lists, tuples, dictionaries, and sets: with them, we describe the dungeon world as data and give our adventurer a real inventory.

Three wooden treasure chests stand in front of a stone wall in a vault, with the numbers 0, 1, and 2 painted on their lids.

In part 3, we built a game loop that responds to the player's commands. The possible paths and events were packed directly into a long if-elif chain.

With two or three rooms, that still works. But once our dungeon has thirty rooms, the game loop quickly gets hard to follow.

That's why today's topic is collections. With them, we can store several values together and describe our dungeon's world as data.

Python comes with several kinds of collections for this:

  • lists
  • tuples
  • dictionaries
  • sets

They do different jobs. By the end of this part, we use them to model rooms, exits, items, and the player state cleanly.

Lists: ordered and changeable

A list stores several values in a specific order. Lists are written with square brackets:

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

The individual values are called elements. A list can contain any number of elements, or it can be empty:

leeres_inventar = []

inventar is German for inventory, and leeres_inventar means empty inventory.

Accessing elements

Every element has a position, called its index. Counting starts at 0:

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

print(inventar[0])
print(inventar[1])
print(inventar[2])

The output is:

torch
bread
rope

So the indexes of this list look like this:

Index Element
0 "torch"
1 "bread"
2 "rope"

Counting from 0 feels strange at first, but it's very common in programming.

With negative indexes, you can access elements from the end of the list:

print(inventar[-1])

The output is:

rope

-1 refers to the last element, -2 to the second to last, and so on.

Changing lists

Lists are changeable, or mutable. With append(...), you add a single element at the end:

inventar = ["torch", "bread"]
inventar.append("key")

print(inventar)

The output is:

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

With remove(...), you remove the first element with a specific value:

inventar.remove("bread")

print(inventar)

After that, the list contains only:

['torch', 'key']

If you want to add several elements to a list, you can use extend(...):

inventar = ["torch"]
fund = ["key", "healing potion"]

inventar.extend(fund)

print(inventar)

The output is:

['torch', 'key', 'healing potion']

The difference from append(...) matters. This:

inventar.append(fund)

adds the complete list fund (German for a find, here the loot) as one single element:

['torch', ['key', 'healing potion']]

We'd then have a list inside our list.

Finding the number of elements

With len(...), you get the length of a list:

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

print(len(inventar))

The output is:

3

An empty list has a length of 0.

Checking whether an element is included

With in, you can check whether a specific value appears in a list:

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

print("bread" in inventar)
print("key" in inventar)

The output is:

True
False

You can use the result directly in a condition:

if "key" in inventar:
    print("You can open the door.")

Looping over a list

With a for loop, you can process every element in turn:

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

for gegenstand in inventar:
    print(f"- {gegenstand}")

The output is:

- torch
- bread
- rope

You don't need an index for this. On each pass, gegenstand (item) refers to the next element of the list.

Tuples: ordered and unchangeable

A tuple is similar to a list: It's ordered and can contain several values. However, it's unchangeable, or immutable: After a tuple is created, its elements can't be replaced, added, or removed.

Tuples are usually written with parentheses:

position = (3, 7)

You access the elements by their index, as with a list:

print(position[0])
print(position[1])

The output is:

3
7

A position made of an x and a y coordinate is a typical use case: Both values belong together and are treated as a unit.

You can also split them directly into two variables:

position = (3, 7)
x, y = position

print(f"X: {x}, Y: {y}")

This is called unpacking.

Tuples can't be changed

The following attempt leads to a TypeError:

position = (3, 7)
position[0] = 9

Python doesn't let you assign a different value to an element of the tuple after the fact.

That's why tuples are a good fit for values that belong together and whose composition should stay the same after they're created.

A tuple with only one element

For a tuple with only one element, the comma is what counts:

ein_element = ("key",)

Without the comma, you just get a string in parentheses:

kein_tupel = ("key")

print(type(kein_tupel))

The output is:

<class 'str'>

So technically, it's the comma that creates the tuple, not the parentheses on their own. The names ein_element and kein_tupel mean one element and not a tuple.

Dictionaries: looking up values by key

A dictionary, or dict for short, maps keys to values.

With a list, you access an element by a numeric index. With a dictionary, you use a key:

raum = {
    "name": "Hall",
    "beschreibung": "A dark hall with a musty smell.",
    "besucht": False,
}

A dictionary is written with curly braces. Each entry consists of a key and its value:

"beschreibung": "A dark hall with a musty smell."

The colon separates the key from the value.

The variable raum is German for room. Its keys beschreibung and besucht mean description and visited.

Dictionaries keep the order in which their entries were inserted. Unlike with a list, though, you don't access a value by that position, but by its key.

Accessing a value

You get the value for a specific key with square brackets:

print(raum["name"])
print(raum["beschreibung"])

The output is:

Hall
A dark hall with a musty smell.

Changing and adding values

Like lists, dictionaries are mutable. You can change an existing value:

raum["besucht"] = True

The same notation lets you add a new entry:

raum["licht"] = "dark"

If the key already exists, its value is replaced. If it doesn't exist yet, a new entry is created. Here, the new key licht means light.

Checking whether a key exists

For a dictionary, the in operator checks its keys:

print("beschreibung" in raum)
print("monster" in raum)

The output is:

True
False

That comes in handy when an entry exists in only some dictionaries:

if "monster" in raum:
    print("Something is waiting for you here.")

Missing keys and .get(...)

If you use square brackets to access a key that doesn't exist, you get a KeyError:

print(raum["monster"])

With .get(...), you get None for a missing key instead:

monster = raum.get("monster")

print(monster)

You can also specify a default value:

monster = raum.get("monster", "No monster")

print(monster)

The output is:

No monster

This is useful when a missing entry is a normal state and shouldn't count as an error.

Looping over dictionaries

By default, a for loop over a dictionary goes through its keys:

for schluessel in raum:
    print(schluessel)

With .values(), you get the values instead:

for wert in raum.values():
    print(wert)

And with .items(), you get the key and the value together on each pass:

for schluessel, wert in raum.items():
    print(f"{schluessel}: {wert}")

schluessel and wert are German for key and value. The unpacking works here the same way as with the tuple: The key and the value are split directly into the two variables.

Nested collections

The values of a dictionary can themselves be lists, tuples, sets, or other dictionaries:

raum = {
    "name": "Hall",
    "ausgaenge": {
        "north": "bibliothek",
        "east": "krypta",
    },
    "gegenstaende": ["torch", "bread"],
}

Here, raum["ausgaenge"] returns another dictionary:

print(raum["ausgaenge"])

To get the destination of the northern exit, we combine two lookups:

print(raum["ausgaenge"]["north"])

The output is:

bibliothek

The keys ausgaenge and gegenstaende mean exits and items. The values bibliothek and krypta are room IDs: library and crypt.

We'll use exactly this kind of nested structure in a moment to describe the entire dungeon world.

Sets: unordered and without duplicates

A set is an unordered collection in which each value appears at most once:

besuchte_raeume = {"halle", "bibliothek", "halle"}

print(besuchte_raeume)

Even though "halle" (hall) was given twice, the value appears only once in the set. One possible output is:

{'halle', 'bibliothek'}

The order might look different, too. Sets have no guaranteed position for their elements, and so there's no access by index either. besuchte_raeume is German for visited rooms.

Adding values

With .add(...), you add a value to a set:

besuchte_raeume.add("krypta")

If you add the same value again, the set stays unchanged:

besuchte_raeume.add("krypta")

"krypta" is still contained only once.

Checking membership

With sets, you also use in:

if "krypta" in besuchte_raeume:
    print("You've already been to the crypt.")

Sets are particularly well suited to cases where what you mainly care about is whether a value is already present, for example:

  • visited rooms
  • unlocked abilities
  • known spells
  • unique IDs

Creating an empty set

You create an empty set with set():

besuchte_raeume = set()

Empty curly braces, on the other hand, create a dictionary:

leere_sammlung = {}

print(type(leere_sammlung))

The output is:

<class 'dict'>

leere_sammlung is German for empty collection. This is easy to mix up, because non-empty sets are also written with curly braces.

Which collection fits which job?

Collection Order Mutable Duplicates Access
List fixed order yes yes by index
Tuple fixed order no yes by index
Dictionary insertion order yes keys unique by key
Set no guaranteed order yes no by membership

For our dungeon, we use:

  • a dictionary for the player state
  • a list for the inventory
  • a dictionary for the rooms
  • a dictionary for each room's exits
  • a list for the items in a room
  • a set for rooms already visited

Each part of our state now gets the collection that suits its job.

The dungeon world as data

So far, the world was built directly into the program logic:

if befehl == "north":
    print("You're standing in front of a locked door.")
elif befehl == "east":
    print("A goblin is waiting in the east.")

As a result, every new room and every new exit required additional conditions.

Instead, we now describe the world in one big dictionary:

raeume = {
    "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": [],
    },
}

The keys "halle", "bibliothek", and "krypta" are the rooms' internal IDs. raeume is German for rooms. Each room is itself a dictionary with four entries:

  • name
  • beschreibung
  • ausgaenge
  • gegenstaende

The exits map a direction to the ID of the destination room:

"ausgaenge": {
    "north": "bibliothek",
    "east": "krypta",
}

So the hall's northern exit leads to the room with the ID "bibliothek", and the eastern one to "krypta".

This means we no longer have to write a separate elif block for every possible direction.

Grouping the player state

The player state now also consists of several values that belong together:

name = "Karl"
hp = 100
max_hp = 100
gold = 50
inventar = []

We can group these values in a dictionary as well:

spieler = {
    "name": "Karl",
    "hp": 100,
    "max_hp": 100,
    "gold": 50,
    "inventar": [],
}

Access now goes through the respective keys:

print(spieler["name"])
print(spieler["hp"])
print(spieler["inventar"])

spieler is German for player. This way, the code also makes it immediately clear which values belong to the player state.

The dungeon, step 4

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

# Dungeon—Part 4: Collections and the world as data
raeume = {
    "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": [],
    },
}

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, inventory, status, quit")

while True:
    raum = raeume[ort]

    if raum_anzeigen:
        besuchte_raeume.add(ort)

        print()
        print(raum["beschreibung"])
        print("Exits:", ", ".join(raum["ausgaenge"]))

        raum_anzeigen = False

    befehl = input("\n> ").strip().lower()

    if befehl == "quit":
        print("You leave the dungeon. Until next time.")
        break

    elif befehl == "look":
        if raum["gegenstaende"]:
            print("Lying here:", ", ".join(raum["gegenstaende"]))
        else:
            print("There's nothing useful here.")

    elif befehl == "take":
        if raum["gegenstaende"]:
            gefundene_gegenstaende = ", ".join(raum["gegenstaende"])

            spieler["inventar"].extend(raum["gegenstaende"])
            raum["gegenstaende"].clear()

            print(f"You take: {gefundene_gegenstaende}")
        else:
            print("There's nothing to take here.")

    elif befehl == "inventory":
        if spieler["inventar"]:
            print("Inventory:", ", ".join(spieler["inventar"]))
        else:
            print("Your inventory is empty.")

    elif befehl == "status":
        print(
            f"{spieler['name']} | "
            f"HP: {spieler['hp']}/{spieler['max_hp']} | "
            f"Gold: {spieler['gold']}"
        )
        print(f"Rooms visited: {len(besuchte_raeume)}")
        print(f"Items: {len(spieler['inventar'])}")

    elif befehl in raum["ausgaenge"]:
        ort = raum["ausgaenge"][befehl]
        raum_anzeigen = True

    else:
        print("You can't do that here.")
        print("Possible exits:", ", ".join(raum["ausgaenge"]))
        print("Other commands: look, take, inventory, status, quit")

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, inventory, status, quit

You're standing in a dark hall with a musty smell.
Exits: north, east

> look
Lying here: torch

> take
You take: torch

> north

Dusty shelves full of tattered books surround you.
Exits: south

> take
You take: key

> inventory
Inventory: torch, key

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

> quit
You leave the dungeon. Until next time.

What happens in the new game loop

The player state now lives in a dictionary:

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": [],
}

The inventory is a list inside this dictionary:

spieler["inventar"]

So we're already combining two collections: A dictionary groups the player state, while a list stores the player's items.

The current room is still stored in the variable ort (German for location):

ort = "halle"

With this value, we get the current room's dictionary from our world:

raum = raeume[ort]

If ort contains the string "halle", that's the same as this lookup:

raum = raeume["halle"]

raum then refers to the hall's dictionary. When we change its contents, we also change the corresponding room inside raeume.

Storing rooms already visited

The set besuchte_raeume starts out empty:

besuchte_raeume = set()

As soon as a room is displayed, we add its ID:

besuchte_raeume.add(ort)

If the player enters the same room again later, its ID still appears only once in the set. So with len(...), we get the number of different rooms visited so far.

Showing the room only after moving

The Boolean variable raum_anzeigen (show room) determines whether the current room's description should be printed:

raum_anzeigen = True

After printing it, we set the variable to False:

raum_anzeigen = False

That way, the room description doesn't appear again after every single command.

When the player moves to another room, we set the variable back to True:

ort = raum["ausgaenge"][befehl]
raum_anzeigen = True

On the next pass of the game loop, the new room's dictionary is loaded and its description is printed.

Recognizing valid directions

This expression checks whether the command entered matches a key in the exits dictionary:

elif befehl in raum["ausgaenge"]:

When the player is in the hall, this dictionary contains the following entries:

{
    "north": "bibliothek",
    "east": "krypta",
}

So the commands "north" and "east" are valid there.

We get the destination of the chosen direction directly from the key:

ort = raum["ausgaenge"][befehl]

If the player types "north", that's the same as:

ort = raum["ausgaenge"]["north"]

The new value of ort is then "bibliothek".

This way, moving through the dungeon no longer needs a separate condition for each individual direction. The same line works for all exits defined in the current room.

Picking up items

The items in the current room are in a list:

raum["gegenstaende"]

With extend(...), we move all elements of this list into the inventory:

spieler["inventar"].extend(raum["gegenstaende"])

Then we empty the list in the room:

raum["gegenstaende"].clear()

The items are now in the inventory and no longer in the room. So the player can't pick them up a second time.

By the way, our take command currently picks up all of a room's items at once. We'll add a way to choose individual items later.

Printing lists as a string

The join(...) method joins several strings into one string:

", ".join(spieler["inventar"])

This turns

["torch", "key"]

into:

torch, key

The string before .join(...) determines what's placed between the individual elements. Here, that's a comma followed by a space.

join(...) expects the elements being joined to be strings. So a list of numbers doesn't work directly:

", ".join([1, 2, 3])

That would lead to a TypeError.

Separating data and logic

The most important step in this part is less a single method than the new structure of our program.

The rooms are described as data:

raeume = {
    ...
}

The game loop, on the other hand, contains the logic that works with this data:

while True:
    ...

If you want to add another room, you no longer have to build new conditions into the game loop for each of its directions. You add the room to the raeume dictionary and connect it to the existing world through its exits.

That doesn't make our dungeon a finished game engine by a long shot. But the world and the program logic are now far less tightly wired together. That makes the next extensions a lot more pleasant.

Pitfalls

  • Forgetting that indexes start at 0: A list with three elements has the indexes 0, 1, and 2. Accessing inventar[3] leads to an IndexError.

  • Removing a value with remove(...) that doesn't exist: If remove(...) can't find the given value in the list, you get a ValueError.

  • Using a missing dictionary key: An access like raum["monster"] leads to a KeyError if the key doesn't exist. With raum.get("monster"), you get None instead.

  • Mixing up lists and dictionaries: You access lists with numeric indexes, and dictionaries with their keys.

python inventar[0] raum["beschreibung"]

  • Mixing up append(...) and extend(...): append(...) adds a value as a new element. extend(...) adds several elements to the existing list.

  • Creating an empty set with {}: {} creates an empty dictionary. For an empty set, you need set().

  • Assuming a fixed set order: Sets don't have an order your program logic can rely on. Use a list or a tuple if the position of the elements matters.

  • Confusing dictionary order with sorting: Dictionaries keep their insertion order, but they don't sort their keys automatically.

  • Joining numbers directly with join(...): join(...) expects strings. So ", ".join([1, 2, 3]) leads to a TypeError.

  • Expecting special characters in commands to match: A command has to match the dictionary key character for character. That's why the commands in the code stick to plain letters like "south", so that nobody has to type special characters.

Exercises

1. Add a treasure chamber

Add a fourth room called "schatzkammer" (treasure chamber) to the dungeon. It should be reachable to the north from the library and contain a "pile of gold".

Solution Add the following entry to the `raeume` dictionary:
"schatzkammer": {
    "name": "Treasure chamber",
    "beschreibung": "Gold and gems glitter in the light of your torch.",
    "ausgaenge": {
        "south": "bibliothek",
    },
    "gegenstaende": ["pile of gold"],
},
Also extend the library's exits:
"ausgaenge": {
    "south": "halle",
    "north": "schatzkammer",
},
The library now leads north into the treasure chamber. From there, the player gets back south to the library.

2. Add a starting item

Give the player a piece of bread at the start. So the inventory should no longer start out empty.

Solution Change the `inventar` entry in the player state:
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": ["bread"],
}
The `inventory` command then shows the bread right away.

3. Print all of a room's exits

Write a for loop that prints all of the hall's directions and destination rooms.

The output should look something like this:

north -> bibliothek
east -> krypta
Solution
halle = raeume["halle"]

for richtung, ziel in halle["ausgaenge"].items():
    print(f"{richtung} -> {ziel}")
On each pass, `.items()` returns the key and the value. Both are unpacked directly into the variables `richtung` and `ziel`, German for direction and destination.

Next up

The world is now described as data. With all its commands, though, the game loop is still fairly long.

That's why, in part 5, you learn about functions. With them, we break the logic into named, reusable blocks and make the game loop easier to follow again.

0 comments

No comments yet. Be the first!