Zum Inhalt springen

cat posts/match-case-pattern-matching.md

match/case: Strukturelles Pattern Matching

match/case ist mehr als ein switch: Mit Literal-, Sequenz-, Mapping- und Class-Patterns zerlegst Du Datenstrukturen, bindest Werte und ergänzt Bedingungen über Guards.

In Teil 3 der Python-Serie haben wir die Befehle des Spielers mit einer längeren if-elif-else-Kette verarbeitet:

if befehl == "norden":
    ...
elif befehl == "osten":
    ...
elif befehl == "status":
    ...
else:
    ...

Seit Python 3.10 gibt es mit match und case eine weitere Möglichkeit, unterschiedliche Fälle zu behandeln.

Wer switch aus anderen Programmiersprachen kennt, könnte match zunächst für Pythons Variante davon halten. Für einfache Wertvergleiche funktioniert es auch so. Das ist aber nur ein kleiner Teil davon.

Structural Pattern Matching kann die Struktur von Daten prüfen, enthaltene Werte herauslösen und direkt an Variablen binden. Statt nur zu fragen:

Ist dieser Wert gleich X?

kann ein Pattern beispielsweise ausdrücken:

Ist das eine Sequenz mit "nimm" als erstem Element und mindestens einem
weiteren Element?

oder:

Ist das ein Mapping mit dem Typ "schaden", einer Monster-Quelle und einem
Schadenswert?

Genau dort wird match interessant.

Die Grundform

Ein match-Statement besteht aus einem Subject und mindestens einem case:

match subject:
    case pattern_1:
        ...
    case pattern_2:
        ...
    case _:
        ...

Python wertet das Subject einmal aus und prüft anschließend die Cases von oben nach unten. Der erste Case, dessen Pattern passt und dessen optionaler Guard erfolgreich ist, wird ausgeführt.

Danach ist das match-Statement beendet.

Es gibt also kein automatisches Fall-through wie bei manchen switch-Varianten und entsprechend auch kein break nach einem case.

Ein einfaches Beispiel:

def reagiere(befehl):
    match befehl:
        case "norden":
            return "Du gehst nach Norden."
        case "osten":
            return "Du gehst nach Osten."
        case "ende":
            return "Du verlässt das Verlies."
        case _:
            return "Das verstehe ich nicht."

Die Strings in den ersten drei Cases sind Literal Patterns. Sie passen nur, wenn das Subject dem jeweiligen Wert entspricht.

Der Unterstrich _ ist ein Wildcard Pattern. Er passt auf jeden Wert und übernimmt damit häufig die Rolle eines abschließenden else.

print(reagiere("norden"))
print(reagiere("tanzen"))

Ausgabe:

Du gehst nach Norden.
Das verstehe ich nicht.

Ein ungeschütztes case _: muss immer zuletzt stehen. Da es auf alles passt, wäre danach kein weiterer Case mehr erreichbar.

match und case sind Soft Keywords

Anders als if, for oder return sind match und case sogenannte Soft Keywords. Python behandelt sie nur in der passenden grammatischen Situation als Schlüsselwörter.

Das ist deshalb weiterhin gültig:

match = "Fackel"
case = 42

print(match, case)

Für neuen Code würde ich solche Variablennamen trotzdem eher vermeiden. Dass sie erlaubt sind, ist vor allem wichtig für die Abwärtskompatibilität mit Code, der schon vor Python 3.10 existierte.

Literal Patterns

Die einfachste Pattern-Art vergleicht mit einem konkreten Literal:

match status:
    case 200:
        print("OK")
    case 404:
        print("Nicht gefunden")
    case "ende":
        print("Spiel beenden")
    case None:
        print("Kein Wert")

Zahlen und Strings werden dabei über Gleichheit verglichen. Für None, True und False gelten die jeweiligen Singleton-Werte.

Typische Literal Patterns sind also:

case "ende":
case 404:
case -1:
case 3.14:
case True:
case False:
case None:

Auch komplexe Zahlen wie 1+2j und Bytes-Literale wie b"ok" sind möglich.

Ein f-String kann dagegen nicht als Literal Pattern verwendet werden:

name = "Karl"

match text:
    case f"Hallo {name}":  # ungültig
        ...

Wenn der Vergleich erst zur Laufzeit zusammengesetzt werden muss, ist ein Guard oder ein normales if meistens die passendere Lösung.

Mehrere Alternativen mit OR-Patterns

Mehrere Patterns lassen sich mit | verbinden:

def reagiere(befehl):
    match befehl:
        case "norden" | "sueden" | "osten" | "westen":
            return "Du gehst los."
        case "ende" | "quit":
            return "Du verlässt das Verlies."
        case _:
            return "Das verstehe ich nicht."

Das | ist hier kein boolesches or, sondern bildet ein OR-Pattern. Python probiert die Alternativen von links nach rechts und der Case passt, sobald eine davon passt.

Den erfolgreichen Wert kannst Du gleichzeitig mit as binden:

def bewege(richtung):
    match richtung:
        case ("norden" | "sueden" | "osten" | "westen") as ziel:
            return f"Du gehst nach {ziel}."
        case _:
            return "Dort kannst Du nicht hingehen."
print(bewege("westen"))

Ausgabe:

Du gehst nach westen.

Das as bindet hier das gesamte erfolgreich gematchte Subject an ziel.

OR-Patterns müssen dieselben Namen binden

Bei einem OR-Pattern müssen alle Alternativen dieselbe Menge an Variablen bereitstellen.

Das ist gültig:

match eingabe:
    case ["geh", richtung] | ["laufe", richtung]:
        print(f"Du gehst nach {richtung}.")

Beide Seiten binden richtung.

Das hier ist dagegen ungültig:

match eingabe:
    case ["geh", richtung] | ["laufe", ziel]:
        ...

Nach dem Match wäre sonst nicht klar, welcher der beiden Namen im Case-Block existiert.

Das erste passende Pattern gewinnt

Die Reihenfolge der Cases gehört zur Logik des Programms.

Das hier ist ungültig:

match befehl:
    case _:
        print("Beliebiger Befehl")
    case "ende":
        print("Spiel beenden")

case _ ist irrefutable: Das Pattern kann nicht fehlschlagen. Der zweite Case wäre damit unerreichbar und Python erkennt das bereits beim Kompilieren.

Auch bei Patterns, die nicht auf alles passen, ist die Reihenfolge wichtig. Spezielle Fälle sollten vor allgemeineren stehen:

match eingabe.split():
    case ["nimm"]:
        print("Was möchtest Du nehmen?")

    case ["nimm", gegenstand]:
        print(f"Du nimmst {gegenstand}.")

    case ["nimm", *gegenstaende]:
        print(f"Du nimmst {', '.join(gegenstaende)}.")

Die drei Patterns bedeuten:

["nimm"]                    genau ein Element
["nimm", gegenstand]        genau zwei Elemente
["nimm", *gegenstaende]     mindestens ein Element

Würde das Star-Pattern zuerst stehen, würde es auch die beiden spezielleren Varianten schlucken.

Capture Patterns: Werte an Namen binden

Ein einfacher Name innerhalb eines Patterns ist normalerweise kein Vergleich, sondern ein Capture Pattern:

match eingabe.split():
    case ["nimm", gegenstand]:
        print(f"Du nimmst {gegenstand}.")

Bei

nimm fackel

ergibt .split():

["nimm", "fackel"]

Das Pattern

["nimm", gegenstand]

bedeutet:

  1. Das Subject muss eine passende Sequenz mit genau zwei Elementen sein.
  2. Das erste Element muss dem Literal "nimm" entsprechen.
  3. Das zweite Element wird an gegenstand gebunden.

Im Case-Block enthält gegenstand deshalb:

"fackel"

Ein Capture Pattern passt grundsätzlich auf jeden Wert:

match wert:
    case irgendwas:
        print(irgendwas)

Dieser Case ist deshalb genauso irrefutable wie case _:. Der Unterschied: _ verwirft den Wert, ein Capture Pattern bindet ihn an einen Namen.

Bindungen existieren auch nach dem match

Variablen aus einem erfolgreichen Pattern sind nicht auf den Case-Block beschränkt:

match ["nimm", "fackel"]:
    case ["nimm", gegenstand]:
        print(f"Im Case: {gegenstand}")

print(f"Danach: {gegenstand}")

Ausgabe:

Im Case: fackel
Danach: fackel

Für die Lesbarkeit ist es oft trotzdem besser, solche Namen nur dort zu verwenden, wo das Pattern sie erzeugt hat.

Bei fehlgeschlagenen Patterns solltest Du Dich dagegen auf gar nichts verlassen. Ein Teil des Patterns kann bereits einen Namen gebunden haben, bevor ein späterer Teil scheitert. Die Python-Spezifikation lässt ausdrücklich offen, ob solche Zwischenbindungen danach bestehen bleiben.

Code wie dieser ist deshalb keine gute Idee:

match wert:
    case [x, 0]:
        ...

# Nicht darauf verlassen, ob x hier existiert.

Nur Bindungen eines erfolgreich ausgewählten Cases sind verlässlich.

Sequence Patterns

Sequence Patterns zerlegen Sequenzen in einzelne Bestandteile:

def verarbeite(eingabe):
    woerter = eingabe.strip().lower().split()

    match woerter:
        case []:
            return "Sag etwas."

        case ["umsehen"] | ["schau"]:
            return "Du schaust Dich um."

        case ["nimm"]:
            return "Was möchtest Du nehmen?"

        case ["nimm", gegenstand]:
            return f"Du nimmst {gegenstand}."

        case ["geh", richtung]:
            return f"Du gehst nach {richtung}."

        case _:
            return "Unbekannter Befehl."

Ein Sequence Pattern ohne Stern verlangt genau die angegebene Anzahl an Elementen.

case ["geh", richtung]:

passt auf:

["geh", "norden"]

aber nicht auf:

["geh"]
["geh", "nach", "norden"]

Mehrere Elemente mit * sammeln

Wie beim normalen Unpacking kannst Du mehrere Elemente mit * auffangen:

match eingabe.split():
    case ["sage", *woerter]:
        print(" ".join(woerter))

Bei

sage öffne das alte tor

enthält woerter:

["öffne", "das", "alte", "tor"]

Das Star-Pattern darf auch in der Mitte stehen:

match daten:
    case [erstes, *mitte, letztes]:
        print(f"Erstes: {erstes}")
        print(f"Mitte: {mitte}")
        print(f"Letztes: {letztes}")

Für

[1, 2, 3, 4, 5]

werden gebunden:

erstes = 1
mitte = [2, 3, 4]
letztes = 5

Der vom Star-Pattern aufgefangene Teil wird als neue Liste gebunden.

Innerhalb eines einzelnen Sequence Patterns darf höchstens ein Star-Pattern vorkommen:

case [*anfang, mitte, *ende]:  # ungültig
    ...

Wenn Dich die übrigen Elemente nicht interessieren, kannst Du *_ verwenden:

match daten:
    case [erstes, zweites, *_]:
        print(erstes, zweites)

Eckige und runde Klammern bedeuten dasselbe

Für Sequence Patterns kannst Du eckige oder runde Klammern verwenden:

case [x, y]:
    ...

und:

case (x, y):
    ...

haben dieselbe Pattern-Semantik.

Die Schreibweise prüft also nicht:

[] = muss eine Liste sein
() = muss ein Tupel sein

Beide können beispielsweise auf eine Liste oder ein Tupel passen:

def zeige(daten):
    match daten:
        case [x, y]:
            print(x, y)


zeige([10, 20])
zeige((10, 20))

Beide Aufrufe matchen.

Auch andere unterstützte Sequenztypen können passen, etwa range oder collections.deque.

Aber nicht jedes Iterable ist eine Sequence

Ein Sequence Pattern matcht nicht einfach alles, worüber man mit for iterieren kann.

Ein Generator ist beispielsweise ein Iterable:

werte = (x for x in range(3))

aber kein Subject, das von einem Sequence Pattern wie

case [a, b, c]:

automatisch konsumiert wird.

Das ist Absicht. Pattern Matching soll beim Ausprobieren eines Patterns nicht irgendwelche Iteratoren teilweise verbrauchen und anschließend bei einem fehlgeschlagenen Match versuchen müssen, diesen Zustand wiederherzustellen.

Bei eigenen Typen reicht es deshalb auch nicht automatisch, nur __len__() und __getitem__() zu implementieren. Ein Typ muss für Pattern Matching als Sequence gelten, etwa durch Ableitung oder Registrierung bei collections.abc.Sequence.

Strings sind ausdrücklich ausgenommen

Obwohl Strings normalerweise als Sequenzen betrachtet werden, zerlegt Structural Pattern Matching sie nicht in einzelne Zeichen:

match "ab":
    case [erstes, zweites]:
        print(erstes, zweites)

Dieser Case passt nicht.

Dasselbe gilt für:

bytes
bytearray

Bei Textbefehlen ist .split() deshalb eine gute Vorbereitung:

woerter = eingabe.split()

match woerter:
    case ["nimm", gegenstand]:
        ...

Damit arbeitest Du mit einer echten Liste von Wörtern.

Mapping Patterns

Mit einem Mapping Pattern kannst Du Dictionaries und andere unterstützte Mapping-Typen anhand bestimmter Schlüssel untersuchen.

Nehmen wir einen Player State:

spieler = {
    "name": "Karl",
    "hp": 75,
    "max_hp": 100,
    "gold": 50,
}

Ein Pattern kann gezielt einen Schlüssel prüfen und dessen Wert binden:

match spieler:
    case {"hp": hp}:
        print(f"Der Spieler besitzt {hp} Lebenspunkte.")

Das Pattern verlangt lediglich, dass ein Schlüssel "hp" vorhanden ist und dessen Wert auf das Pattern hp passt.

Weitere Schlüssel stören nicht.

Das ist ein wichtiger Unterschied zu einem Sequence Pattern:

case [x, y]:

verlangt ohne Star-Pattern genau zwei Elemente.

case {"hp": hp}:

verlangt dagegen nicht, dass das Mapping ausschließlich aus "hp" besteht.

Mehrere Schlüssel prüfen

Mehrere Schlüssel lassen sich gleichzeitig verlangen:

match spieler:
    case {"name": name, "hp": hp}:
        print(f"{name} besitzt {hp} Lebenspunkte.")

Beide Schlüssel müssen vorhanden sein.

Du kannst auch einen konkreten Wert im Mapping verlangen:

match spieler:
    case {"hp": 0}:
        print("Game Over.")

    case {"hp": hp}:
        print(f"Noch {hp} HP.")

Der erste Case passt nur, wenn der vorhandene "hp"-Wert tatsächlich 0 ist.

Übrige Einträge mit **rest sammeln

Nicht ausdrücklich gematchte Schlüssel kannst Du mit **rest auffangen:

match spieler:
    case {"name": name, **rest}:
        print(f"Name: {name}")
        print(f"Übriger State: {rest}")

Für unseren Player State enthält rest:

{
    "hp": 75,
    "max_hp": 100,
    "gold": 50,
}

rest ist dabei ein neues Dictionary.

**_ ist in einem Mapping Pattern nicht erlaubt. Es wäre ohnehin überflüssig, weil zusätzliche Schlüssel standardmäßig ignoriert werden:

case {"name": name}:
    ...

reicht vollkommen aus, wenn Dich der Rest nicht interessiert.

Mapping Patterns greifen nicht einfach über [] zu

Intern werden die angegebenen Schlüssel bei Mapping Patterns über das Verhalten von get() nachgeschlagen. Das hat beispielsweise bei defaultdict eine interessante Folge.

Ein Pattern erzeugt keinen fehlenden Schlüssel nur deshalb, weil ein defaultdict für normale []-Zugriffe einen Defaultwert erzeugen würde.

from collections import defaultdict

daten = defaultdict(int)

match daten:
    case {"hp": hp}:
        print(hp)

Der Case passt nicht bloß deshalb, weil

daten["hp"]

den Wert 0 erzeugen könnte. "hp" muss für das Mapping Pattern bereits vorhanden sein.

Für normalen Anwendungscode ist dieses Detail selten relevant, verhindert aber überraschende Seiteneffekte beim Matchen.

Guards: zusätzliche Bedingungen

Patterns sind besonders gut darin, Form, Typ und enthaltene Werte zu prüfen. Für Bedingungen, die sich nicht sinnvoll als Pattern ausdrücken lassen, gibt es Guards.

match spieler:
    case {"hp": hp} if hp <= 0:
        print("Game Over.")

    case {"hp": hp} if hp <= 25:
        print(f"Dein Zustand ist kritisch: {hp} HP.")

    case {"hp": hp}:
        print(f"Du besitzt noch {hp} HP.")

Ein Guard steht hinter dem Pattern:

case pattern if bedingung:

Python geht dabei sinngemäß so vor:

  1. Pattern matchen.
  2. Gebundene Werte bereitstellen.
  3. Guard auswerten.
  4. Nur wenn beides erfolgreich ist, den Case-Block ausführen.

Passt das Pattern, aber der Guard ergibt False, geht Python zum nächsten Case weiter.

Die vom Pattern gebundenen Namen kannst Du bereits im Guard verwenden:

case {"hp": hp} if hp <= 25:

Zuerst wird "hp" an hp gebunden, anschließend prüft Python:

hp <= 25

Reine Wertebereiche brauchen meistens kein match

Ein Guard macht nicht automatisch jedes match sinnvoll.

Für:

if hp <= 0:
    print("Game Over.")
elif hp <= 25:
    print("Kritisch!")
else:
    print("Alles in Ordnung.")

wäre diese Variante möglich:

match hp:
    case wert if wert <= 0:
        print("Game Over.")
    case wert if wert <= 25:
        print("Kritisch!")
    case _:
        print("Alles in Ordnung.")

Sie gewinnt aber nichts.

match besitzt keinen eigenen Range-Pattern-Typ. Wenn Du nur Zahlenbereiche prüfen möchtest, ist ein normales if meist direkter.

Der Guard wird interessant, wenn bereits das Pattern Arbeit übernimmt:

match event:
    case {"typ": "schaden", "wert": wert} if wert > 100:
        print("Kritischer Treffer!")

Hier prüft das Pattern die Struktur und bindet wert; der Guard ergänzt nur noch die Bedingung.

Class Patterns

Class Patterns können prüfen, ob ein Subject eine Instanz einer bestimmten Klasse ist, und anschließend deren Attribute matchen.

class Punkt:
    def __init__(self, x, y):
        self.x = x
        self.y = y

Mit Keyword Patterns lassen sich die Attribute direkt ansprechen:

punkt = Punkt(0, 7)

match punkt:
    case Punkt(x=0, y=0):
        print("Der Punkt liegt im Ursprung.")

    case Punkt(x=0, y=y):
        print(f"Der Punkt liegt auf der Y-Achse bei {y}.")

    case Punkt(x=x, y=0):
        print(f"Der Punkt liegt auf der X-Achse bei {x}.")

    case Punkt(x=x, y=y):
        print(f"Der Punkt liegt bei {x}, {y}.")

Ausgabe:

Der Punkt liegt auf der Y-Achse bei 7.

Der zweite Case bedeutet sinngemäß:

  1. Ist punkt eine Instanz von Punkt?
  2. Hat das Attribut x den Wert 0?
  3. Falls ja: Binde den Wert von y an den lokalen Namen y.

Dabei verwendet Python für die Typprüfung isinstance().

Eine Unterklasse von Punkt kann deshalb ebenfalls matchen:

class MarkierterPunkt(Punkt):
    pass


punkt = MarkierterPunkt(0, 7)

match punkt:
    case Punkt(x=0, y=y):
        print(y)

Das Pattern prüft nicht auf exakte Typgleichheit.

Ein Class Pattern ist kein Constructor Call

Diese Schreibweise:

case Punkt(x=0, y=y):

sieht ein bisschen wie ein Funktions- oder Constructor-Aufruf aus.

Es wird aber kein Punkt erzeugt.

Python verwendet Punkt zur Typprüfung und untersucht anschließend die Attribute des bereits vorhandenen Subjects.

Positionale Class Patterns mit __match_args__

Keyword Patterns sind explizit:

case Punkt(x=0, y=y):

Manche Klassen können zusätzlich positional gematcht werden:

case Punkt(0, y):

Dafür verwendet Python das Klassenattribut __match_args__.

class Punkt:
    __match_args__ = ("x", "y")

    def __init__(self, x, y):
        self.x = x
        self.y = y

Jetzt sind diese Patterns möglich:

match punkt:
    case Punkt(0, 0):
        print("Ursprung")

    case Punkt(0, y):
        print(f"Y-Achse bei {y}")

    case Punkt(x, 0):
        print(f"X-Achse bei {x}")

    case Punkt(x, y):
        print(f"Punkt bei {x}, {y}")

Die Definition

__match_args__ = ("x", "y")

legt die Reihenfolge für positionale Patterns fest.

Punkt(0, y)

entspricht damit sinngemäß:

Punkt(x=0, y=y)

Für selbst geschriebene Klassen finde ich Keyword Patterns oft angenehmer, weil man beim Lesen sofort sieht, welches Attribut gemeint ist.

Positionale Patterns funktionieren besonders gut bei Typen, deren Bestandteile eine offensichtliche Reihenfolge besitzen.

Dataclasses übernehmen die Arbeit

Bei einer Dataclass musst Du __match_args__ normalerweise nicht selbst definieren:

from dataclasses import dataclass


@dataclass
class Punkt:
    x: int
    y: int

Dataclasses erzeugen standardmäßig ein passendes __match_args__. Deshalb funktioniert direkt:

punkt = Punkt(0, 7)

match punkt:
    case Punkt(0, y):
        print(f"Y-Achse bei {y}")

Ausgabe:

Y-Achse bei 7

Auch hier bleibt die explizite Variante möglich:

case Punkt(x=0, y=y):

Welche Schreibweise klarer ist, hängt von der Klasse ab.

Patterns verschachteln

Die einzelnen Pattern-Arten lassen sich miteinander kombinieren. Darin steckt ein großer Teil der eigentlichen Stärke von Structural Pattern Matching.

Nehmen wir ein Event aus einem Spiel:

event = {
    "typ": "schaden",
    "quelle": {
        "name": "Goblin",
        "art": "monster",
    },
    "wert": 25,
}

Das können wir in einem einzigen Pattern zerlegen:

match event:
    case {
        "typ": "schaden",
        "quelle": {"name": name, "art": "monster"},
        "wert": schaden,
    }:
        print(f"{name} verursacht {schaden} Schaden.")

Ausgabe:

Goblin verursacht 25 Schaden.

Dieses eine Pattern verlangt:

  • "typ" hat den Wert "schaden".
  • "quelle" ist ein passendes Mapping.
  • Darin hat "art" den Wert "monster".
  • "name" wird an name gebunden.
  • "wert" wird an schaden gebunden.

Die klassische Variante könnte etwa so aussehen:

if (
    event["typ"] == "schaden"
    and event["quelle"]["art"] == "monster"
):
    name = event["quelle"]["name"]
    schaden = event["wert"]

    print(f"{name} verursacht {schaden} Schaden.")

Das funktioniert, setzt aber voraus, dass die erwarteten Schlüssel tatsächlich vorhanden sind. Das Pattern beschreibt dagegen direkt die Datenform, die für diesen Fall gebraucht wird.

Noch interessanter wird es bei mehreren Event-Typen:

match event:
    case {
        "typ": "schaden",
        "quelle": {"name": name},
        "wert": wert,
    }:
        print(f"{name} verursacht {wert} Schaden.")

    case {
        "typ": "heilung",
        "ziel": name,
        "wert": wert,
    }:
        print(f"{name} erhält {wert} HP.")

    case {
        "typ": "bewegung",
        "richtung": richtung,
    }:
        print(f"Bewegung nach {richtung}.")

    case _:
        print("Unbekanntes Event.")

Hier wird nicht einfach ein einzelner Wert verglichen. Jeder Case beschreibt eine andere Form der Daten und holt gleichzeitig genau die Werte heraus, die der jeweilige Code benötigt.

Die Falle mit nackten Namen

Das ist wahrscheinlich die wichtigste Stolperfalle bei match.

Angenommen, wir definieren eine vermeintliche Konstante:

ENDE = "ende"

Dann sieht dieser Code zunächst plausibel aus:

def reagiere(befehl):
    match befehl:
        case ENDE:
            return "Das Spiel endet."

Er bedeutet aber nicht:

if befehl == ENDE:

ENDE ist hier ein nackter Name und damit ein Capture Pattern. Jeder Wert wird an diesen Namen gebunden.

Der Case passt also immer.

print(reagiere("norden"))

liefert ebenfalls:

Das Spiel endet.

Mit einem weiteren Case wird das Problem noch offensichtlicher:

match befehl:
    case ENDE:
        ...
    case _:
        ...

Das ist bereits ein SyntaxError, weil case ENDE irrefutable ist. Der Wildcard-Case könnte niemals erreicht werden.

Warum Literale funktionieren

Diese Patterns sind echte Wertvergleiche:

case "ende":
case 404:
case True:
case False:
case None:

Sie werden von Python als Literal Patterns erkannt und nicht als neue Variablennamen.

Bei vorhandenen benannten Werten braucht Python dagegen eine Möglichkeit, Capture und Vergleich eindeutig auseinanderzuhalten.

Value Patterns: vorhandene Werte vergleichen

Ein benannter Wert kann als Value Pattern verwendet werden, wenn er über einen gepunkteten Namen angesprochen wird.

Zum Beispiel:

class Befehle:
    ENDE = "ende"
    STATUS = "status"

Jetzt funktioniert ein echter Vergleich:

def reagiere(befehl):
    match befehl:
        case Befehle.ENDE:
            return "Das Spiel endet."

        case Befehle.STATUS:
            return "Der Status wird angezeigt."

        case _:
            return "Unbekannter Befehl."

Befehle.ENDE ist ein Value Pattern.

Python löst den Wert auf und vergleicht ihn mit dem Subject. Ein solcher Vergleich verwendet ==.

Enums eignen sich dafür besonders gut:

from enum import Enum


class Status(Enum):
    AKTIV = 1
    BESIEGT = 2
match status:
    case Status.AKTIV:
        print("Das Spiel läuft.")

    case Status.BESIEGT:
        print("Game Over.")

Hier sollte status tatsächlich ein entsprechendes Enum Member sein:

status = Status.AKTIV

Der Integer

1

ist nicht dasselbe wie:

Status.AKTIV

Guard als Alternative

Eine vorhandene lokale Variable kannst Du auch über einen Guard vergleichen:

ENDE = "ende"

match befehl:
    case wert if wert == ENDE:
        print("Das Spiel endet.")

    case _:
        print("Unbekannter Befehl.")

Das funktioniert, ist für einen einzelnen Vergleich aber umständlicher als:

if befehl == ENDE:
    ...

Value Patterns lohnen sich vor allem bei Enums oder sinnvoll strukturierten Konstanten.

Group Patterns

Runde Klammern können auch einfach ein Pattern gruppieren:

case ("norden" | "sueden") as richtung:

Die Klammern sorgen hier dafür, dass sich as richtung auf das gesamte OR-Pattern bezieht.

Das ist etwas anderes als ein Sequence Pattern.

Der Unterschied steckt wie bei normalen Tupeln im Komma:

case (wert):

ist lediglich ein gruppiertes Pattern.

case (wert,):

ist ein Sequence Pattern mit genau einem Element.

Für die meisten alltäglichen Patterns ergibt sich die nötige Gruppierung ohnehin aus der Schreibweise, bei Kombinationen aus | und as kann sie aber die Absicht deutlich machen.

Ein Command Parser für den Dungeon

Jetzt können wir die einzelnen Pattern-Arten auf unser Text-Adventure loslassen.

Die Eingabe wird zunächst normalisiert und in Wörter zerlegt:

woerter = eingabe.strip().lower().split()

Danach beschreibt jeder Case eine gültige Befehlsform:

def verarbeite_befehl(eingabe, raum, spieler):
    """Verarbeitet einen Textbefehl und gibt das Ergebnis zurück."""
    woerter = eingabe.strip().lower().split()

    match woerter:
        case []:
            return "Bitte gib einen Befehl ein."

        case ["ende"] | ["quit"]:
            return "ende"

        case ["umsehen"] | ["schau"]:
            gegenstaende = raum["gegenstaende"]

            if gegenstaende:
                return "Hier liegt: " + ", ".join(gegenstaende)

            return "Hier liegt nichts Brauchbares."

        case ["inventar"]:
            inventar = spieler["inventar"]

            if inventar:
                return "Inventar: " + ", ".join(inventar)

            return "Dein Inventar ist leer."

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

        case ["nimm"]:
            return "Was möchtest Du nehmen?"

        case ["nimm", *namen]:
            gesucht = " ".join(namen)

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

                    return f"Du nimmst {gegenstand}."

            return f"Hier liegt kein Gegenstand namens {gesucht}."

        case ["geh", richtung] | [richtung] if richtung in raum["ausgaenge"]:
            return raum["ausgaenge"][richtung]

        case _:
            return "Das verstehe ich nicht."

Die Eingabe

nimm alte fackel

wird zu:

["nimm", "alte", "fackel"]

Darauf passt:

case ["nimm", *namen]:

namen enthält anschließend:

["alte", "fackel"]

und

gesucht = " ".join(namen)

macht daraus wieder:

alte fackel

Das Bewegungs-Pattern akzeptiert gleich zwei Eingabeformen:

case ["geh", richtung] | [richtung] if richtung in raum["ausgaenge"]:

Damit funktionieren:

geh norden

und:

norden

Beide Alternativen binden denselben Namen richtung, wie es ein OR-Pattern verlangt.

Erst danach prüft der Guard:

richtung in raum["ausgaenge"]

Ein einzelnes unbekanntes Wort wie

tanzen

passt zwar zunächst auf:

[richtung]

der Guard schlägt aber fehl. Python probiert deshalb den nächsten Case und landet schließlich bei:

case _:

Das ist ein gutes Beispiel dafür, wie Pattern und Guard unterschiedliche Aufgaben übernehmen:

  • Das Pattern beschreibt die Form der Eingabe.
  • Der Guard prüft eine zusätzliche Bedingung gegen den aktuellen Game State.

In einem größeren Programm würde ich die verschiedenen Return Values nicht dauerhaft als nackte Strings verwenden. Dann wären beispielsweise Enums, Dataclasses oder eigene Result-Objekte sauberer. Für unseren kleinen Parser reicht die einfache Variante.

Wann match gut passt

match spielt seine Stärke aus, wenn die Struktur der Daten Teil der Entscheidung ist.

Ein gutes Beispiel sind Nachrichten:

match nachricht:
    case {"typ": "chat", "text": text}:
        ...

    case {"typ": "bewegung", "x": x, "y": y}:
        ...

    case {"typ": "schaden", "wert": wert, "quelle": quelle}:
        ...

Oder unterschiedliche Objektformen:

match objekt:
    case Punkt(x=0, y=0):
        ...

    case Punkt(x=x, y=y):
        ...

    case _:
        ...

Oder ein kleiner Command Parser:

match eingabe.split():
    case ["nimm", *namen]:
        ...

    case ["geh", richtung]:
        ...

    case ["benutze", gegenstand, "mit", ziel]:
        ...

Hier erledigt das Pattern gleichzeitig mehrere Dinge: Es prüft die Form, gleicht bestimmte Bestandteile ab und bindet die interessanten Werte.

Mit einer langen Reihe von Indexzugriffen und Bedingungen wäre dieselbe Logik oft schwerer zu lesen.

Wann if besser ist

Für einfache Bedingungen bleibt if meistens die klarere Wahl.

if hp <= 0:
    print("Game Over.")
elif hp <= 25:
    print("Kritisch!")
else:
    print("Du kannst weiterkämpfen.")

Dafür ein match mit mehreren Guards zu konstruieren, macht den Code nicht besser.

Auch:

if befehl == "ende":
    spiel_beenden()

braucht kein:

match befehl:
    case "ende":
        spiel_beenden()

Wenn Du nur einen einzelnen Wert oder eine boolesche Bedingung prüfen möchtest, spricht wenig gegen if.

match ist kein moderner Ersatz für if. Es ist ein anderes Werkzeug für andere Formen von Entscheidungen.

Stolperfallen

  • Einen nackten Namen für eine Konstante halten: case ENDE greift nicht auf den vorhandenen Wert von ENDE zu. Es bindet das Subject an einen neuen Namen beziehungsweise an diesen Namen im aktuellen Scope. Für benannte Werte verwendest Du ein Value Pattern wie Befehle.ENDE oder Status.AKTIV.

  • case _ zu früh einsetzen: Das Wildcard Pattern passt immer. Ein ungeschützter irrefutable Case muss der letzte Case sein.

  • Zu allgemeine Patterns zuerst schreiben: Der erste erfolgreiche Case gewinnt. Spezielle Datenformen gehören vor allgemeinere.

  • Capture Patterns mit Vergleichen verwechseln: case wert: bedeutet nicht „vergleiche mit der Variablen wert“, sondern „binde das Subject an wert“.

  • Auf Bindungen eines fehlgeschlagenen Patterns vertrauen: Nur Namen eines erfolgreich ausgewählten Patterns sind verlässlich. Was mit Teilbindungen eines fehlgeschlagenen Patterns geschieht, ist absichtlich nicht garantiert.

  • Sequence Patterns mit exakten Typprüfungen verwechseln: [x, y] verlangt keine list. Auch andere von Pattern Matching unterstützte Sequenztypen können passen.

  • Beliebige Iterables als Sequence erwarten: Generatoren und andere reine Iteratoren werden nicht automatisch durch Sequence Patterns zerlegt.

  • Strings direkt zerlegen wollen: str, bytes und bytearray sind für Sequence Patterns ausdrücklich ausgenommen.

  • Bei einem Mapping exakte Gleichheit erwarten: {"hp": hp} passt auch, wenn das Mapping zehn weitere Schlüssel besitzt.

  • **_ in Mapping Patterns verwenden: Zusätzliche Mapping-Einträge werden ohnehin ignoriert. **_ ist deshalb nicht erlaubt.

  • Bei OR-Patterns unterschiedliche Namen binden: Jede Alternative eines OR-Patterns muss dieselbe Menge von Namen bereitstellen.

  • Class Patterns für Constructor Calls halten: case Punkt(x=0, y=y) erzeugt keinen Punkt, sondern prüft ein vorhandenes Objekt.

  • Class Patterns als exakte Typprüfung verstehen: Sie verwenden isinstance(). Unterklassen können deshalb ebenfalls passen.

  • break nach einem Case schreiben: Nach dem ersten erfolgreichen Case ist das match-Statement automatisch beendet.

  • match für jeden Vergleich verwenden: Wertebereiche und einfache boolesche Bedingungen bleiben häufig als if lesbarer.

Kompakte Übersicht

Pattern Beispiel Bedeutung
Literal Pattern case "ende": vergleicht mit einem Literal
Wildcard Pattern case _: passt immer und bindet nichts
Capture Pattern case wert: bindet das Subject an wert
OR-Pattern case "ja" \| "j": probiert mehrere Alternativen
AS-Pattern case ("n" \| "s") as richtung: bindet ein erfolgreiches Pattern
Group Pattern case ("ja" \| "j"): gruppiert ein Pattern
Sequence Pattern case ["nimm", ding]: prüft und zerlegt eine Sequenz
Star Pattern case [erstes, *rest]: sammelt übrige Sequenzelemente
Mapping Pattern case {"hp": hp}: prüft Schlüssel und bindet Werte
Class Pattern case Punkt(x=0, y=y): prüft Instanz und Attribute
Value Pattern case Status.AKTIV: vergleicht mit einem gepunkteten Wert
Guard case wert if wert > 0: ergänzt eine zusätzliche Bedingung

Die wichtigste Regel, die man sich bei match merken sollte, ist weniger die Syntax als der Unterschied zwischen Vergleich und Bindung:

case "ende":

vergleicht mit einem Wert.

case Befehle.ENDE:

vergleicht ebenfalls mit einem vorhandenen Wert.

Aber:

case ende:

bindet das Subject an einen Namen.

Wer diesen Unterschied im Kopf hat, sieht in match schnell mehr als einen switch: Es ist vor allem ein Werkzeug zum Beschreiben, Prüfen und Zerlegen strukturierter Daten.

Weiterlesen

0 Kommentare

Noch keine Kommentare. Sei der/die Erste!