Mai 2019

Band 34, Nummer 5

[C# 8.0]

Musterabgleich in C# 8.0

Von Filip Ekberg | Mai 2019

Im Laufe der Jahre haben wir eine Vielzahl von Funktionen in C# gesehen, die nicht nur die Leistung unseres Codes, sondern vor allem seine Lesbarkeit verbessert haben. Angesichts des rasanten Tempos der Softwareindustrie muss die Sprache sicherlich mit ihrer Anwenderbasis Schritt halten und sich weiterentwickeln. Eine Funktion, die in verschiedenen Programmiersprachen wie Haskell, Swift oder Kotlin weit verbreitet ist, findet manchmal ihren Weg in C#. Eine dieser Funktionen ist Musterabgleich: ein Konzept, das es schon seit langem gibt und auf das viele Entwickler im .NET-Bereich sehnsüchtig gewartet haben.

Ab C# 7.0 haben Entwickler einen Vorgeschmack von der Leistungsfähigkeit des Musterabgleichs bekommen. Wir haben gesehen, wie ein Muster Gestalt annahm, das sich später zu einer äußerst leistungsfähigen und interessanten Ergänzung der Sprache entwickelt hat. So wie sich die Art und Weise, wie wir unsere Software schreiben, durch andere Sprachfunktionen erheblich verändert hat, gehe ich davon aus, dass der Musterabgleich in C# ähnliche Auswirkungen haben wird.

Benötigen wir aber wirklich eine weitere Sprachfunktion? Können wir nicht einfach herkömmliche Ansätze weiterhin verwenden? Selbstverständlich könnten wird das. Obwohl ein Zusatz wie Musterabgleich die Art und Weise definitiv verändern wird, wie viele von uns Code schreiben, könnte die gleiche Frage für andere Sprachfunktionen gestellt werden, die im Laufe der Jahre eingeführt wurden.

Eine Funktion, die die C#-Sprache massiv verändert hat, war die Einführung von LINQ (Language-Integrated Query). Heutzutage wählen Benutzer beim Verarbeiten von Daten den Ansatz aus, der ihnen persönlich gefällt. Einige entscheiden sich für LINQ, um in einigen Fällen weniger ausführlichen Code zu erstellen, während andere traditionelle Schleifen bevorzugen. Ich erwarte eine ähnliche Akzeptanz für den Musterabgleich, da die neue Funktionalität die Arbeitsweise der Entwickler verändern wird, wenn sie sich von ausführlicheren Ansätzen verabschieden. Allerdings gehen die traditionellen Ansätze nicht verloren, da sich viele Entwickler für bewährte und bekannte Lösungen entscheiden werden. Aber die zusätzlichen Sprachfunktionen sollten eine Möglichkeit bieten, C#-Codeprojekte zu ergänzen, anstatt den aktuellen Code als veraltet abzustempeln.

Einführung von Musterabgleich

Wenn Sie jemals Sprachen wie Kotlin oder Swift ausprobiert haben, haben Sie wahrscheinlich Beispiele für Musterabgleich in Aktion gesehen. Er wird sehr häufig von vielen verschiedenen Programmiersprachen auf dem Markt verwendet – vor allem natürlich, um den Code etwas lesbarer zu machen. Was also ist Musterabgleich?

Es ist ziemlich einfach. Sie betrachten eine bestimmte Struktur und identifizieren sie anhand ihres Aussehens und können sie dann sofort nutzen. Wenn Sie eine Tüte Obst bekommen, erkennen Sie sofort den Unterschied zwischen Äpfeln und Birnen. Auch dann, wenn beide Obstsorten grün sind. Gleichzeitig können Sie in Ihre Obsttüte schauen und identifizieren, welche Früchte grün sind, da wir wissen, dass alle Früchte eine Farbe besitzen.

Die Unterscheidung zwischen dem Typ der Frucht und einem Attribut der Frucht ist genau das, worum es beim Musterabgleich geht. Entwickler haben bei dieser Form der Identifizierung verschiedene Möglichkeiten, sich auszudrücken.

Traditionell konnte ich dies alles mit einer einfachen Bedingung überprüfen. Aber was geschieht, wenn ich explizit den Apfel verwenden muss? Dies würde auf eine Situation hinauslaufen, in der ich zuerst den Typ überprüfen, ein Attribut verwenden und dann in einen Apfel umwandeln muss. Dieser Code wird schnell ein wenig unübersichtlich und ist ehrlich gesagt fehleranfällig.

Hier ist ein Beispiel, in dem ich den spezifischen Typ der Frucht als Apfel bestätige. Ich wende eine Attributeinschränkung an, die ich umzuwandeln muss, um sie verwenden zu können:

if(fruit.GetType() == typeof(Apple) && fruit.Color == Color.Green)
{
  var apple = fruit as Apple;
}

Ein weiterer Ansatz, den ich wählen könnte, ist die Verwendung des Schlüsselwortes „is“. Dies verleiht mir etwas mehr Flexibilität. Im Gegensatz zum vorherigen Beispiel passt das Schlüsselwort „is“ auch bei abgeleiteten Äpfeln:

if(fruit is Apple)
{
  MakeApplePieFrom(fruit as Apple);
}

Wenn die Frucht in diesem Fall ein abgeleiteter Typ von Apfel ist, kann ich daraus einen Apfelkuchen backen. Im früheren Beispiel müsste es sich dagegen um einen ganz bestimmten Typ von Apfel handeln.

Glücklicherweise ist gibt es eine bessere Möglichkeit. Wie bereits erwähnt, ermöglichen Sprachen wie Swift und Kotlin die Verwendung von Musterabgleich. C# 7.0 führte eine schlanke Version des Musterabgleichs ein, die hilfreich sein kann, obwohl ihr viele der nützlichen Funktionen fehlen, die in anderen Sprachen vorhanden sind. Sie können ein Refactoring des vorherigen Ausdrucks in den folgenden C# 7.0-Code vornehmen, sodass Sie einen Schalter verwenden können, um Ihre verschiedenen Muster abzugleichen. Diese Vorgehensweise ist nicht perfekt, verbessert aber das, was bisher verfügbar war. Dies ist der Code:

switch(fruit)
{
  case Apple apple:
    MakeApplePieFrom(apple);
    break;
  default:
    break;
}

Einige Dinge hier sind interessant. Erstens: Beachten Sie, dass ich keinen einzigen Typ habe, der irgendwo in diesem Code umgewandelt wird. Außerdem kann ich den Apfel verwenden, für den gerade im case-Kontext eine Übereinstimmung gefunden wurde. Genau wie beim Schlüsselwort „is“ gilt dieser Abgleich auch bei abgeleiteten Äpfeln.

Dieser C# 7.0-Code ist auch besser lesbar und viel einfacher zu diskutieren als ähnlicher Code in C# 6.0. Der Code besagt einfach nur: „Basierend auf der Tatsache, dass Obst ein Apfel ist, möchte ich diesen Apfel verwenden“. Jede case-Angabe kann einen Typ abgleichen, der ähnliche Eigenschaften aufweist, d.h. er erbt beispielsweise von derselben Klasse oder implementiert die gleiche Schnittstelle. In diesem Fall sind ein Apfel, eine Birne und eine Banane alles Früchte.

Was fehlt, ist eine Möglichkeit, die grünen Äpfel herauszufiltern. Kennen Sie Ausnahmefilter? Das ist eine in C# 6.0 eingeführte Funktion, mit der Sie bestimmte Ausnahmen nur dann abfangen, wenn eine bestimmte Bedingung erfüllt ist. Mit dieser Funktion wurde das Schlüsselwort „when“ eingeführt, das auch beim Musterabgleich anwendbar ist. Ich kann den Apfel mithilfe von Musterabgleich zuordnen und „case“ nur eingeben, wenn die Bedingung erfüllt ist. Abbildung 1 zeigt dies.

Abbildung 1: Anwenden eines Filters mithilfe des Schlüsselworts „When“

Fruit fruit = new Apple { Color = Color.Green };
switch (fruit)
{
  case Apple apple when apple.Color == Color.Green:
    MakeApplePieFrom(apple);
    break;
  case Apple apple when apple.Color == Color.Brown:
    ThrowAway(apple);
    break;
  case Apple apple:
    Eat(apple);
    break;
  case Orange orange:
    orange.Peel();
    break;
}

Wie Abbildung1 zeigt, ist die Reihenfolge von Bedeutung. Ich suche zunächst einen Apfel mit der Farbe Grün, da mir diese Eigenschaft am wichtigsten ist. Wenn es eine andere Farbe gibt, sagen wir Braun, würde das darauf hindeuten, dass mein Apfel verfault ist. Dann möchte ich ihn wegwerfen. Was alle anderen Äpfel betrifft, so will ich sie nicht im Kuchen verwenden, also werde ich sie einfach essen. Das endgültige Apfelmuster ist vom Typ „erfasst alle“ für alle Äpfel, die weder eine grüne noch eine braune Farbe haben.

Sie sehen im Code auch Folgendes: Wenn ich eine Orange bekomme, schäle ich sie einfach. Ich bin nicht darauf beschränkt, einen bestimmten Typ zu behandeln. Solange die Typen alle von „Fruit“ (Obst) erben, ist alles in Ordnung.

Alles andere funktioniert wie der normale Schalter, den Sie seit C# 1.0 verwenden. Dieses Beispiel wurde vollständig in C# 7.0 geschrieben, sodass die Frage lautet: Gibt es Raum für Verbesserungen? Ich denke schon. Der Code ist noch ein wenig auf der ausdrucksstarken Seite, und er könnte durch eine Verbesserung der Ausdrucksweise von Mustern lesbarer gemacht werden. Außerdem wäre es hilfreich, andere Möglichkeiten zu haben, um Einschränkungen hinsichtlich des „Aussehens“ meiner Daten auszudrücken. Schauen wir uns nun C# 8.0 an, und werfen wir einen Blick auf die Änderungen, die eingeführt wurden, um unser Leben einfacher zu gestalten.

Die Entwicklung des Musterabgleichs in C# 8.0

Die neueste Version von C#, die sich derzeit in der Vorschau befindet, enthält einige wichtige Verbesserungen für den Musterabgleich. Um C# 8.0 zu testen, müssen Sie Visual Studio 2019 Vorschau verwenden oder die Vorschausprachfunktionen in Visual Studio 2019 aktivieren. Die allgemeine Verfügbarkeit von C# 8.0 wird später in diesem Jahr zur gleichen Zeit wie die Veröffentlichung von .NET Core 3.0 erwartet. Wie können wir neue Wege finden, um eine Einschränkung für die Eigenschaften eines Typs auszudrücken? Wie können wir den Ausdruck von Blockmustern intuitiver und lesbarer gestalten? In C# 8.0 macht die Sprache einen weiteren Schritt nach vorn, um eine Möglichkeit für das Arbeiten mit Mustern einzuführen, die den Entwicklern, die in Sprachen wie Kotlin gearbeitet haben, sehr vertraut sein sollte. Dies alles sind wunderbare Ergänzungen, die den Code besser lesbar und verwaltbar machen.

Erstens haben wir jetzt die Möglichkeit, einen so genannten Schalterausdruck anstelle der traditionellen Schalteranweisung einzusetzen, die Entwickler seit C# 1.0 verwenden. Hier ist ein Beispiel für einen Schalterausdruck in C# 8.0:

var whatFruit = fruit switch {
  Apple _ => "This is an apple",
  _ => "This is not an apple"
};

Wie Sie sehen können, verwende ich einfach ein Muster und einen Ausdruck, anstatt für jede unterschiedliche Übereinstimmung „case“ und „break“ angeben zu müssen. Wenn ich eine Übereinstimmung mit einem Stück Obst suche, bedeutet der Unterstrich (_), dass mir das tatsächlichen Obst gleichgültig ist, mit dem es eine Übereinstimmung gibt. Tatsächlich muss es sich nicht um einen initialisierten Obsttyp handeln. Der Unterstrich gilt auch für eine Übereinstimmung mit NULL. Betrachten Sie dies einfach als eine Übereinstimmung für den jeweiligen Typ. Als ich diesen Apfel gefunden habe, habe ich eine Zeichenfolge mit einem Ausdruck zurückgegeben (ähnlich wie die Member mit Ausdruckstext, die in C# 6.0 eingeführt wurden).

Hier geht es um mehr als nur um die Einsparung von Zeichen. Hier tun sich ungeahnte Möglichkeiten auf. Beispielsweise könnte ich jetzt einen Member mit Ausdruckstext einführen, der einen dieser Schalterausdrücke beinhaltet, der ebenfalls die Leistungsfähigkeit des Musterabgleichs nutzt:

public Fruit Fruit { get; set; }
public string WhatFruit => Fruit switch
{
  Apple _ => "This is an apple",
  _ => "This is not an apple"
};

Dies kann sehr interessant und mächtig sein. Der folgende Code zeigt, wie Sie diesen Musterabgleich auf traditionelle Weise ausführen würden. Werfen Sie einen Blick darauf, und entscheiden Sie, welche Vorgehensweise Sie vorziehen:

public string WhatFruit
{
  get
  {
    if(Fruit is Apple)
    {
      return "This is an apple";
    }
    return "This is not an apple";
  }
}

Natürlich ist dies ein sehr einfaches Szenario. Stellen Sie sich vor, ich führe Einschränkungen und mehrere Typen für den Abgleich ein und verwenden den umgewandelten Typ dann innerhalb des Bedingungskontexts. Schon von der Idee begeistert? Das dachte ich mir!

Dies ist zwar eine willkommene Ergänzung der Sprache, aber bitte widerstehen Sie der Versuchung, Schalterausdrücke für jede if/else if/else if/else-Bedingung zu verwenden. Ein Beispiel dafür, wie Sie nicht vorgehen sollten, finden Sie im folgenden Code:

bool? visible = false;
var visibility = visible switch
{
  true => "Visible",
  false => "Hidden",
  null, _ => "Blink"
};

Dieser Code gibt an, dass Sie vier „cases“ für einen Nullwerte zulassenden booleschen Wert haben könnten, was natürlich nicht möglich ist. Achten Sie einfach nur darauf, wie Sie Schalterausdrücke verwenden, und missbrauchen Sie die Syntax nicht, genau wie bei jeder anderen Sprachfunktion.

Ich habe bereits die Tatsache erwähnt, dass Schalterausdrücke die Menge an Code verringern, den Sie schreiben müssen, und diesen Code lesbarer machen können. Dies gilt auch beim Hinzufügen von Einschränkungen zu Ihren Typen. Die Änderungen am Musterabgleich in C# 8.0 fallen besonders auf, wenn Sie sich die Kombination aus Tupeln, Dekonstruktion und so genannten rekursiven Mustern ansehen.

Auszudrücken von Mustern

Ein rekursives Muster liegt vor, wenn die Ausgabe eines Musterabgleichsausdrucks zur Eingabe eines anderen Musterabgleichsausdrucks wird. Dies bedeutet, das Objekt zu dekonstruieren und zu untersuchen, wie der Typ, seine Eigenschaften, deren Typen usw. alle ausgedrückt werden, und dann die Übereinstimmung auf alle diese Elemente anzuwenden. Das klingt kompliziert, ist es aber nicht wirklich.

Sehen wir uns einen anderen Typ und seine Struktur an. In Abbildung2 sehen Sie ein Rechteck, das von „Shape“ erbt. „Shape“ ist nur eine abstrakte Klasse, die den Eigenschaftenpunkt einführt, eine Möglichkeit für mich, die Form auf eine Oberfläche zu bekommen, damit ich weiß, wo sie platziert werden soll.

Abbildung 2: Beispiel für eine Dekonstruktion

abstract class Shape
{
  public Point Point { get; set; }
}
class Rectangle : Shape
{
  public int Width { get; set; }
  public int Height { get; set; }
  public void Deconstruct(out int width, out int height, out Point point)
  {
    width = Width;
    height = Height;
    point = Point;
  }
}

Sie fragen sich vielleicht, worum es bei der Deconstruct-Methode in Abbildung 2 geht. Sie erlaubt mir, die Werte einer Instanz in neue Variablen außerhalb der Klasse zu „extrahieren“. Sie wird häufig zusammen mit Musterabgleich und Tupeln verwendet, wie Sie gleich feststellen werden.

Also habe ich im Wesentlichen drei neue Möglichkeiten, ein Muster in C# 8.0 auszudrücken, die alle für einen spezifischen Anwendungsfall gelten. Dies sind:

  • Positionsmuster
  • Eigenschaftenmuster
  • Tupelmuster

Keine Sorge, wenn Sie die normale Schaltersyntax bevorzugen, können Sie auch damit diese Verbesserungen beim Musterabgleich nutzen! Diese Änderungen und Ergänzungen der Sprache in Bezug auf den Musterabgleich werden allgemein als rekursive Muster bezeichnet.

Das Positionsmuster nutzt die Dekonstruktionsmethode, die für Ihre Klasse verfügbar ist. Sie können ein Muster ausdrücken, das mit bestimmten Werten abgleicht, die Sie aus der Dekonstruktion erhalten. Angesichts der Tatsache, dass Sie eine Möglichkeit haben, das Rechteck zu dekonstruieren, können Sie ein Muster ausdrücken, das die Position der Ausgabe nutzt, wie Abbildung 3 zeigt.

Abbildung 3: Positionsmuster

Shape shape = new Rectangle
{
  Width = 100,
  Height = 100,
  Point = new Point { X = 0, Y = 100 }
};
var result = shape switch
{
  Rectangle (100, 100, null) => "Found 100x100 rectangle without a point",
  Rectangle (100, 100, _) => "Found 100x100 rectangle",
  _ => "Different, or null shape"
};

Gleichen wir zunächst den Typ der Form ab. In diesem Fall möchte ich ihn nur für ein Rechteck abgleichen. Das zweite angewendete Muster verwendet, wenn es mit einem Rechteck abgeglichen ist, die Dekonstruktionsmethode zusammen mit der Tupelsyntax, um auszudrücken, welche Werte ich für jede bestimmte Position benötige.

Ich kann angeben, dass ich explizit möchte, dass der Punkt NULL sein soll, oder ich kann den Unterstrich verwenden, um auszudrücken, dass es mir einfach gleichgültig ist. Denken Sie daran, dass die Reihenfolge hier sehr wichtig ist. Wenn wir die Version an erster Stelle platzieren würden, bei der es uns gleichgültig ist, würde sie immer mit diesem Muster übereinstimmen, selbst wenn das Rechteck einen Punkt aufweist oder nicht. Dies wird als Positionsmuster bezeichnet.

Dies ist sehr praktisch, wenn eine Dekonstruktion zur Verfügung steht. Wenn die Dekonstruktion jedoch viele Werte ausgibt, wird es recht ausführlich. Hier kommt das Eigenschaftenmuster ins Spiel. Bisher habe ich den Abgleich für verschiedene Typen ausgeführt, aber einige Szenarien erfordern den Abgleich mit anderen Dingen (z.B. mit einem Zustand) oder nur die Untersuchung der verschiedenen Eigenschaftswerte oder deren Fehlen.

Wie der folgende Code beschreibt, ist es mir gleichgültig, welchen Typ ich erhalte, solange er mit einem Typ übereinstimmt, der einen Punkt enthält, wobei dieser Punkt in der Y-Eigenschaft einen Wert von 100 aufweist:

shape switch
{
  { Point: { Y : 100 } } => "Y is 100",
  { Point: null } => "Point not initialized",
};

Beachten Sie, dass der Code tatsächlich keine Fälle verarbeitet, in denen die Form NULL oder der Punkt initialisiert ist, aber einen anderen Y-Wert als 100 aufweist. In diesen Fällen löst dieser Code eine Ausnahme aus. Dies kann durch die Einführung des Standardfalls mithilfe des Unterstrichs gelöst werden.

Ich könnte auch verlangen, dass der Punkt nicht initialisiert ist und nur diese nicht initialisierten Szenarien verarbeiten. Dies ist sehr viel weniger ausführlich als die Verwendung der Positionsmuster und funktioniert sehr gut in Situationen, in denen Sie dem Typ, den Sie abgleichen, keine Dekonstruktionsmethode hinzufügen können.

Schließlich verfüge ich über das Tupelmuster, das das Positionsmuster nutzt und es mir ermöglicht, ein Tupel zu erstellen, für das ich meinen Abgleich ausführen kann. Ich kann dies mit einem Szenario veranschaulichen, in dem ich verschiedene Zustände wie das Öffnen, Schließen und Abschließen einer Tür untersuche (siehe Abbildung 4). Eine bestimmte Situation kann abhängig vom aktuellen Zustand der Tür, dem Vorgang, den ich ausführen möchte, und dem Schlüssel, den ich ggf. besitze, auftreten. Dieses Beispiel für die Verwendung des Tupelmusters zur Einführung eines Zustandsautomaten wird häufig von Mads Torgersen (C#-Design Lead) verwendet. Lesen Sie Torgersens Beitrag „Do More with Patterns in C# 8.0“ unter bit.ly/2O2SDqo.

Abbildung 4: Tupelmuster

var newState = (state, operation, key.IsValid) switch
{
  (State.Opened, Operation.Close, _)      => State.Closed,
  (State.Opened, Operation.Open, _)       => throw new Exception(
    "Can't open an opened door"),
  (State.Opened, Operation.Lock, true)    => State.Locked,
  (State.Locked, Operation.Open, true)    => State.Opened,
  (State.Closed, Operation.Open, false)   => State.Locked,
  (State.Closed, Operation.Lock, true)    => State.Locked,
  (State.Closed, Operation.Close, _)      => throw new Exception(
    "Can't close a closed door"),
  _ => state
};

Der Code in Abbildung 4 erstellt zunächst ein neues Tupel, das den aktuellen Zustand, den gewünschten Vorgang und eine boolesche Überprüfung enthält, ob der Benutzer einen gültigen Schlüssel besitzt. Es handelt sich um ein sehr einfaches Szenario.

Basierend auf diesen unterschiedlichen Werten kann ich verschiedene Situationen abgleichen, indem ich weitere Tupel zusammen mit einem Positionsmuster erstelle. Dies ist das Tupelmuster. Wenn ich versuche, eine Tür zu öffnen, die geschlossen, aber nicht verschlossen ist, führt das zu einem neuen Zustand, der mich informiert, dass die Tür jetzt geöffnet ist. Wenn die Tür verschlossen ist und ich versuche, sie mit einem nicht passenden (ungültigen) Schlüssel zu öffnen, bleibt die Tür verschlossen. Wenn ich versuche, eine Tür zu öffnen, die bereits geöffnet ist, erhalte ich eine Ausnahme. Sie verstehen, was ich meine. Dies ist eine sehr flexible und interessante Art und Weise, mit einer Situation umzugehen, die vorher sehr ausführlichen Code erforderte, der viel schlechter lesbar war.

Abschließende Worte

Die Verbesserungen beim Musterabgleich in C# 8.0 werden zusammen mit dem Schalterausdruck die Art und Weise definitiv verändern, wie Entwickler Anwendungen schreiben. C# ist fast zwei Jahrzehnte alt und hat sich weiterentwickelt, um widerzuspiegeln, wie Anwendungen erstellt werden. Musterabgleich ist einfach der neueste Ausdruck dieses Entwicklungstrends.

Für Entwickler ist es ratsam, vorsichtig zu sein und diese neuen Prinzipien und Muster nicht überzustrapazieren. Machen Sie sich Gedanken über den Code, den Sie schreiben, und stellen Sie sicher, dass er lesbar, verständlich und verwaltbar ist. Das ist alles, was Entwickler verlangen, und ich rechne damit, dass diese Änderungen an der Sprache dazu beitragen werden, das Signal-Rausch-Verhältnis des von Ihnen generierten Codes zu verbessern.


Filip Ekbergist Referent, Pluralsight-Autor, Berater und Autor von „C# Smorgasbord“ (2012). Ekberg hat unter anderem in Sydney und Göteborg gearbeitet und verfügt über mehr als ein Jahrzehnt Erfahrung mit C#. Sie erreichen ihn über Twitter: @fekberg oder über filip@ekberg.dev.

Unser Dank gilt dem folgenden technischen Experten bei Microsoft für die Durchsicht dieses Artikels: Bill Wagner


Diesen Artikel im MSDN Magazine-Forum diskutieren