Ende 2021 wurde mit Python 3.10 die Möglichkeit eines Strukturmusterabgleich (engl. Pattern Matching) implementiert. Diverse andere Sprachen, wie C, Java oder C# verwenden für eine einfache Fallunterscheidung klassischerweise switch-Konstruktionen – Python 3.10+ zieht mit seiner neuen match-Anweisung nach und bietet zusätzlich deutlich mehr Möglichkeiten, indem auch komplexe Datenstrukturen zerlegt und verarbeitet werden können. Trotzdem ist vielen Python-Entwicklern die neue Funktion nicht bekannt – vielleicht, weil ich über viele Jahre die Umsetzung mit einer if-elif-else-Struktur eingebrannt hat. In diesem Blog-Artikel wird die Funktionsweise der match-Anweisung grundlegend mit diversen kleinen Beispielen erklärt.
In einem einfachen Beispiel (hier in Java) wird der Variable wochentag eine Zahl zwischen eins und sieben zugeordnet. Anschließend soll der ausgeschriebene Wochentag in der Konsole ausgegeben werden. Die Variable wochentag ist hierbei der Ausdruck, auf den sich die switch-Anweisung bezieht. Die case-Labels definieren die Einsprungspunkt, sie legen also fest, welcher Code ausgeführt wird, wenn diese Labels dem Wert der Variable wochentag entsprechen. In diesem Beispiel wird Dienstag in der Konsole ausgegeben, da die Variable wochentag den Wert 2 enthält. Wichtig ist, dass hinter der Anweisung System.out.println die break-Anweisung steht. Diese beendet die switch-Anweisung – anderenfalls würde der Code der folgenden case-Labels ausgeführt werden (also würde nach Dienstag auch Sonntag und Unbekannter Wochentag ausgegeben werden).
int wochentag = 2;
switch (wochentag) {
case 1:
System.out.println("Montag");
break;
case 2:
System.out.println("Dienstag");
break;
// usw...
case 7:
System.out.println("Sonntag");
break;
default: // Alle zuvor definierten cases wurden nicht erfüllt
System.out.println("Unbekannter Wochentag");
}
In Python werden solche Anwendungsfälle klassisch mit if-elif-else-Strukturen implementiert. In diesem konkreten Beispiel könnte auch ein Sequence- oder Mapping-Typ verwendet werden (wie eine Liste oder ein Dictionary), bei komplexeren Anweisungen, die über eine Ausgabe in der Konsole hinausgehen, ist zwar theoretisch möglich, aber komplex und daher nicht sinnvoll.
wochentag: int = 2
if wochentag == 1:
print("Montag")
elif wochentag == 2:
print("Dienstag")
# usw...
elif wochentag == 7:
print("Sonntag")
else:
print(f"Unbekannter Wochentag: {wochentag}")
Mittels der neuen match-Anweisung in Python kann dieses Beispiel eleganter und ohne if-Anweisungen implementiert werden. Die match-Anweisung verhält sich in diesem Beispiel ähnlich wie das switch-Konstrukt in Java. Anders als in Java wird am Ende der Anweisungen, die einem case zugeordnet sind, jedoch kein break benötigt. Wird in einer match-Anweisung kein case erfüllt, dann wird kein Code innerhalb dieser ausgeführt. Wird der case _ definiert, dann wird dieser ausgeführt, wenn die alle zuvor definierten cases nicht erfüllt wurden.
wochentag: int = 2
match wochentag:
case 1:
print("Montag")
case 2:
print("Dienstag")
# usw...
case 7:
print("Sonntag")
case _: # Alle zuvor definierten cases wurden nicht erfüllt
print(f"Unbekannter Wochentag: {wochentag}")
Verschiedene cases, für die dieselben Anweisungen ausgeführt werden sollen, können einfach zusammengefasst werden. Im folgenden Beispiel sollen angezeigt werden, ob es sich um einen Arbeitstag, oder um einen Wochentag handelt.
wochentag: int = 2
match wochentag:
case 1 | 2 | 3 | 4 | 5: # die Variable wochentag entspricht einem dieser fünf Werte
print("Wochentag")
case 6 | 7:
print("Wochenende")
case _:
print(f"Unbekannter Wochentag: {wochentag}")
Die einzelnen cases können zusätzlich um if-Anweisung erweitert werden. Das obige Beispiel wird um den Fall erweitert, dass eine Person am Wochenende arbeiten kann. Hierfür wird hinter den Werten im case eine if-Bedingung ergänzt. Die im Code markierten Cases A und B sind beide erfüllt. In einem solchen Fall wird nur der als erstes definierte Case ausgeführt – in diesem Beispiel also ausschließlich Case A und nicht Case B. Ein solches Szenario, also das Erfüllen mehrerer Bedingungen, ist mit switch-Konstrukten nicht umsetzbar.
wochentag: int = 7
arbeit_am_wochenende: bool = True
match wochentag:
case 1 | 2 | 3 | 4 | 5:
print("Wochentag")
case 6 | 7 if arbeit_am_wochenende: # Case A
print("Wochenende, aber ich muss arbeiten")
case 6 | 7: # Case B
print("Wochenende")
case _:
print(f"Unbekannter Wochentag: {wochentag}")
match-Anweisungen können nicht nur auf primitive Datentypen (int, float, str, bool) angewendet werden, sondern auch Sequenzen, Dictionaries und eigene Klassen. Die Möglichkeit mehr als nur primitive Datentypen einfach und mit wenig Programmieraufwand zu behandeln bietet diverse Vorteile, wie zum Beispiel einen übersichtlicheren Quellcode (und damit eine Reduzierung von Fehlern) und Zeitersparnis beim Programmieren.
Im folgendem Beispiel werden Sequenzen ausgewertet, welche mit zwei Stellen einen Punkt darstellen. Ohne if-Anweisungen und Indexierungen wird bestimmt, ob der Punkt im Ursprung, auf der x- bzw. y-Achse, oder frei im Feld liegt. Automatisch werden die entsprechenden x- und y-Variablen durch die match-Anweisung ermittelt. Fälle, in denen keine Sequenz mit exakt zwei Einträgen als Parameter der Funktion test vorhanden ist, werden nicht behandelt und es wird der String "Kein Punkt" zurückgegeben.
# Sequenzen (z.B. list und tuple)
from typing import Sequence
### Es wird eine Menge an Zahlen erwartet
def test(point: Sequence[int]) -> str:
match point:
case (0, 0):
return "Ursprung"
case (x, 0):
return f"Auf der X-Achse bei {x}"
case (0, y):
return f"Auf der Y-Achse bei {y}"
case (x, y):
return f"Punkt bei {x}, {y}"
case _:
return "Kein Punkt"
### Es können Lists oder Tuples verwendet werden, da beide Sequenzen sind
test( (3, 9) ) # => "Punkt bei 3, 9"
test( [0, 3] ) # => "Auf der Y-Achse bei 3"
test( (4, -2) ) # => "Punkt bei 4, -2"
test( (9, 0) ) # => "Auf der X-Achse bei 9"
test( (1, 2, 3) ) # => "Kein Punkt" (Es werden nur Sequenzen mit exakt zwei Werten akzeptiert)
test( [0, 0] ) # => "Ursprung"
test( "x=5, y=3" ) # => "Kein Punkt" (Sonderfall: Ein String wird als Sequenz von einzelnen Zeichen betrachtet. Da die Länge der Sequenz nicht zwei ist, wird diese Sequenz nicht akzeptiert)
Neben der Auswertung von Sequenzen bestehen viele Möglichkeiten der Anwendung auf Dictionaries. Im folgenden sind fünf Beispiele, die das Verhalten von match-Anweisungen mit Dictionaries im einfachen Mapping, in optionalen Schlüsseln, in der Kombination mit if-Anweisungen, sowie in Verschachtelungen beschreiben.
# Dictionaries
### Einfaches Mapping
def test_1(arg: dict[str, str]) -> str:
match arg:
case {"usertype": "user", "name": name}:
return f"Der Benutzer heißt {name}"
case {"usertype": "admin", "name": name}:
return f"Der Administrator heißt {name}"
case _:
return "Ungültiges Argument"
test_1( {"usertype": "user", "name": "Max"} ) # => "Der Benutzer heißt Max"
test_1( {"usertype": "admin", "name": "Moritz"} ) # => "Der Administrator heißt Moritz"
test_1( {"usertype": "guest", "name": "Fritz"} ) # => "Ungültiges Argument" (Der usertype "guest" wird nicht akzeptiert)
test_1( {"usertype": "user"} ) # => "Ungültiges Argument" (Es ist kein Name angegeben)
### Optionale Schlüssel (1/2)
def test_2(arg: dict[str, str]) -> str:
match arg:
case {"action": "login", "user": user}:
return f"{user} hat sich angemeldet"
case {"action": "login"}:
return "Ein nicht bekannter User hat sich angemeldet"
case _:
return "Ungültige Aktion"
test_2( {"action": "login"} ) # => "Ein nicht bekannter User hat sich angemeldet"
test_2( {"action": "logout"} ) # => "Ungültige Aktion"
test_2( {"action": "login", "user": "Lisa"} ) # => "Lisa hat sich angemeldet"
### Optionale Schlüssel (2/2)
def test_3(arg: dict) -> str:
match arg:
case {"type": "article", **kwds}:
return f"Artikel mit den folgenden Eigenschaften: {kwds}"
case _:
return "Kein Artikel"
test_3( {"type": "article", "farbe": "blau", "groesse": "xxl"} ) # => 'Artikel mit den folgenden Eigenschaften: {"farbe": "blau", "groesse": "xxl"}'
test_3( {"type": "article", "article_type": "Hose"} ) # => 'Artikel mit den folgenden Eigenschaften: {"article_type": "Hose"}'
test_3( {"type": "article"} ) # => 'Artikel mit den folgenden Eigenschaften: {}'
test_3( "Blaue Hose" ) # => "Kein Artikel" (Der Typ String wird nicht akzeptiert)
test_3( {"telnr": 123456789} ) # => "Kein Artikel" (Das Dict muss den Schlüssel "type" mit dem Wert "article" enthalten
### if-Anweisungen auf Dictionary-Werten
def test_4(arg: dict) -> str:
match arg:
case {"strasse": strasse, "hausno": hausno} if hausno <= 0:
return "Die Hausnummer muss größer als 0 sein"
case {"strasse": strasse, "hausno": hausno}:
return f"{strasse} {hausno}"
case _:
return "Fehler"
test_4( {"strasse": "Hauptstraße", "hausno": 42} ) # => "Hauptstraße 42"
test_4( {"strasse": "Unterstraße", "hausno": -9} ) # => "Die Hausnummer muss größer als 0 sein"
test_4( {"strasse": "Oberstraße"} ) # => "Fehler" (Es ist keine Hausnummer angegeben)
# Verschachtelte Dictionaries
def test_5(arg: dict) -> str:
match arg:
case {"name": name, "naehrwerte": {"zucker": zucker, "fett": fett, "salz": salz}}:
# Die Variablen name, zucker, fett und salz wurden definiert
return f"Für das Produkt {name} wurden alle Nährwerte mitgegeben"
case _:
return "Fehler"
test_5( {"name": "Nutella", "naehrwerte": {"zucker": "56,3%", "fett": "30,9%", "salz": "0,1%"}} ) # => "Für das Produkt Nutella wurden alle Nährwerte mitgegeben"
test_5( {"name": "Nutella", "naehrwerte": {"eiweiss": "6,3%"}} ) # => "Fehler" (Es müssen der Zucker-, Fett- und Salz-Gehalt mitgegeben werden; weitere Schlüssel werden nicht akzeptiert)
test_5( {"name": "Nutella", "naehrwerte": {"zucker": "56,3%", "salz": "0,1%"}} ) # => "Fehler" (der Fett-Gehalt wird nicht angegeben)
test_5( "Nutella" ) # => "Fehler" (Nur Dict werden akzeptiert)
test_5( {"name": "Nutella", "naehrwerte": "56,3% Zucker"} ) # => "Fehler" (Der Wert des Schlüssels "naehrwerte" ist nicht vom Typ Dict)
Auch auf eigene Klassen und Objekte lassen sich match-Anweisungen anwenden. Hierfür muss in einer Klasse die Eigenschaft __match_args__ definiert werden. Diese legt fest, wie ein Objekt in den case-Elementen behandelt wird. Die Eigenschaft beschreibt eine Sequenz aus Strings, wobei jeder String den Namen einer Objekteigenschaft beinhaltet. Die so definierten Objekteigenschaften werden schließlich in den case-Elementen angewendet. Die Objekteigenschaften entsprechen nicht zwangsläufig den Argumenten der __init__-Funktion.
Im folgenden Beispiel wird ein Punkt als Elemente der gleichnamigen Klasse definiert. Die __init__-Funktion erwartet einen x- und einen y-Wert als Parameter, deren Wert als Objekteigenschaft xx bzw. yy festgehalten wird. Beide Eigenschaften werden in __match_args__ (siehe Markierung [A]) als Argument in case-Elementen definiert. In der match-Anweisung wird in den case-Elementen der Strukturmusterabgleich definiert. In der mit [B] markierten Code-Zeile wird überprüft, ob die Eigenschaften xx und yy des Objektes p beide null sind. Wichtig ist hierbei, dass in dieser Code-Zeile keine neue Instanz der Klasse Punkt erzeugt wird, was bei dieser Schreibweise außerhalb der case-Elemente passieren würde. Im folgenden case-Element (siehe Markierung [C]) werden Objekte selektiert, deren Eigenschaft xx dem Wert null, und deren Eigenschaft yy nicht dem Wert null entsprechen. Der Wert der Objekteigenschaft yy wird in der match-Anweisung in der Variable yyy dargestellt. Die Benennung der Variablen x, xx und xxx bzw. y, yy und yyy dienen hierbei zum besseren Verständnis des Quellcodes – diese können jedoch alle z.B. x bzw. y benannt werden.
class Punkt:
__match_args__ = ("xx", "yy") # [A] Objekteigenschaften, die in case-Elementen angewendet werden
def __init__(self, x: float, y: float):
self.xx: float = x
self.yy: float = y
def test_1(p: Punkt) -> str:
match p:
case Punkt(0, 0): # [B]
return "Ursprung"
case Punkt(0, yyy): # [C]
return f"Auf der y-Achse bei {yyy}"
case Punkt(xxx, 0):
return f"Auf der x-Achse bei {xxx}"
case Punkt(xxx, yyy):
return f"Punkt bei ({xxx}, {yyy})"
print(test_1(Punkt(0, 0))) # => "Ursprung"
print(test_1(Punkt(0, 5))) # => "Auf der y-Achse bei 5"
print(test_1(Punkt(3, 0))) # => "Auf der x-Achse bei 3"
print(test_1(Punkt(2, 4))) # => "Punkt bei (2, 4)"
Im folgenden Beispiel wird die Funktionsweise nochmals deutlich. Die in der Klasse Pythagoras definierte __init__-Funktion (siehe Markierung [B]) erwartet zwei Parameter mit dem Typ float (a und b). In dieser Funktion werden die Eigenschaften c mit dem Typ float und c_groesser_als_sechs mit dem Typ bool berechnet. Die Namen der Objekteigenschaften werden in der Klassen-Eigenschaft __match_args__ eingetragen (siehe Markierung [A]). Es werden zwei Instanzen dieser Klasse erstellt (siehe Markierung [D]), wobei je zwei float-Werte als Argumente definiert sind. In den case-Elementen der match-Anweisung (siehe Markierung [C]) wird nach Instanzen unterschieden, deren Eigenschaft c_groesser_als_sechs entweder True oder False sind (wie in der Codezeile mit Markierung [A] definiert). Die Variable cc wird mit dem Wert der Eigenschaft c der Instanz gefüllt.
class Pythagoras:
__match_args__ = ("c_groesser_als_sechs", "c") # [A]
def __init__(self, a: float, b: float): # [B]
from math import sqrt
self.a: float = a
self.b: float = b
self.c: float = round(sqrt(a**2 + b**2), 2)
self.c_groesser_als_sechs: bool = self.c > 6
def test_2(p):
match p:
case Pythagoras(True, cc): # [C]
return f"c größer als 6: {cc}"
case Pythagoras(False, cc):
return f"c kleiner/gleich 6: {cc}"
print(test_2(Pythagoras(1.0, 2.0))) # => "c kleiner/gleich 6: 2.24" [D]
print(test_2(Pythagoras(37.5, 12.3))) # => "c größer als 6: 39.47"