Blog

Spaß mit Mustern – Pattern Matching in C#

Sep 5, 2022

Über viele C#-Sprachversionen hinweg hat Microsoft tüchtig an Features für das Pattern Matching gearbeitet. Zum aktuellen Stand ist es Zeit für einen Rückblick und eine Einschätzung. Auf der BASTA! Spring gibt’s auch einen Vortrag zu dem Thema, wenn ihr Interesse habt.

C#-Version 7.0 wurde mit Visual Studio 2017 öffentlich verfügbar, also tatsächlich im Jahre 2017 – das war ja nicht immer so offensichtlich. Zum ersten Mal stand da „Pattern Matching“ auf der Liste der Neuheiten, oder gar „Mustervergleich“, wenn man diese Liste auf Deutsch las. Ich tat das nicht, was mich im Nachhinein glücklich stimmt, denn so musste ich mich nicht über die Neuheit „Erweiterte Ausdruckskörpermember“ wundern [1].

In C# 7 wurde Pattern Matching erstmals möglich, und zwar mit dem recht einfachen Typvergleich. Etwa so:

void ProcessItem(Item item) {
  if (item is Hammer hammer) {
    // jetzt hämmern
  }
  else {
    // kann nicht gehämmert werden
  }
}

Mit C# 7.1 wurde dieses neue Feature noch ein wenig mit der Unterstützung von Generics aufgebohrt, und außer in if-Ausdrücken konnte es auch in switch verwendet werden. Trotzdem ließ sich die Featureankündigung „Pattern Matching“ etwas großspurig an, denn bis dahin gab es tatsächlich nur sehr wenig Möglichkeiten, wenn auch durchaus wichtige.

ZUM NEWSLETTER

Regelmäßig News zur Konferenz und der .NET-Community

In C# 8 ging es allerdings mit Macht weiter, mit vielen Neuerungen für Liebhaber von Mustern. Zunächst konntet ihr nun switch auch als Ausdruck verwenden, was einer kompakten Syntax sehr zuträglich war.

int CalcResult(int input) {
  var result = input switch {
    1 => 2,
    2 => 3,
    _ => throw new ArgumentException("Weiter geht's nicht")
  };
}

Das war natürlich ein sehr funktionaler Schritt, der die imperative Struktur von switch, die bereits ähnlich in der Sprache C existierte, durch eine moderne funktionale Variante ergänzte. Interessanterweise entschied man sich dagegen, switch zum Bestandteil einer Funktionsdeklaration zu machen, wie das etwa in F# geht.

let calcResult =
    function | 1 -> 2
             | 2 -> 3
             | _ => raise (System.ArgumentException("Weiter geht's nicht"))

Richtig, bei dieser merkwürdigen Syntax gibt es gar keinen sichtbaren Eingabeparameter. Das ist nicht weiter schlimm, denn direkter Zugriff auf den Wert ist nicht nötig, und auch tatsächlich nicht möglich – offenbar wollte Microsoft die C#-Programmierer nicht mit solchen extremen Formen verwirren. Immerhin ist die Form in C# ansonsten durchaus angenehm zu verwenden und sehr nah an dem, was sich in F# machen lässt.

Wichtige neue Mustertypen seit C# 8.0

Zusätzlich zum switch-Ausdruck gab es in C# 8.0 außerdem neue Muster, und die in dieser C#-Version eingeführten Neuheiten waren womöglich die bedeutsamsten in der Familie von Musterfeatures bis zum heutigen Tag. Zunächst gab es das Property-Pattern, mit dem sich einzelne Eigenschaftswerte prüfen lassen:

bool IsJune(DateTime dt) => dt is { Month: 6 };

Das Schlüsselwort is ergibt in diesem Zusammenhang nicht immer den meisten Sinn – besser wäre (englisch) „matches“ oder etwas Ähnliches. An dieser Stelle greife ich einmal etwas voraus: In C# 9.0 wurden zusätzlich relationale und logische Operatoren verfügbar, die mit anderen Patterns kombiniert werden können. So könnt ihr nun diese Varianten verwenden:

bool IsPastMidYear(DateTime dt) => dt is { Month: >= 7 };
bool IsThisYearOrLast(DateTime dt) => dt is { Year: 2022 or 2021 };
bool IsSummer(DateTime dt) => dt is
  { Month: 7 or 8 } or
  { Month: 6, Day: >= 21 } or 
  { Month: 9, Day: < 21 };

Zur Zeit der Einführung von C# 8.0 wurden in der Liste von Neuheiten noch separat das Tuple Pattern und das Positional Pattern aufgeführt. Mittlerweile listet die Dokumentation allerdings nur noch das Positional Pattern, vermutlich weil man bei Microsoft gemerkt hat, dass die beiden Varianten ähnlich sind bzw. eng zusammengehören. Das Positional Pattern lässt sich nämlich auf jeden Typ anwenden, der sich mit Hilfe einer Deconstruct-Methode in seine Einzelteile zerlegen lässt – und das Tupel (also der eingebaute Typ Tuple, der Klarheit halber) ist genau so ein Typ. Ihr wisst sicherlich, dass der Typ dekonstruierbar ist, etwa so:

(string, string) personInfo = ("Oli", "Sturm");
var (firstName, lastName) = personInfo;
// firstName ist jetzt "Oli" und lastName ist "Sturm"

Das ist also dasselbe Verhalten, das sich für einen beliebigen Typ mit der Implementation einer Deconstruct-Methode auch erreichen lässt. Allerdings lässt sich der Begriff „Tuple Pattern“ noch immer verwenden, wenn es um einen Einsatzfall geht, wo für das Muster zunächst eine Struktur hergestellt werden muss. Im Beispiel in Listing 1 werden zwei separate Parameter auf diese Weise für ein Muster zusammengefasst.

static OrderValue OrderValueCategory(
int itemCount, double itemPrice) => (itemCount, itemPrice) switch
{
  ( < 0, _) => throw new ArgumentException("Positive itemCounts please!"),
  (_, < 0) => throw new ArgumentException("Positive itemPrices please!"),
  ( >= 100, _) => OrderValue.ValuableDueToHighCount,
  (_, >= 1000) => OrderValue.ValuableDueToHighItemPrice,
  var (c, p) when c * p > 1000 => OrderValue.ValuableDueToHighTotal,
  _ => OrderValue.NotValuable
};

In dieser Syntax lässt sich die Quelle für den Match als Tupel interpretieren, daher passt hier der Name. Im Beispiel sind noch einige andere Details zu sehen, die ich bisher nicht angesprochen habe. Der Unterstrich dient als Platzhalter für einen Teil des Resultats, der im Muster irrelevant ist (Microsoft nennt das „Discard Pattern“). Im vorletzten Muster ist zu sehen, wie ein Muster auch dazu verwendet werden kann, Werte für die weitere Nutzung zu extrahieren. In diesem Fall wurde die etwas verwirrende Form var (c, p) verwendet, die gleichbedeutend mit (var c, var p) ist – die Wahl ist dem Programmierer überlassen. Auf diese Methode der Werteextraktion komme ich später noch einmal zurück.

In allgemeinerer Form könnt ihr das Positional Pattern auch für komplexere Typen anwenden, die bereits existieren. Im folgenden Beispiel gibt es zur Kapselung der Werte itemCount und itemPrice einen eigenen Typ Order und dieser wird im Match direkt verwendet – die Logik der Muster bleibt allerdings exakt identisch, da der Typ mit seiner Deconstruct-Methode die Werte in derselben Struktur extern verfügbar macht, die das Tupel im ersten Beispiel auch hatte (Listing 2).

class Order {
  public int ItemCount { get; set; } public double ItemPrice { get; set; }
  public void Deconstruct(out int itemCount, out double itemPrice) {
    itemCount = ItemCount; 
    itemPrice = ItemPrice;
  }
}
 
static OrderValue OrderValueCategory(Order o) => 
  o switch 
{
  ( < 0, _) => throw new ArgumentException("Positive itemCounts please!"),
  (_, < 0) => throw new ArgumentException("Positive itemPrices please!"),
  ( >= 100, _) => OrderValue.ValuableDueToHighCount,
  (_, >= 1000) => OrderValue.ValuableDueToHighItemPrice,
  var (c, p) when c * p > 1000 => OrderValue.ValuableDueToHighTotal,
  _ => OrderValue.NotValuable
};

Muster müssen vollständig sein

Es ist übrigens auch möglich, beim Positional Pattern die Namen der Parameter anzugeben. So könnte ich etwa schreiben:

...
(itemCount: >= 100, itemPrice: _) => OrderValue.ValuableDueToHighCount,
...

Diese Syntax halte ich persönlich allerdings nur aus einem Grund für erwähnenswert: Sie funktioniert nicht so, wie mancher Programmierer instinktiv annimmt. Viele vermuten nämlich, dass mit der Verwendung eines oder mehrerer Namen nicht das gesamte Muster angegeben werden muss, also im Beispiel etwa der itemPrice weggelassen werden darf. Das stimmt allerdings nicht, der Compiler weist dann auf das fehlende Element hin. So dient also die Angabe der Namen höchstens der Illustration – das sei euch überlassen, aber für mich persönlich ist so etwas Platzverschwendung und Mehrarbeit bei der Wartung. Dabei meine ich wohlgemerkt nicht die Tatsache, dass das Muster vollständig sein muss – das finde ich gut!

Da ich selbst Pattern Matching vor allem in funktionalen Programmiersprachen seit vielen Jahren einsetze, fällt mir beim Lesen von Dokumentation und Beispielen auf den Microsoft-Webseiten vor allem auf, dass diese sich grundsätzlich im Bereich geschäftlicher Objekte und Daten bewegen. Das finde ich interessant, da ich in der funktionalen Programmierung die Verwendung von Pattern Matching als algorithmische Struktur gewöhnt bin. Als Beispiel hier eine mögliche F#-Implementation der Standardfunktion fold (analog zu reduce in anderen Sprachen, oder Aggregate in LINQ) mit Hilfe von Pattern Matching:

let rec fold f s = function
  | [] -> s
  | x :: xs -> fold f (f s x) xs

Wenn ihr versucht, über die Logik dieses Codes nachzudenken, fällt euch bestimmt schnell auf, was derzeit in C# noch fehlt: Muster für die Handhabung von Listen. Man kann argumentieren, dass diese nicht sinnvoll sind in der Sprache – vielleicht wird sich das ändern, wenn es in Zukunft Discriminated Unions gibt und vielleicht auch bessere Unterstützung für Tail Recursion. Ich möchte hier nicht näher auf das F#-Beispiel eingehen – gern könnt ihr mich kontaktieren, wenn ich damit Interesse geweckt habe.

ZUM NEWSLETTER

Regelmäßig News zur Konferenz und der .NET-Community

Auch in C# lassen sich Muster algorithmisch nutzen, wenn es nicht gerade um Listenverarbeitung geht. Ich habe ein komplexes Beispiel zu diesem Thema, das aus meiner Arbeit an einem Buch zu funktionaler Programmierung in C# entstand. Nachdem ich in 2007 begonnen hatte, auf Veranstaltungen über dieses Thema vorzutragen, arbeitete ich an einer Sammlung hilfreicher Funktionen, die ich FCSlib nannte [2]. Mein Buch zum Thema erschien 2011 und die Codebasis enthielt eine Implementation des rot-schwarzen Binärbaums in funktionaler Art, die von Chris Okasaki in seinem Buch „Purely Functional Data Structures“ publiziert worden war. Der originale Code lag in ML und Haskell vor und ich hatte ihn für C# umgeschrieben.

Funktionales Balancieren mit Haskell

Besonders interessant ist im Zusammenhang mit Pattern Matching die Funktion balance. Diese stellt im Binärbaum das Gleichgewicht wieder her – wie das genau geschieht, sei eurem eigenen Studium eines schwarz-roten Baums überlassen. Ich empfehle sehr, Chris Okasakis Buch zu lesen. Seine Implementation der Funktion in Haskell basiert auf diesen beiden Datentypen:

data Color = R | B
data RedBlackSet a = E | T Color (RedBlackSet a) a (RedBlackSet a)

Das sind Discriminated Unions, die es leider bisher in C# nicht gibt. Damit lassen sich sehr einfach Datentypen beschreiben. Die Farbe darf etwa oder B sein – Red oder Black. Der Typ RedBlackSet (mit dem generischen Subtyp a) kann entweder E sein (Empty, also leer), oder ein Wert T (Tree), dem dann eine Farbe zugewiesen wird, sowie ein linker und ein rechter Ast mit einem Wert vom Typ ain der Mitte. Fertig. Beeindruckend, oder? Aber das nur am Rande.

Die Funktion balance selbst besteht aus fünf überladenen Teilen. Hier sind zwei davon:

balance B (T R (T R a x b) y c) z d = T R (T B a x b) y (T B c z d) 
balance B (T R a x (T R b y c)) z d = T R (T B a x b) y (T B c z d)
...

In Haskell ist es üblich, dass unterschiedliche Teile eines Mustervergleichs einfach als Varianten einer Funktion geschrieben sind. Das geschieht hier, daher unterscheiden sich die beiden Funktionsteile anscheinend in der Signatur, aber nicht im Namen. Der Teil zwischen balance und dem Gleichheitszeichen stellt ein Pattern dar. Es wird „gematcht“, wenn der Baum B (also schwarz) ist, und dann wird der linke Teilbaum mit dem Ausdruck in Klammern verglichen – der muss T sein (muss also etwas enthalten, nicht E für den leeren Baum), und außerdem R für rot, wiederum einen Unterbaum R haben usw., und falls dieses Pattern zutreffen sollte, wird letztlich auf der rechten Seite der Zuweisung ein neuer Baum mit einer etwas veränderten Struktur erzeugt.

SIE LIEBEN C#?

Entdecken Sie die BASTA! Tracks

Wie gesagt, auf den Algorithmus zur Ausbalancierung des Baums kommt es hier nicht an. Die Patterns sind allerdings interessant, da geschehen mehrere Dinge:

  1. Die Struktur, die an die Funktion übergeben wurde, wird mit der im Pattern verglichen.

  2. Dies geschieht auch mit untergeordneten Teilstrukturen, nicht nur auf der obersten Ebene.

  3. Wenn ein Muster passt, werden Informationen für die weitere Verarbeitung aus der Datenstruktur entnommen – so ergeben sich die Werte axbycz und d, die auf der rechten Seite zur Erzeugung der neuen Struktur verwendet werden.

In der C#-Syntax von vor zehn Jahren ließ sich dieser Vorgang nicht syntaktisch elegant abbilden. Mein Code von damals war also extrem lang und ausführlich – in Listing 3 findet sich ein kleiner Auszug zur Illustration.

private static RedBlackTree<T> Balance(Color nodeColor, RedBlackTree<T> left, T value, RedBlackTree<T> right) {
  if (nodeColor == RedBlackTree<T>.Color.Black) {
    if (!(left.IsEmpty) &&
      left.NodeColor == RedBlackTree<T>.Color.Red &&
      !(left.Left.IsEmpty) &&
      left.Left.NodeColor == RedBlackTree<T>.Color.Red)
      return new RedBlackTree<T>(Color.Red, 
        new RedBlackTree<T>(Color.Black, 
          left.Left.Left, left.Left.Value, left.Left.Right),
        left.Value, 
        new RedBlackTree<T>(Color.Black, 
          left.Right, value, right));
...

Diese Zeilen entsprechen übrigens der ersten Zeile aus dem Haskell-Beispiel, falls ihr zum Verständnis noch einmal zurückblicken möchtet. Nun kommt aber die richtig tolle Neuigkeit: Mit der Unterstützung für Pattern Matching in heutigen C#-Versionen lässt sich die gesamte Funktion wie in Listing 4 schreiben. Die verwendeten Strukturen habe ich alle zuvor bereits angesprochen – das Tuple Pattern sowie das Positional Pattern, basierend auf einer Deconstruct-Methode im Typ RedBlackTree<T>.

private static RedBlackTree<T> Balance(Color nodeColor, RedBlackTree<T> left, T? value, RedBlackTree<T> right) { 
  const Color R = Color.Red;
  const Color B = Color.Black;
  RedBlackTree<T> TT(Color c, RedBlackTree<T> l, T? v, RedBlackTree<T> r) => new(c, l, v, r);
  return (nodeColor, left, value, right) switch
  {
    (B, (R, (R, var a, var x, var b), var y, var c), var z, var d) => 
      TT(R, TT(B, a, x, b), y, TT(B, c, z, d)),
    (B, (R, var a, var x, (R, var b, var y, var c)), var z, var d) => 
      TT(R, TT(B, a, x, b), y, TT(B, c, z, d)),
    (B, var a, var x, (R, (R, var b, var y, var c), var z, var d)) => 
      TT(R, TT(B, a, x, b), y, TT(B, c, z, d)),
    (B, var a, var x, (R, var b, var y, (R, var c, var z, var d))) => 
      TT(R, TT(B, a, x, b), y, TT(B, c, z, d)),
    (var color, var a, var x, var b) => TT(color, a, x, b)
  };
}

Dazu gibt es mehrere Dinge zu sagen. Erstens, es ist immer noch nicht alles so wie in Haskell. Da gibt es einige Unterschiede. Z. B. beinhaltet das Pattern Matching in C# nicht die Unterscheidung T oder E aus Haskell – enthält der Teilbaum überhaupt etwas oder nicht? Das wäre in C# unverhältnismäßig schwieriger, daher habe ich den Baum einfach mit einem Platzhalter für eine „leere Farbe“ programmiert, sodass und B bereits nur „volle“ Elemente finden. In C# sind außerdem Hilfsdefinitionen nötig, um die Werte R und B bzw. die kurze Notation zur Erzeugung eines neuen Baumes nutzbar zu machen.

Schließlich gibt es noch Kuriositäten im Detail – um etwa R und B für den Match verwenden zu dürfen, müssen diese const deklariert sein. Zusätzlich (oder trotzdem!) müssen aber auch die später verwendbaren Variablen jeweils var vorangestellt haben – warum? Das ist in C# wohl eine Frage der Konsistenz, aber es macht jeden Match-Ausdruck in diesem Beispiel doppelt so lang, wie er sein müsste, und trägt zur Signifikanz des Ausdrucks nichts bei.

ZUM NEWSLETTER

Regelmäßig News zur Konferenz und der .NET-Community

Zweitens und abschließend stellt sich natürlich, wie auch in meinem Buch vor über zehn Jahren, die Frage: Sollte man das so machen? Was würdet ihr von dem Kollegen halten, der solchen Code schreibt und eincheckt? Nun, die Vorgaben dazu sollten natürlich jedem Team überlassen sein, und ich habe keinen Zweifel, dass manche C#-Programmierer eher ungern im Alltag solchen Code lesen würden. Allerdings finde ich auch die umgekehrte Sicht interessant. Der Code im Beispiel spiegelt letztlich einen komplexen Algorithmus wider. Insbesondere stellt er ein direktes Abbild der originalen Variante dar und es lässt sich in dieser Form direkt ein Vergleich anstellen, um etwa zu gewährleisten, dass der ursprüngliche Algorithmus korrekt übertragen worden ist – das wäre in der alten Implementation sehr schwierig gewesen. Sollte ein Original in ähnlichem Zusammenhang einmal geändert oder aktualisiert werden müssen, lässt sich die Änderung entsprechend einfach nach C# übertragen. Fühlt sich ein durchschnittlicher C#-Entwickler angehalten, an diesem Code schnell mal eine verdachtsbasierte Veränderung durchzuführen? Wohl kaum, bzw. hoffentlich nicht! Das ist schließlich gut so – und nebenbei ist das in der alten Form des Codes auch nicht anders. Insofern spricht doch vielleicht gar nichts dagegen, komplexe logische Zusammenhänge in syntaktisch kompakten Formen wie im Beispiel zu schreiben. Vielleicht ist das nicht absolut alltagstauglich, aber wir arbeiten ja auch nicht tagein, tagaus an den Innereien von rot-schwarzen Binärbäumen!

Ihr aktueller Zugang zur .NET- und Microsoft-Welt.
Der BASTA! Newsletter:

Behind the Tracks

.NET Framework & C#
Visual Studio, .NET, Git, C# & mehr

Agile & DevOps
Agile Methoden, wie Scrum oder Kanban und Tools wie Visual Studio, Azure DevOps usw.

Web Development
Alle Wege führen ins Web

Data Access & Storage
Alles rund um´s Thema Data

JavaScript
Leichtegewichtig entwickeln

UI Technology
Alles rund um UI- und UX-Aspekte

Microservices & APIs
Services, die sich über APIs via REST und JavaScript nutzen lassen

Security
Tools und Methoden für sicherere Applikationen

Cloud & Azure
Cloud-basierte & Native Apps