Skip to content

cat posts/en/python-lernen-05-funktionen.md

Learning Python (Part 5): Functions

Functions break code into named, reusable blocks. With parameters, return values, and scope, our game loop becomes easy to follow again.

A cast-iron meat grinder stands on a kitchen counter with a raw potato in its hopper, and french fries drop from its outlet onto a plate.

In part 4, we described our dungeon's rooms as data and grouped the player state in a dictionary. That means we no longer have to write separate code for every room.

Our game loop works, but by now it handles quite a lot of jobs: It displays rooms, manages the inventory, prints the player state, and processes the player's commands.

Today we break this logic into functions. A function bundles related code under a name. We can then call that code where we need it, reuse it, and work on it separately from the rest of the program.

Defining a function

You define a function with the def keyword:

def begruesse():
    print("Welcome to the dungeon!")

def is followed by the function's name, parentheses, and a colon. Below that, indented, comes the function body:

def begruesse():
    print("Welcome to the dungeon!")

The definition alone doesn't run the function body yet. Only a call with parentheses starts the function:

begruesse()

The output is:

Welcome to the dungeon!

begruesse is German for greet. You can call the same function as often as you like:

begruesse()
begruesse()
begruesse()

The function body runs again on every call.

Naming functions well

A function's name should make it as clear as possible what the function does.

Good function names often start with a verb:

zeige_raum()
berechne_schaden()
nimm_gegenstaende()
erstelle_dungeon()

In this series, the names are German: show room, calculate damage, take items, and create dungeon. Their verbs zeige, berechne, nimm, and erstelle show up again and again.

Names like these, on the other hand, say very little:

mach()
funktion1()
daten()

That's do, function1, and data.

The same basic rules apply to function names as to variable names. Multiple words are usually written in snake_case:

zeige_player_state()
berechne_verbleibende_hp()

The better the name, the less you'll have to think later about what a call like zeige_raum(raum) actually does.

Parameters: passing values to a function

Our function so far always prints the same text. If it should work with different values, it gets parameters:

def begruesse(name):
    print(f"Welcome, {name}!")

When we call it, we pass a concrete value:

begruesse("Karl")
begruesse("Lara")

The output is:

Welcome, Karl!
Welcome, Lara!

Inside the function, name stands for whichever value was passed.

Strictly speaking, we distinguish between a parameter and an argument:

  • name is the parameter in the function definition.
  • "Karl" is the argument in the function call.

So the two terms describe two sides of the same process: The function defines which values it expects, and the call supplies those values.

Multiple parameters

A function can have several parameters:

def zeige_status(name, hp, max_hp):
    print(f"{name} | HP: {hp}/{max_hp}")

When you call it, the arguments can be matched up by their position:

zeige_status("Karl", 75, 100)

The output is:

Karl | HP: 75/100

The mapping looks like this:

Parameter Argument
name "Karl"
hp 75
max_hp 100

"Karl" goes to the first parameter, name, 75 to the second parameter, hp, and 100 to max_hp.

These values are passed as positional arguments.

Keyword arguments

When you call a function, you can also match arguments explicitly to the name of the parameter:

zeige_status(name="Karl", hp=75, max_hp=100)

Arguments like these are called keyword arguments.

Since they're matched by name, their order doesn't matter here:

zeige_status(max_hp=100, name="Karl", hp=75)

The result is the same.

This notation can be easier to understand, especially for functions with several arguments that look alike:

zeige_status(
    name="Karl",
    hp=75,
    max_hp=100,
)

With the simple function definitions we've used so far, you can also combine positional arguments and keyword arguments. The positional arguments have to come first:

zeige_status("Karl", hp=75, max_hp=100)

This notation, however, doesn't work:

zeige_status(name="Karl", 75, 100)

A plain positional argument can't come after a keyword argument.

Default values

Parameters can have a default value. It's used when the call doesn't pass an argument of its own for that parameter:

def verursache_schaden(menge=10):
    print(f"You lose {menge} hit points.")

verursache_schaden means cause damage, and menge means amount. Without an argument, the function uses the default value:

verursache_schaden()

Output:

You lose 10 hit points.

When you call it, you can pass a different value:

verursache_schaden(25)

Output:

You lose 25 hit points.

Of course, that also works as a keyword argument:

verursache_schaden(menge=25)

With the simple form of parameters we use here, required parameters have to come before parameters with default values:

def zeige_status(name, hp, max_hp=100):
    print(f"{name} | HP: {hp}/{max_hp}")

Now we can leave out max_hp in the call:

zeige_status("Karl", 75)

Python then automatically uses the value 100.

This definition, however, is invalid:

# Invalid:
def zeige_status(max_hp=100, name):
    print(f"{name} | HP: {max_hp}")

Python reports a SyntaxError here as soon as it parses the code.

return: Returning a result

A function can do more than print something. It can also hand a result, the return value, back to the calling code.

For that, we use return:

def erstelle_statuszeile(name, hp, max_hp=100):
    return f"{name} | HP: {hp}/{max_hp}"

When we call it, we get the returned string and can, for example, store it in a variable:

status = erstelle_statuszeile("Karl", 75)

print(status)

The output is:

Karl | HP: 75/100

erstelle_statuszeile means create status line. The difference between print(...) and return matters here:

  • print(...) writes something to the output.
  • return hands a value back to the calling code.

You can keep working with a return value afterward:

status = erstelle_statuszeile("Karl", 75)
laenge = len(status)

print(f"The status line is {laenge} characters long.")

Here, the function only decides how the status line is put together. What happens to that string afterward is up to the calling code.

return ends the function

As soon as Python runs a return, the current function call ends:

def pruefe_hp(hp):
    if hp <= 0:
        return "defeated"

    return "alive"

If hp <= 0, "defeated" is returned. In that case, the second return is never reached.

print(pruefe_hp(0))
print(pruefe_hp(75))

Output:

defeated
alive

pruefe_hp means check HP. Ending a function early like this is often called an early return.

It lets you avoid needlessly deep nesting:

def pruefe_hp(hp):
    if hp <= 0:
        return "defeated"

    if hp <= 25:
        return "critical"

    return "ready"

Python checks the conditions from top to bottom. As soon as a return runs, that function call is over.

Functions without an explicit return

Even a function without return returns a value: None.

def begruesse(name):
    print(f"Welcome, {name}!")

If we store the return value, that becomes visible:

ergebnis = begruesse("Karl")

print(ergebnis)

The output is:

Welcome, Karl!
None

The function printed a text, but it didn't set a return value of its own. Python therefore returns None automatically. ergebnis is German for result.

None is its own value in Python and often signals that there's no other value.

Docstrings

A docstring can stand right at the start of a function body:

def erstelle_statuszeile(name, hp, max_hp=100):
    """Creates a status line for the player."""
    return f"{name} | HP: {hp}/{max_hp}"

A docstring documents what the function is meant for.

Unlike a regular comment, it's part of the function and can be read by Python and other tools:

help(erstelle_statuszeile)

You can also access the __doc__ attribute directly:

print(erstelle_statuszeile.__doc__)

For a small function with an unambiguous name, a docstring like

"""Shows the room."""

often wouldn't be much more helpful than the function name itself. For more complex functions, though, the documentation can explain what's expected, what comes back, or what behavior isn't obvious from the code.

Scope: where names are visible

Every name has a scope: the part of the program where you can use it.

A name created by an assignment inside a function is normally local to that function:

def wuerfeln():
    ergebnis = 6
    return ergebnis

Inside the function, we can access ergebnis. Outside, this local name isn't available:

wuerfeln()

print(ergebnis)

That leads to a NameError.

If we want to use the value outside, we return it and store the return value:

wurf = wuerfeln()

print(wurf)

wuerfeln means roll the dice, and wurf means roll. ergebnis is the local name inside wuerfeln(). wurf, on the other hand, belongs to the scope outside the function.

Parameters are local too

Parameters are also local names of the function:

def verdopple(zahl):
    ergebnis = zahl * 2
    return ergebnis

Here, both zahl (number) and ergebnis belong to the local scope of verdopple() (double).

The argument in the call can come from a variable with a completely different name:

hp = 50
neue_hp = verdopple(hp)

print(neue_hp)

Outside, the value is called hp; inside the function, zahl. The names don't have to match.

Local and global names

A name defined at module level, meaning outside all functions, lives in that module's global scope:

max_hp = 100


def zeige_max_hp():
    print(max_hp)

The function can read this global name:

zeige_max_hp()

That works, but functions shouldn't depend on global variables without good reason.

This version is often clearer:

def zeige_max_hp(max_hp):
    print(max_hp)


max_hp = 100
zeige_max_hp(max_hp)

Now the function call itself shows which information the function needs. That also makes it easier to reuse with other values.

What happens when you pass objects

Calling a function doesn't create new copies of our lists or dictionaries. So a parameter can refer to the same object as a name outside the function.

With a mutable object, the function can change this shared object:

def fuege_hinzu(inventar, gegenstand):
    inventar.append(gegenstand)


spieler_inventar = ["torch"]
fuege_hinzu(spieler_inventar, "key")

print(spieler_inventar)

The output is:

['torch', 'key']

fuege_hinzu means add. During the function call, the local parameter inventar and the outer name spieler_inventar refer to the same list. append(...) changes exactly that list.

It works the same way with dictionaries:

def verletze(spieler, schaden):
    spieler["hp"] = spieler["hp"] - schaden


spieler = {
    "name": "Karl",
    "hp": 100,
}

verletze(spieler, 25)

print(spieler["hp"])

Output:

75

verletze means hurt, and schaden means damage.

Assigning isn't the same as changing

A new assignment to the parameter, by contrast, only changes what this local name refers to:

def leere_inventar(inventar):
    inventar = []

The original list isn't affected:

spieler_inventar = ["torch", "key"]
leere_inventar(spieler_inventar)

print(spieler_inventar)

Output:

['torch', 'key']

Inside the function, after the assignment, inventar refers to a new empty list. spieler_inventar still refers to the original list.

This function, on the other hand, changes the existing object:

def leere_inventar(inventar):
    inventar.clear()

Now the change is visible outside the function too:

spieler_inventar = ["torch", "key"]
leere_inventar(spieler_inventar)

print(spieler_inventar)

Output:

[]

leere_inventar means empty inventory, used as a verb. So the difference lies in what we do:

inventar = []

binds the local name to a new list.

inventar.clear()

changes the existing list.

Immutable values and return values

Integers, strings, Booleans, and tuples are immutable. The object itself can't be changed after it's created.

Let's look at this function:

def heile(hp, menge):
    hp = hp + menge

heile is German for heal. The call doesn't change the outer variable:

hp = 50
heile(hp, 20)

print(hp)

Output:

50

The addition creates a new integer. The local name hp inside the function is then bound to this new value. The outer name hp isn't affected.

If we need the calculated value outside, we return it:

def heile(hp, menge, max_hp=100):
    return min(hp + menge, max_hp)

When we call it, we assign the return value back:

hp = 50
hp = heile(hp, 20)

print(hp)

Output:

70

This makes it plain in the code: heile(...) calculates a new value, and the calling code decides where to store it.

Small functions with a clear job

Moving code into just any function doesn't automatically make it better.

This function, for example, does everything at once:

def alles_machen():
    # Show room
    # Read input
    # Pick up items
    # Change player state
    # End game
    ...

The code now lives in a function (alles_machen means do everything), but that hasn't made it much easier to follow.

Functions with clearly defined jobs are more helpful:

zeige_raum()
zeige_inventar()
nimm_alles()
erstelle_statuszeile()

In each case, the name says fairly precisely what the function is responsible for: show room, show inventory, take everything, create status line.

That doesn't mean every single line of code needs its own function. A function should wrap up one coherent task so that a call to it stays easy to understand in the rest of the program.

The dungeon, step 5

We now break the dungeon from part 4 into individual functions.

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

# Dungeon—Part 5: Functions
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 and its exits."""
    print()
    print(f"Location: {raum['name']}")
    print(raum["beschreibung"])
    print("Exits:", ", ".join(raum["ausgaenge"]))


def zeige_gegenstaende(raum):
    """Shows the items in the current room."""
    if raum["gegenstaende"]:
        print("Lying here:", ", ".join(raum["gegenstaende"]))
    else:
        print("There's nothing useful here.")


def nimm_alles(raum, inventar):
    """Puts all of the room's items into the inventory."""
    gegenstaende = raum["gegenstaende"]

    if not gegenstaende:
        return 0

    anzahl = len(gegenstaende)

    inventar.extend(gegenstaende)
    gegenstaende.clear()

    return anzahl


def zeige_inventar(inventar):
    """Shows the contents of the inventory."""
    if inventar:
        print("Inventory:", ", ".join(inventar))
    else:
        print("Your inventory is empty.")


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']}"
    )


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)}")
    print(f"Items: {len(spieler['inventar'])}")


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

    while True:
        raum = raeume[ort]

        if raum_anzeigen:
            besuchte_raeume.add(ort)
            zeige_raum(raum)
            raum_anzeigen = False

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

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

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

        elif befehl == "take":
            anzahl = nimm_alles(raum, spieler["inventar"])

            if anzahl == 1:
                print("You pick up one item.")
            elif anzahl > 1:
                print(f"You pick up {anzahl} items.")
            else:
                print("There's nothing to take here.")

        elif befehl == "inventory":
            zeige_inventar(spieler["inventar"])

        elif befehl == "status":
            zeige_status(spieler, besuchte_raeume)

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

        else:
            print("You can't do that here.")
            print("Possible exits:", ", ".join(raum["ausgaenge"]))
            print("Other commands: look, take, inventory, status, 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, inventory, status, quit

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

> look
Lying here: torch

> take
You pick up one item.

> inventory
Inventory: torch

> north

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

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

> quit
You leave the dungeon. Until next time.

Starting the program with main()

Almost the entire actual program flow now lives in a function called main:

def main():
    ...

The name main has no special technical meaning in Python. It's a common convention for the function that contains a program's main flow.

At the end of our file is:

if __name__ == "__main__":
    main()

You already know how if works from part 3. What's new is __name__.

Python sets this variable for every module. If dungeon.py is started directly as a program, __name__ has the value "__main__". The condition is then true, and main() is called.

For now, that looks a bit more long-winded than a single

main()

and when you start the file directly, the result really is the same. The difference shows once we later import dungeon.py from another Python file: Then main() doesn't run automatically.

That way, other code can later use our functions too, without the game starting the moment the file is imported.

Python still processes the file from top to bottom. First, the function definitions run, which creates the functions. Their function bodies only run when the respective function is actually called.

Creating rooms with a function

The function

def erstelle_raeume():
    return {
        ...
    }

creates the dictionary with the dungeon world and returns it. Its name means create rooms.

In main(), we store this return value:

raeume = erstelle_raeume()

This keeps the lengthy room definition separate from the actual game loop.

Later, we could move the rooms into a file of their own or load them from a file, without having to rebuild the game loop from scratch.

Functions for output

Several functions take care of specific output:

zeige_raum(raum)
zeige_gegenstaende(raum)
zeige_inventar(inventar)
zeige_status(spieler, besuchte_raeume)

As a result, the game loop no longer needs to know which individual print statements make up a room description.

Instead, all it says there is:

zeige_raum(raum)

The function name alone tells you fairly precisely what happens at this point.

Returning a string

erstelle_statuszeile(...) returns a string but doesn't display it itself:

def erstelle_statuszeile(spieler):
    return (
        f"{spieler['name']} | "
        f"HP: {spieler['hp']}/{spieler['max_hp']} | "
        f"Gold: {spieler['gold']}"
    )

The zeige_status(...) function uses this return value:

print(erstelle_statuszeile(spieler))

That makes erstelle_statuszeile(...) more flexible. The returned string could later just as well be saved to a file, reused in another text, or passed to another function.

A function that creates data and returns it isn't tied to printing that data right away with print(...).

Changing state through mutable objects

The nimm_alles(...) function gets two mutable objects:

def nimm_alles(raum, inventar):

raum refers to the current room's dictionary, and inventar to the list in the player state.

First, we give the room's list of items a second name:

gegenstaende = raum["gegenstaende"]

gegenstaende now refers to the same list as raum["gegenstaende"].

Then we extend the inventory:

inventar.extend(gegenstaende)

and empty the room's list of items:

gegenstaende.clear()

Both changes persist after the function call because the existing lists were changed.

All the function returns is the number of items picked up (anzahl means number):

return anzahl

The game loop uses this return value to decide which message to display:

anzahl = nimm_alles(raum, spieler["inventar"])

if anzahl == 1:
    print("You pick up one item.")
elif anzahl > 1:
    print(f"You pick up {anzahl} items.")
else:
    print("There's nothing to take here.")

So nimm_alles(...) takes care of moving the items. How the result is presented to the player remains the game loop's job.

The game loop becomes more readable

Let's compare an excerpt from part 4:

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

After moving the code out, this is what remains in the game loop:

elif befehl == "inventory":
    zeige_inventar(spieler["inventar"])

The details are still in the program, but they now sit in a place of their own under an understandable name.

As a result, the game loop increasingly reads like a description of how the game plays out:

if befehl == "quit":
    ...
elif befehl == "look":
    zeige_gegenstaende(raum)
elif befehl == "take":
    ...
elif befehl == "inventory":
    zeige_inventar(spieler["inventar"])
elif befehl == "status":
    zeige_status(spieler, besuchte_raeume)

That's one of the big advantages of functions: The details don't disappear, but they don't all have to be visible everywhere at the same time.

Watch out for mutable default values

Default values have a quirk that quickly leads to unexpected behavior with lists and dictionaries.

At first, this function (fuege_gegenstand_hinzu means add item) looks handy:

def fuege_gegenstand_hinzu(gegenstand, inventar=[]):
    inventar.append(gegenstand)
    return inventar

However, the default value [] isn't created anew on every call. Python evaluates it when the function definition runs. Later calls without an inventar of their own therefore use the same list.

You can see that here:

print(fuege_gegenstand_hinzu("torch"))
print(fuege_gegenstand_hinzu("key"))

The output is:

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

On the second call, the torch is still there.

If every call without an inventory of its own should create a new list, we use None as the default value instead:

def fuege_gegenstand_hinzu(gegenstand, inventar=None):
    if inventar is None:
        inventar = []

    inventar.append(gegenstand)
    return inventar

Now the list is created anew inside the function if no inventory was passed.

Default values such as numbers, strings, Booleans, or None don't cause this problem, because the function can't change them in the same way:

def heile(hp, menge=20, max_hp=100):
    return min(hp + menge, max_hp)

So mutable default values aren't forbidden as such. But you should only use them when the shared object across several calls is really what you intend. In regular application code, that's fairly rare.

Pitfalls

  • Only defining a function: A def block creates the function but doesn't run its function body yet. Only a call like zeige_raum(raum) starts it.

  • Forgetting the parentheses in the call: zeige_raum refers to the function object. zeige_raum(raum) calls the function.

  • Mixing up print and return: print(...) writes something to the output. return hands a value back to the calling code.

  • Ignoring a return value: If a function calculates a new integer or string, you have to use the returned value if you want to keep it:

python hp = heile(hp, 20)

  • Expecting code to run after return: As soon as return is reached on the current execution path, the function call ends. Any statements that follow on this path no longer run.

  • Accessing local names from outside: Names that exist only inside a function aren't directly available outside their local scope.

  • Mixing up assignment and mutation: inventar = [] binds the local name to a new list. inventar.clear() changes the existing list.

  • Using mutable default values without meaning to: A list or a dictionary as a default value can be shared between several calls.

  • Calling a function before its definition has run: Python must already have processed the corresponding def statement. That's why our function definitions come before the call to main().

  • Using too many global variables: Pass the values a function needs through parameters whenever possible, and return calculated results with return. That makes it easier to see what a function depends on.

Exercises

1. Check whether the player is defeated

Write a function ist_besiegt(hp) (is defeated). It should return True if the player has no hit points left, and False otherwise.

Solution
def ist_besiegt(hp):
    """Checks whether the player has no hit points left."""
    return hp <= 0
The comparison `hp <= 0` already results in a Boolean. That's why we can return the result directly:
if ist_besiegt(spieler["hp"]):
    print("You have been defeated.")
There's no need for an additional `if` inside `ist_besiegt(...)`.

2. Write a healing function

Write a function heile(...) that receives the current hit points, a healing amount, and the maximum hit points.

The new hit points can't exceed max_hp.

Solution
def heile(hp, menge, max_hp=100):
    """Calculates the hit points after healing."""
    return min(hp + menge, max_hp)
Use the return value to update the player state:
spieler["hp"] = heile(
    spieler["hp"],
    menge=20,
    max_hp=spieler["max_hp"],
)
The function doesn't change the integer directly. Instead, it calculates a new value and returns it.

3. Extend the status line

Extend erstelle_statuszeile(...) so that it also shows the number of items in the inventory.

The output should look something like this:

Karl | HP: 75/100 | Gold: 50 | Inventory: 2
Solution
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'])}"
    )
Since the inventory is already part of the player state, the function doesn't need an additional parameter for it.

Next up

Our code is now split into manageable functions. A few places, though, are still more long-winded than they need to be in Python.

That's why part 6 is about Pythonic code: comprehensions, slicing, enumerate, unpacking, and other idioms that let you express typical tasks clearly and compactly.

0 comments

No comments yet. Be the first!