Skip to content

cat posts/en/python-lernen-07-klassen-und-objekte.md

Learning Python (Part 7): Classes and objects

Classes bundle state and behavior: players, rooms, and items become objects with their own attributes and methods.

Art deco poster: A machine stamps out identical small robots on a conveyor belt. One wears a top hat, another a red cowboy hat.

In part 6, we reworked the dungeon to be Pythonic. The player state still lives in a dictionary, each room consists of another dictionary, and the logic that goes with them is spread across several functions.

That works well. But the bigger our program gets, the more we have to track ourselves which data and functions belong together.

The player state, for example, looks like this:

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

The functions that work with this data are somewhere else:

zeige_inventar(spieler["inventar"])
erstelle_statuszeile(spieler)
nimm_gegenstand(raum, spieler["inventar"], name)

With classes, we can combine data and the behavior that goes with it into objects of their own. The spieler dictionary becomes a Spieler object (German for player) that manages its state and provides methods such as nimm(...), heile(...), or zeige_inventar().

Classes and objects

A class defines a new type of object. It describes which data objects of this type can have and which behavior is available to them.

An object is a concrete instance of this class.

class Spieler:
    def __init__(self, name):
        self.name = name
        self.hp = 100

We create an object by calling the class:

held = Spieler("Karl")

Spieler is the class. held (German for hero) refers to a concrete instance of this class.

We can create as many independent players as we like:

held = Spieler("Karl")
magierin = Spieler("Lara")

print(held.name)
print(magierin.name)

The output is:

Karl
Lara

magierin is German for a female mage. Both objects belong to the Spieler class, but each has its own state:

held.hp = 75

print(held.hp)
print(magierin.hp)

Output:

75
100

The change to held doesn't affect magierin. Here, hp is an instance attribute, so it belongs to one particular object.

Class names are capitalized

We usually write variables and functions in snake_case:

max_hp = 100


def zeige_status():
    ...

For class names, on the other hand, the PEP 8 style guide recommends the CapWords style, which is also often called PascalCase. Each word starts with an uppercase letter, and the words are joined without underscores:

class Spieler:
    ...


class Gegenstand:
    ...


class MagischerGegenstand:
    ...

Gegenstand means item, and MagischerGegenstand means magic item. This way, the name alone usually tells you in the code whether a class, a function, or a variable is meant.

__init__: setting up the starting state

A class can define the special method __init__:

class Spieler:
    def __init__(self, name, max_hp=100, gold=0):
        self.name = name
        self.hp = max_hp
        self.max_hp = max_hp
        self.gold = gold
        self.inventar = []

When we then create an object:

held = Spieler("Karl", max_hp=120, gold=50)

Python automatically calls __init__ for the new instance. After that, held has the attributes that were set up:

print(held.name)
print(held.hp)
print(held.max_hp)
print(held.gold)
print(held.inventar)

Output:

Karl
120
120
50
[]

Colloquially, __init__ is often called the constructor. Strictly speaking, though: __new__ is responsible for creating the new instance, and __init__ then initializes the object that already exists.

In regular application code, we almost never have to deal with __new__ ourselves. For our dungeon, it's enough to think of __init__ as the place where a new object gets its starting state.

The name __init__ starts and ends with two underscores each. Methods like this are often called dunder methods, short for double underscore.

What does self mean?

By Python convention, the first parameter of a regular instance method is called self:

class Spieler:
    def __init__(self, name):
        self.name = name

self refers to the concrete instance the method is currently running for.

With

held = Spieler("Karl")

self refers to the newly created Spieler object during the __init__ call.

This assignment

self.name = name

uses two different names:

  • name is the parameter of __init__.
  • self.name is an attribute of the object.

The local parameter name is needed only during this method call. self.name then stays around as part of the object.

When we create the player, we don't pass an argument for self:

held = Spieler("Karl")

With a bound method call, Python takes care of inserting the instance as the first argument.

self isn't a reserved keyword. Technically, the parameter could have a different name. But the convention is so well established in Python that you should always use self for regular instance methods.

Attributes store the state

We store values that belong to an object in attributes:

held.name
held.hp
held.gold
held.inventar

Together, these instance attributes make up the state of our player object.

We access them with a dot:

print(held.name)
held.gold = held.gold + 10

With regular Python classes, new attributes can in principle be created later, too:

held.level = 1

It's usually clearer to set up the essential instance attributes in __init__. That way, one central place shows the state a new object starts with.

Methods: behavior on the object

Put simply, a method is a function that's available through a class and its instances:

class Spieler:
    def __init__(self, name, max_hp=100):
        self.name = name
        self.hp = max_hp
        self.max_hp = max_hp
        self.inventar = []

    def nimm(self, gegenstand):
        self.inventar.append(gegenstand)

    def ist_besiegt(self):
        return self.hp <= 0

We call the methods through an object:

held = Spieler("Karl")

held.nimm("torch")

print(held.inventar)
print(held.ist_besiegt())

Output:

['torch']
False

The nimm(...) method (take) doesn't need the inventory as an additional argument. Through self.inventar, it already has access to the state of the concrete player object.

The call

held.nimm("torch")

is, in this case, the same as:

Spieler.nimm(held, "torch")

When accessed through held, the function Spieler.nimm becomes a method bound to this instance. When it's called, Python automatically inserts held as the first argument.

Methods can change the state

A method can read and change the object's attributes:

class Spieler:
    def __init__(self, name, max_hp=100):
        self.name = name
        self.hp = max_hp
        self.max_hp = max_hp

    def erleide_schaden(self, menge):
        self.hp = max(self.hp - menge, 0)

    def heile(self, menge):
        self.hp = min(self.hp + menge, self.max_hp)

Usage:

held = Spieler("Karl")

held.erleide_schaden(30)
print(held.hp)

held.heile(20)
print(held.hp)

Output:

70
90

erleide_schaden means take damage. The rules for hit points now live directly in the behavior of the player object:

  • Damage doesn't let hp drop below 0.
  • Healing doesn't let hp rise above max_hp.

The calling code doesn't have to remember to respect these limits every time.

Methods can return values

Just like regular functions, methods can return a value with return:

class Spieler:
    def __init__(self, name, hp=100):
        self.name = name
        self.hp = hp

    def ist_besiegt(self):
        return self.hp <= 0

You can use the method directly in a condition:

if held.ist_besiegt():
    print("Your adventure ends here.")

So a method doesn't have to change the state. It can also just read it and return a result.

__str__: representing an object as a string

If we print one of our own objects without any further adjustments, we first get a technical representation:

held = Spieler("Karl")

print(held)

It looks something like this:

<__main__.Spieler object at 0x7f41d23a7e10>

That tells us hardly anything interesting about the player.

With the dunder method __str__, we can define which human-readable string representation our object should have:

class Spieler:
    def __init__(self, name, max_hp=100, gold=0):
        self.name = name
        self.hp = max_hp
        self.max_hp = max_hp
        self.gold = gold
        self.inventar = []

    def __str__(self):
        return (
            f"{self.name} | "
            f"HP: {self.hp}/{self.max_hp} | "
            f"Gold: {self.gold} | "
            f"Inventory: {len(self.inventar)}"
        )

Now this works:

held = Spieler("Karl", gold=50)

print(held)

Output:

Karl | HP: 100/100 | Gold: 50 | Inventory: 0

For objects, print(...) uses their string representation. For our Spieler, that means __str__ comes into play.

We also get this representation in an f-string without a special format specifier:

print(f"Status: {held}")

Output:

Status: Karl | HP: 100/100 | Gold: 50 | Inventory: 0

__str__ has to return a string. The method shouldn't print anything itself.

__repr__: a representation for developers

Besides __str__, there's __repr__.

While __str__ is mainly meant to provide a pleasant representation for humans, __repr__ aims for an unambiguous representation that helps developers.

class Gegenstand:
    def __init__(self, name, beschreibung=""):
        self.name = name
        self.beschreibung = beschreibung

    def __str__(self):
        return self.name

    def __repr__(self):
        return (
            f"Gegenstand("
            f"name={self.name!r}, "
            f"beschreibung={self.beschreibung!r}"
            f")"
        )

Usage:

fackel = Gegenstand(
    "torch",
    "A sooty but still usable torch.",
)

print(str(fackel))
print(repr(fackel))

Output:

torch
Gegenstand(name='torch', beschreibung='A sooty but still usable torch.')

The !r in the f-string uses the repr(...) of the respective value. That makes strings, for example, show up with their quotation marks. The variable fackel is German for torch.

Where it makes sense, __repr__ is often designed so that the representation resembles a valid Python expression that would create an equivalent object. That's not a hard requirement, though.

You run into __repr__ in the REPL, when debugging, and when objects are displayed inside many collections, among other places.

For our dungeon, __str__ matters more. We're still covering __repr__ because the difference quickly becomes practical with your own objects.

Objects can work with other objects

The player should no longer store plain strings in their inventory, but real Gegenstand objects:

class Gegenstand:
    def __init__(self, name, beschreibung=""):
        self.name = name
        self.beschreibung = beschreibung

    def __str__(self):
        return self.name

We create a torch:

fackel = Gegenstand(
    "torch",
    "A sooty but still usable torch.",
)

Then the player can pick up this object, using the Spieler class with nimm(...) from the section “Methods: behavior on the object”:

held.nimm(fackel)

The inventory now contains a Gegenstand object and not the string "torch".

For human-readable output, we can convert the items with str(...):

print(", ".join(str(gegenstand) for gegenstand in held.inventar))

Output:

torch

In object-oriented programming, putting objects together from other objects is often called composition. People also often speak of a has-a relationship: A player has items in their inventory.

With our dungeon, though, there's a small subtlety. The torch is created independently of the player and can move between a room and the inventory. In stricter OOP terminology, this relationship can therefore also be called aggregation. For our code, what matters most is the practical idea: Our objects can store references to other objects and work with them.

We use that in several places:

  • A Spieler has a list of Gegenstand objects.
  • A Raum (room) also has a list of Gegenstand objects.
  • The raeume dictionary refers to several Raum objects.

Bit by bit, this creates a network of connected objects.

Classes don't replace every collection

Classes and collections do different jobs.

A dictionary is still an excellent way to quickly find the right room by a known ID:

raeume = {
    "halle": Raum(...),
    "bibliothek": Raum(...),
    "krypta": Raum(...),
}

A dictionary also makes sense for a room's exits:

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

So the Raum class doesn't automatically replace all dictionaries and lists. It bundles the state and the behavior that together make up a room.

Inside objects, we'll keep using lists, dictionaries, sets, and other collections all the time.

Avoiding mutable default values

The same rules for default values apply to __init__ as to any other function or method.

This class contains a well-known trap:

class Raum:
    def __init__(self, name, gegenstaende=[]):
        self.name = name
        self.gegenstaende = gegenstaende

The default value [] is created only once, when the function definition runs. All calls without their own gegenstaende then use the same list:

halle = Raum("Hall")
krypta = Raum("Crypt")

halle.gegenstaende.append("torch")

print(krypta.gegenstaende)

Surprising output:

['torch']

Here, the hall and the crypt refer to the same list.

The usual solution uses None as the default:

class Raum:
    def __init__(self, name, gegenstaende=None):
        self.name = name

        if gegenstaende is None:
            gegenstaende = []

        self.gegenstaende = gegenstaende

We can write it more compactly:

class Raum:
    def __init__(self, name, gegenstaende=None):
        self.name = name
        self.gegenstaende = [] if gegenstaende is None else gegenstaende

For our complete dungeon, we go one small step further and create copies of the collections that are passed in:

self.ausgaenge = dict(ausgaenge) if ausgaenge is not None else {}
self.gegenstaende = list(gegenstaende) if gegenstaende is not None else []

That gives each room its own outer collections. If the calling code passes in a list of items, for example, the room doesn't store exactly that list, but a new list with the same elements.

This is still only a shallow copy. The Gegenstand objects inside it aren't duplicated—and that's exactly what we want here.

The commonly seen shorthand

self.gegenstaende = gegenstaende or []

isn't fully equivalent. An empty list that was passed in on purpose is also falsy and would therefore be replaced by a different new list.

With

gegenstaende is None

we check for exactly the case we actually mean.

The dungeon, step 7

We now replace the dictionaries for the player and the individual rooms with objects of their own. The outer dungeon world stays a dictionary so that we can still find rooms conveniently by their ID.

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

# Dungeon—Part 7: Classes and objects
class Gegenstand:
    """An item that can lie in a room or in an inventory."""

    def __init__(self, name, beschreibung=""):
        self.name = name
        self.beschreibung = beschreibung

    def __str__(self):
        return self.name

    def __repr__(self):
        return (
            f"Gegenstand("
            f"name={self.name!r}, "
            f"beschreibung={self.beschreibung!r}"
            f")"
        )


class Spieler:
    """Manages the player's state and behavior."""

    def __init__(self, name, max_hp=100, gold=0):
        self.name = name
        self.hp = max_hp
        self.max_hp = max_hp
        self.gold = gold
        self.inventar = []

    def nimm(self, gegenstand):
        """Puts an item into the inventory."""
        self.inventar.append(gegenstand)

    def erleide_schaden(self, menge):
        """Reduces HP, but not below zero."""
        self.hp = max(self.hp - menge, 0)

    def heile(self, menge):
        """Increases HP, but not above max_hp."""
        self.hp = min(self.hp + menge, self.max_hp)

    def ist_besiegt(self):
        """Checks whether the player has no HP left."""
        return self.hp <= 0

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

        print("Inventory:")

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

    def __str__(self):
        return (
            f"{self.name} | "
            f"HP: {self.hp}/{self.max_hp} | "
            f"Gold: {self.gold} | "
            f"Inventory: {len(self.inventar)}"
        )


class Raum:
    """A room with a description, exits, and items."""

    def __init__(
        self,
        name,
        beschreibung,
        ausgaenge=None,
        gegenstaende=None,
    ):
        self.name = name
        self.beschreibung = beschreibung
        self.ausgaenge = dict(ausgaenge) if ausgaenge is not None else {}
        self.gegenstaende = (
            list(gegenstaende)
            if gegenstaende is not None
            else []
        )

    def beschreibe(self):
        """Shows the room, its exits, and visible items."""
        print()
        print(f"Location: {self.name}")
        print(self.beschreibung)

        print("Exits:")

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

        if self.gegenstaende:
            print(
                "Lying here:",
                ", ".join(
                    str(gegenstand)
                    for gegenstand in self.gegenstaende
                ),
            )

    def zeige_gegenstaende(self):
        """Shows the room's items."""
        if not self.gegenstaende:
            print("There's nothing useful here.")
            return

        print(
            "Lying here:",
            ", ".join(
                str(gegenstand)
                for gegenstand in self.gegenstaende
            ),
        )

    def hat_ausgang(self, richtung):
        """Checks whether the room has an exit in that direction."""
        return richtung in self.ausgaenge

    def ziel(self, richtung):
        """Returns the ID of the destination room, or None."""
        return self.ausgaenge.get(richtung)

    def nimm_gegenstand(self, name):
        """Removes a matching item and returns it."""
        gesuchter_name = name.strip().lower()

        for gegenstand in self.gegenstaende:
            if gegenstand.name.lower() == gesuchter_name:
                self.gegenstaende.remove(gegenstand)
                return gegenstand

        return None


def erstelle_raeume():
    """Creates the dungeon world from Raum and Gegenstand objects."""
    fackel = Gegenstand(
        "torch",
        "A sooty but still usable torch.",
    )
    schluessel = Gegenstand(
        "key",
        "A heavy iron key with rusty teeth.",
    )

    return {
        "halle": Raum(
            name="Hall",
            beschreibung=(
                "You're standing in a dark hall with a musty smell."
            ),
            ausgaenge={
                "north": "bibliothek",
                "east": "krypta",
            },
            gegenstaende=[fackel],
        ),
        "bibliothek": Raum(
            name="Library",
            beschreibung=(
                "Dusty shelves full of tattered books surround you."
            ),
            ausgaenge={
                "south": "halle",
            },
            gegenstaende=[schluessel],
        ),
        "krypta": Raum(
            name="Crypt",
            beschreibung=(
                "Moisture glistens on the walls of the old crypt."
            ),
            ausgaenge={
                "west": "halle",
            },
        ),
    }


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."""
    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.")

    name = input("What's your name, adventurer? ").strip()
    gold = int(input("How many gold coins are you bringing? "))

    spieler = Spieler(
        name=name,
        max_hp=100,
        gold=gold,
    )

    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)
            raum.beschreibe()
            raum_anzeigen = False

        verb, argument = teile_befehl(input("\n> "))

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

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

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

            gegenstand = raum.nimm_gegenstand(argument)

            if gegenstand is None:
                print(f"There's no item called {argument} here.")
                continue

            spieler.nimm(gegenstand)
            print(f"You take the {gegenstand}.")

            if gegenstand.beschreibung:
                print(gegenstand.beschreibung)

        elif verb == "inventory":
            spieler.zeige_inventar()

        elif verb == "status":
            print(spieler)
            print(f"Rooms visited: {len(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 not raum.hat_ausgang(argument):
                print(f"You can't go {argument}.")
                continue

            ort = raum.ziel(argument)
            raum_anzeigen = True

        elif not argument and raum.hat_ausgang(verb):
            ort = raum.ziel(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.
A sooty but still usable 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.
A heavy iron key with rusty teeth.

> 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 changed compared to part 6?

The basic flow of the game has stayed the same. Instead of dictionary keys, we now work with attributes and methods in many places.

Before:

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

Now:

gegenstand = raum.nimm_gegenstand(argument)
spieler.nimm(gegenstand)

Before:

raum["gegenstaende"]

Now:

raum.gegenstaende

Before:

if argument in raum["ausgaenge"]:
    ort = raum["ausgaenge"][argument]

Now:

if raum.hat_ausgang(argument):
    ort = raum.ziel(argument)

Methods such as hat_ausgang(...) (has exit) and ziel(...) (destination) form an interface to the room. For these operations, the game loop no longer needs to know that the exits are stored internally in a dictionary.

The player manages their own state

The player state is set up when the object is created:

spieler = Spieler(
    name=name,
    max_hp=100,
    gold=gold,
)

After that, we access it through attributes:

spieler.name
spieler.hp
spieler.max_hp
spieler.gold
spieler.inventar

The behavior that goes with it lives in methods of the class:

spieler.nimm(gegenstand)
spieler.erleide_schaden(25)
spieler.heile(20)
spieler.ist_besiegt()
spieler.zeige_inventar()

This puts the state and the behavior that works directly with it in the same place in the program.

That doesn't mean every function has to become a method from now on. teile_befehl(...), for example, belongs neither to a player nor to a room, so it stays a regular function.

Items move between objects

At the start, the torch is in the hall's list of items.

The call

gegenstand = raum.nimm_gegenstand("torch")

removes the Gegenstand object from the room's list and returns exactly this object.

Then

spieler.nimm(gegenstand)

puts the same torch into the player's inventory.

No new Gegenstand object is created in the process. The existing instance only moves from one collection to another.

That's an important difference from our earlier dungeon, in which items were only strings like "torch". The torch can now have state of its own: a description, for example, and later perhaps weight, condition, or burn time.

Methods form an interface

In the game loop, we could still access many attributes directly:

raum.ausgaenge
raum.gegenstaende
spieler.inventar

For common operations or rules, we use methods instead:

raum.hat_ausgang(richtung)
raum.nimm_gegenstand(name)
spieler.nimm(gegenstand)
spieler.heile(menge)

These methods form a small API for each class.

As a result, the rest of the code depends less on how an object stores its data internally.

For example, Raum might organize its exits differently at some point. As long as

raum.hat_ausgang(richtung)
raum.ziel(richtung)

still offer the same behavior, the game loop wouldn't necessarily have to change.

Our classes are by no means fully encapsulated yet, though. Attributes such as raum.ausgaenge or spieler.hp stay directly accessible. For interfaces like these, Python often relies more on conventions than on enforced access restrictions.

Is this already object-oriented programming?

Yes. We model parts of our game as objects with their own state and fitting behavior.

Beyond that, object-oriented programming offers many more concepts and techniques, for example:

  • encapsulation
  • inheritance
  • polymorphism
  • class methods and static methods
  • properties
  • abstract base classes

But we don't have to use them automatically just because our program contains classes.

For the dungeon, a much simpler idea is enough for now:

A class can bundle data and the behavior that belong together logically.

Even that doesn't mean every dictionary should immediately become a class. A class is mainly worth it when something in the program is a recognizable unit of its own and has behavior of its own tied to that data.

Pitfalls

  • Forgetting self: Inside an instance method, you access the attributes of the respective object through self.

python self.hp = self.hp - schaden

hp on its own would be a different name and wouldn't automatically mean the instance attribute here.

  • Passing self yourself in a regular method call: With spieler.heile(20), Python binds the instance to self automatically. So spieler.heile(spieler, 20) would pass one argument too many.

  • Misspelling the dunder method: It's __init__, with two underscores each before and after init.

  • Returning a value of your own from __init__: __init__ initializes an instance and has to return None. A return without a value is possible, but any other return value leads to a TypeError.

  • Using mutable default values: A list or a dictionary used directly as a default value isn't created anew for each instance.

python def __init__(self, inventar=[]): ...

Normally, use None and create the collection inside the method.

  • Equating wert or [] with a None check: An empty collection that was passed in on purpose is also falsy. is None checks more precisely whether no value was passed at all.

  • Mixing up class and object: Spieler is the class. Spieler("Karl") creates an instance of this class.

  • Mixing up a method and a method call: spieler.zeige_inventar returns the method object. Only spieler.zeige_inventar() calls the method.

  • Having __str__ print directly: __str__ has to return a string. The printing is then done by print(...), for example.

  • Thinking composition and aggregation are mandatory Python concepts: These are terms for describing relationships between objects, not special Python syntax.

  • Compulsively wrapping all data in classes: Lists, sets, and dictionaries are still useful. Classes complement collections; they don't replace them.

  • Putting too much behavior into one class: Just because a function could technically be a method of Spieler doesn't mean it automatically belongs there. The behavior should make sense for the object.

Exercises

1. Cause damage

Add a method erleide_schaden(menge) to the Spieler class. It should reduce the HP, but not let it drop below 0.

Solution
def erleide_schaden(self, menge):
    """Reduces HP, but not below zero."""
    self.hp = max(self.hp - menge, 0)
Usage:
spieler.erleide_schaden(25)

print(spieler.hp)
`max(...)` prevents the hit points from going negative.

2. Check an exit

Add a method hat_ausgang(richtung) to the Raum class that returns True if the room has a matching exit.

Solution
def hat_ausgang(self, richtung):
    """Checks whether the room has an exit in that direction."""
    return richtung in self.ausgaenge
Usage:
if raum.hat_ausgang("north"):
    print("You can go north.")
The membership test already results in a Boolean, so it can be returned directly.

3. Search for items in the inventory

Add a method hat_gegenstand(name) (has item) to Spieler. Case shouldn't matter.

Solution
def hat_gegenstand(self, name):
    """Checks whether an item is in the inventory."""
    gesuchter_name = name.strip().lower()

    return any(
        gegenstand.name.lower() == gesuchter_name
        for gegenstand in self.inventar
    )
Usage:
if spieler.hat_gegenstand("key"):
    print("You can unlock the door.")
`any(...)` returns `True` as soon as at least one element of the generated iterable is truthy. Here, that means at least one item has the name we're looking for.

4. Describe an item in more detail

Add a method untersuche() (examine) to Gegenstand that returns either the description or a default text.

Solution
def untersuche(self):
    """Returns the item's description."""
    if self.beschreibung:
        return self.beschreibung

    return f"You notice nothing special about the {self.name}."
Usage:
print(fackel.untersuche())
The method doesn't change the item. It only uses its state and returns a fitting string based on it.

Going deeper

For classes that mainly store data, Python can generate a lot of boilerplate automatically:

Dataclasses: classes for structured data

Why methods such as __init__, __str__, and __repr__ work, and how your own objects can behave like built-in data types:

The Python data model: dunder methods

Next up

Our dungeon now has real objects with their own state and behavior. When the player enters the gold value, though, the program still trusts that they really type a valid integer.

That's why part 8 is about error handling: exceptions, try, except, else, and finally. With them, the dungeon responds to invalid input and other problems in a controlled way, instead of crashing with a traceback.

0 comments

No comments yet. Be the first!