Zum Inhalt
wannacode

record, switch und Muster

Kurz gesagt: Ein Record ist ein Datentyp, für den C# den Konstruktor, die Gleichheit nach Werten und ToString selbst schreibt, und ein switch mit Mustern wählt nach Typ und Properties.

Problem

Der Platz ist belegt, aber das Dictionary findet ihn nicht

Reservierungen im Kino: Der Schlüssel ist der Platz (Reihe und Nummer), der Wert ist der Name der Person.

Seat
class Seat
{
private int row;
private int number;
public Seat(int row, int number)
{
this.row = row;
this.number = number;
}
}
Dictionary<Seat, string> reservations = new Dictionary<Seat, string>();
reservations.Add(new Seat(5, 12), "Maria");
Console.WriteLine(reservations.GetValueOrDefault(new Seat(5, 12), "frei"));
Console.WriteLine(reservations.ContainsKey(new Seat(5, 12)));
Ausgabe
frei
False

Maria sitzt in Reihe 5, Platz 12, aber GetValueOrDefault(key, fallback), das den Wert liefert oder den Ersatzwert, wenn der Schlüssel fehlt, sagt „frei“. Warum, weißt du noch aus der Lektion über Hashcodes: new Seat(5, 12) zweimal geschrieben sind zwei verschiedene Objekte, und Seat sagt nicht, wann sie gleich sind. Die Lösung sind Equals und GetHashCode, dazu ToString für die Ausgabe und Properties zum Lesen. Rund 30 Zeilen für eine Klasse, die nur zwei Zahlen trägt.

Genauer, für Neugierige

Der Indexer reservations[...] mit einem Schlüssel, den es nicht gibt, liefert keinen Ersatzwert, sondern wirft eine Exception:

try
{
Console.WriteLine(reservations[new Seat(5, 12)]);
}
catch (KeyNotFoundException e)
{
Console.WriteLine("KeyNotFoundException: " + e.Message);
}
Ausgabe
KeyNotFoundException: The given key 'Seat' was not present in the dictionary.

Die Idee

record: eine Klasse für Daten

Seit C# 9 braucht dasselbe eine Zeile:
record Seat(int Row, int Number);
Dictionary<Seat, string> reservations = new Dictionary<Seat, string>();
reservations.Add(new Seat(5, 12), "Maria");
Console.WriteLine(reservations.GetValueOrDefault(new Seat(5, 12), "frei"));
Console.WriteLine(reservations.ContainsKey(new Seat(5, 12)));
Seat seat = new Seat(5, 12);
Console.WriteLine(seat);
Console.WriteLine("Reihe " + seat.Row + ", Platz " + seat.Number);
Ausgabe
Maria
True
Seat { Row = 5, Number = 12 }
Reihe 5, Platz 12

Ein record ist eine Klasse, deren Aufgabe es ist, Daten zu tragen. In den Klammern stehen seine Positionsparameter, und C# schreibt für dich:

  • eine Property für jeden Parameter, mit demselben Namen: Row und Number. Sie wird nur beim Erzeugen gesetzt;
  • einen Konstruktor mit allen Parametern;
  • Equals und GetHashCode, die die Properties vergleichen. Deshalb findet das Dictionary Maria jetzt. Auch die Operatoren == und != vergleichen die Properties;
  • ToString in der Form Seat { Row = 5, Number = 12 }.

Ein Objekt eines record ändert sich nach dem Erzeugen nicht. Ein neues Objekt mit einer Änderung liefert with (siehe „Genauer, für Neugierige“).

Dictionary<Seat, string> reservations = new Dictionary<Seat, string>();
reservations.Add(new Seat(5, 12), "Maria");
Console.WriteLine(reservations.GetValueOrDefault(new Seat(5, 12), "frei"));
Console.WriteLine(reservations.ContainsKey(new Seat(5, 12)));
Seat seat = new Seat(5, 12);
Console.WriteLine(seat);
Console.WriteLine("Reihe " + seat.Row + ", Platz " + seat.Number);
Schritt für Schritt, in Worten
  1. Ein leeres Dictionary: Der Schlüssel ist ein Kinoplatz, der Wert ist ein Name.
  2. Add legt den Schlüssel Seat(5, 12) mit dem Wert „Maria“ an.
  3. GetValueOrDefault bekommt ein neues Seat-Objekt. Das ist ein anderes Objekt im Speicher, nicht der Schlüssel aus Add.
  4. Der GetHashCode eines Records wird aus den Properties berechnet, deshalb ist er gleich: Die Suche geht an dieselbe Stelle.
  5. Equals eines Records vergleicht Property für Property: Reihe 5 und 5, Platz 12 und 12. Sie sind gleich, und GetValueOrDefault gibt „Maria“ zurück.
  6. ContainsKey macht dasselbe mit einem weiteren neuen Seat-Objekt: True.
  7. Auch ToString und die Properties Row und Number schreibt der Record selbst.
Genauer, für Neugierige
  • with macht eine Kopie des Objekts und ändert nur die genannten Properties. Das alte Objekt bleibt, wie es ist. Ein positionaler record lässt sich auch in einzelne Variablen zerlegen:
Seat seat = new Seat(5, 12);
Seat next = seat with { Number = 13 };
Console.WriteLine(next);
Console.WriteLine(seat);
var (row, number) = next;
Console.WriteLine($"Reihe {row}, Platz {number}");
Ausgabe
Seat { Row = 5, Number = 13 }
Seat { Row = 5, Number = 12 }
Reihe 5, Platz 13
  • Ein Versuch, eine Property zu ändern, kompiliert nicht:
Seat seat = new Seat(5, 12);
seat.Row = 6;
Kompilierfehler
error CS8852: Init-only property or indexer 'Seat.Row' can only be assigned in an object initializer, or on 'this' or 'base' in an instance constructor or an 'init' accessor.
  • Ein record kann neben den positionalen auch eigene Methoden, Properties und Felder haben. Auch sie zählen bei Equals mit. Er kann von einem anderen record erben (das nutzen wir in der Karte über Zahlungen), aber nicht von einer normalen Klasse, und er kann Interfaces implementieren. Von einem sealed record kann man nicht erben.
  • Ein record ist eine Klasse: Die Variable zeigt auf das Objekt. record struct Point(int X, int Y); ist ein Werttyp, wie int. Auch er vergleicht nach seinen Feldern.
  • Die Unveränderlichkeit reicht nur eine Ebene tief. Wenn eine Property eine List ist, zeigt sie immer auf dieselbe Liste, aber die Liste selbst kann sich ändern. Und Equals vergleicht die Listen als Pfeile, nicht nach ihrem Inhalt:
record Cart(List<string> Items);
Cart cart = new Cart(new List<string> { "Kaffee" });
cart.Items.Add("Honig");
Console.WriteLine(string.Join(", ", cart.Items));
Console.WriteLine(cart == new Cart(new List<string> { "Kaffee", "Honig" }));
Ausgabe
Kaffee, Honig
False
  • Im Modul „Collections“ hast du Equals und GetHashCode von Hand geschrieben. Mit record hast du sie schon. Wenn du eine andere Regel für die Gleichheit brauchst, kannst du trotzdem deine eigenen schreiben.

Die Idee

Prüfungen und Methoden in einem Record

Ein Preis kann nicht negativ sein. Die positionale Form record Price(decimal Amount, string Currency); hat keinen Platz für eine Prüfung. Deshalb sind hier die Properties und der Konstruktor von Hand geschrieben, und record liefert trotzdem Equals, GetHashCode und ToString.
record Price
{
public decimal Amount { get; }
public string Currency { get; }
public Price(decimal amount, string currency)
{
if (amount < 0)
{
throw new ArgumentException("Der Preis darf nicht negativ sein: " + amount);
}
Amount = amount;
Currency = currency;
}
public Price WithDiscount(decimal percent)
{
return new Price(Math.Round(Amount * (1 - percent / 100), 2), Currency);
}
}
Price coffee = new Price(18.50m, "EUR");
Price sale = coffee.WithDiscount(20);
Console.WriteLine(coffee);
Console.WriteLine(sale);
try
{
new Price(-5, "EUR");
}
catch (ArgumentException e)
{
Console.WriteLine(e.Message);
}
Ausgabe
Price { Amount = 18.50, Currency = EUR }
Price { Amount = 14.80, Currency = EUR }
Der Preis darf nicht negativ sein: -5
  • Die Prüfung kommt, bevor die Properties gesetzt werden. Wenn der Konstruktor eine Exception wirft, wird das Objekt nicht erzeugt.
  • WithDiscount ändert den Preis nicht. Es gibt einen neuen Price zurück. So arbeitest du mit Objekten, die sich nicht ändern: coffee bleibt bei 18,50 €.
  • Math.Round(..., 2) rundet auf den Cent. decimal behält die Nachkommastellen: Ohne Runden ergibt 18,50 mal 0,80 den Wert 14,800.
  • Die Properties haben hier nur get. Deshalb kann with sie nicht ändern und die Prüfung nicht umgehen.

Probier es aus

Sind sie gleich?

Seat ist ein record, wie in der Karte davor.

Was gibt das Programm aus?

Seat a = new Seat(5, 12);
Seat b = new Seat(5, 12);
Console.WriteLine(a == b);
Console.WriteLine(a.GetHashCode() == b.GetHashCode());
Console.WriteLine(ReferenceEquals(a, b));
Antwort

Probier es aus

Die Lieferung wird zu teuer

Die Lieferung in eine Filiale kostet 4,50 €, aber das Programm sagt 6,90 €. In welcher Zeile ist der Fehler?

Wähle die Zeile mit dem Fehler.

Die Idee

Ein switch, der einen Wert zurückgibt

Seit C# 8 kann ein switch ein Ausdruck sein: Er liefert einen Wert, den du in einer Variable speicherst oder zurückgibst.
string method = "filiale";
decimal price = method switch
{
"filiale" => 4.50m,
"kurier" or "adresse" => 6.90m,
"laden" => 0,
_ => throw new ArgumentException("Unbekannte Lieferart: " + method),
};
Console.WriteLine("Lieferung: " + price + " €");
Ausgabe
Lieferung: 4.50 €
  • Der geprüfte Wert steht vor switch. Jeder Fall ist Muster => Wert. Es gibt kein Durchfallen, und es wird kein break geschrieben.
  • Mehrere Werte in einem Fall werden mit or verbunden: "kurier" or "adresse".
  • _ fängt alles Übrige, wie default. In diesem Fall kannst du eine Exception werfen.
  • Der Ausdruck muss für jede Eingabe einen Wert liefern. Bei Text gibt es unendlich viele Eingaben, deshalb muss _ da sein. Ohne es stoppt C# das Kompilieren nicht, es warnt nur:
static decimal Price(string method)
{
return method switch
{
"filiale" => 4.50m,
"kurier" => 6.90m,
};
}
Warnung
warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern '""' is not covered.

Und das Programm stoppt erst, wenn eine Eingabe ohne Fall kommt, Price("post"):

Fehler
Unhandled exception. System.Runtime.CompilerServices.SwitchExpressionException: Non-exhaustive switch expression failed to match its input.
Unmatched value was post.

Wenn der Fall noch von einer anderen Bedingung abhängt, wird sie mit when hinzugefügt:

static decimal Delivery(string method, double weightKg)
{
return method switch
{
"filiale" => 4.50m,
"kurier" when weightKg > 10 => 6.90m + 3,
"kurier" => 6.90m,
_ => 0,
};
}
Console.WriteLine(Delivery("kurier", 4));
Console.WriteLine(Delivery("kurier", 12));
Ausgabe
6.90
9.90
Genauer, für Neugierige
  • Jeder Fall ist nur ein Ausdruck: Einen Block aus mehreren Zeilen gibt es hier nicht. Wenn die Berechnung lang ist, schreib sie in eine Methode und ruf sie aus dem Fall auf.
  • Der alte switch mit case "filiale": ... break; funktioniert weiterhin, und du wirst ihn in altem Code sehen. Auch in ihm fällt ein Fall mit Code nicht durch (die Karte davor).

Die Idee

Eine Familie von Typen

Der Shop nimmt drei Zahlungsarten an: Karte, Banküberweisung und Nachnahme. Andere gibt es nicht. Jede Art ist ein eigener record, ein Untertyp eines gemeinsamen abstract record:

abstract record Payment;
sealed record CardPayment(string CardNumber) : Payment;
sealed record BankTransfer(string Iban) : Payment;
sealed record CashOnDelivery() : Payment;

abstract record Payment; ist der gemeinsame Typ: Von ihm kannst du kein Objekt erzeugen, nur von den Untertypen. : Payment nach einem Untertyp sagt „das ist eine Art von Payment“. Der Rumpf ist leer, deshalb endet die Deklaration mit ;. Die Untertypen sind records, weil jede Zahlungsart nur Daten ist: eine Kartennummer, eine IBAN oder nichts.

sealed bedeutet in C# etwas anderes: Von der Klasse kann man nicht erben. Hier ist jede Zahlungsart sealed, damit es keine „Art von Karte“ gibt. Die Familie selbst lässt sich aber nicht abschließen: In C# gibt es keine Möglichkeit zu sagen „nur diese drei erben von Payment“. Eine vierte Art kompiliert ohne Fehler:

sealed record Voucher(string Code) : Payment;

Wie du dich davor schützt, steht in der nächsten Karte.

Sieh es dir an

Muster in switch

Die Gebühr hängt von der Zahlungsart ab. Ein switch kann auch den Typ des Objekts prüfen:
static decimal Fee(Payment payment, decimal amount)
{
return payment switch
{
CardPayment => Math.Round(amount * 0.015m, 2),
BankTransfer => 0.50m,
CashOnDelivery => 2.00m,
_ => throw new ArgumentException("Unbekannte Zahlung: " + payment),
};
}
Console.WriteLine(Fee(new CardPayment("4111222233334444"), 200));
Console.WriteLine(Fee(new BankTransfer("DE89370400440532013000"), 200));
Console.WriteLine(Fee(new CashOnDelivery(), 200));
Ausgabe
3.00
0.50
2.00

CardPayment => ... bedeutet „wenn das Objekt ein CardPayment ist“. Wenn du auch das Objekt selbst brauchst, gib ihm einen Namen: CardPayment card => .... Das ist is mit Muster aus dem Modul „Vererbung und Polymorphie“, aber für viele Typen auf einmal.

Das _ am Ende ist Pflicht. Der Compiler kennt nicht alle Untertypen von Payment: Jeder kann einen neuen hinzufügen. Ohne _ warnt er nur:

static decimal Fee(Payment payment, decimal amount)
{
return payment switch
{
CardPayment => Math.Round(amount * 0.015m, 2),
BankTransfer => 0.50m,
CashOnDelivery => 2.00m,
};
}
Warnung
warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern '_' is not covered.

Dann stoppt eine vierte Art das Programm erst zur Laufzeit, mit einer SwitchExpressionException. _ mit throw stoppt es wenigstens mit einer klaren Meldung, die sagt, welche Art fehlt:

try
{
Fee(new Voucher("SALE10"), 200);
}
catch (ArgumentException e)
{
Console.WriteLine(e.Message);
}
Ausgabe
Unbekannte Zahlung: Voucher { Code = SALE10 }

Ein Positionsmuster holt die Properties direkt heraus, in der Reihenfolge der Positionsparameter, und when fügt dem Fall eine Bedingung hinzu:

static string Describe(Payment payment)
{
return payment switch
{
CardPayment(var number) => "Karte ****" + number.Substring(number.Length - 4),
BankTransfer(var iban) when iban.StartsWith("DE") => "Überweisung von einem deutschen Konto",
BankTransfer => "Überweisung aus dem Ausland",
CashOnDelivery => "Nachnahme",
_ => throw new ArgumentException("Unbekannte Zahlung: " + payment),
};
}
Console.WriteLine(Describe(new CardPayment("4111222233334444")));
Console.WriteLine(Describe(new BankTransfer("DE89370400440532013000")));
Console.WriteLine(Describe(new BankTransfer("AT611904300234573201")));
Console.WriteLine(Describe(new CashOnDelivery()));
Ausgabe
Karte ****4444
Überweisung von einem deutschen Konto
Überweisung aus dem Ausland
Nachnahme
Genauer, für Neugierige
  • Das Positionsmuster funktioniert auch mit is. Eine Property kann man auch beim Namen nennen: CardPayment { CardNumber: var digits }. Das ist ein Property-Muster, und es funktioniert mit jeder Klasse, nicht nur mit record:
Payment payment = new CardPayment("4111222233334444");
if (payment is CardPayment(var number))
{
Console.WriteLine("Die Karte endet auf " + number.Substring(12));
}
if (payment is CardPayment { CardNumber: var digits })
{
Console.WriteLine("Und mit dem Namen der Property: " + digits.Substring(12));
}
Ausgabe
Die Karte endet auf 4444
Und mit dem Namen der Property: 4444
  • Wenn das Objekt null ist, passt kein Typ, und es kommt bei _ an. Wenn null eine sinnvolle Eingabe ist, füge null => ... vor _ hinzu.
  • Muster lassen sich verschachteln: Order(Customer(var name), var total).

Probier es aus

Welcher Fall gewinnt?

Die Fälle werden von oben nach unten geprüft. Der erste, der passt, gewinnt.

Was gibt das Programm aus?

static string Label(Payment payment, decimal amount)
{
return payment switch
{
CashOnDelivery when amount > 500 => "Nachnahme: über 500 € nicht möglich",
CashOnDelivery => "Nachnahme",
CardPayment when amount > 1000 => "Karte: Bestätigung nötig",
CardPayment => "Karte",
BankTransfer => "Überweisung",
_ => throw new ArgumentException("Unbekannte Zahlung: " + payment),
};
}
Console.WriteLine(Label(new CashOnDelivery(), 650));
Console.WriteLine(Label(new CashOnDelivery(), 80));
Console.WriteLine(Label(new CardPayment("4111222233334444"), 650));
Antwort
Genauer, für Neugierige

Der Fall mit when muss vor dem allgemeinen für denselben Typ stehen. Andersherum kompiliert es nicht, weil der allgemeine Fall alles fängt und der mit when nie erreicht würde:

CashOnDelivery => "Nachnahme",
CashOnDelivery when amount > 500 => "Nachnahme: über 500 € nicht möglich",
Kompilierfehler
error CS8510: The pattern is unreachable. It has already been handled by a previous arm of the switch expression or it is impossible to match.

Die Idee

Raw-Strings und var

Text über mehrere Zeilen, wie ein Kassenbon oder JSON, ist mit " und \n in jeder Zeile schwer zu lesen. Seit C# 11 gibt es das Raw-String-Literal zwischen """ und """:

string receipt = """
Der Eckladen
Kaffeebohnen 18.50 €
Honig 12.00 €
Summe: 30.50 €
""";
Console.WriteLine(receipt);
string name = "Maria";
decimal total = 75.5m;
string json = $$"""
{"customer": "{{name}}", "total": {{total:F2}}}
""";
Console.WriteLine(json);
Ausgabe
Der Eckladen
Kaffeebohnen 18.50 €
Honig 12.00 €
Summe: 30.50 €
{"customer": "Maria", "total": 75.50}
  • Nach den öffnenden """ beginnt der Text in einer neuen Zeile, und die schließenden """ stehen in einer eigenen Zeile. Der Zeilenumbruch davor gehört nicht zum Text, deshalb steht hier WriteLine.
  • Die gemeinsame Einrückung links wird entfernt. Maßstab ist die Einrückung der schließenden """.
  • Anführungszeichen darin werden nicht maskiert.
  • Ein $ vor dem String füllt Werte in geschweiften Klammern aus. Hier hat das JSON selbst geschweifte Klammern, deshalb stehen zwei $$ davor, und die Werte stehen in doppelten Klammern: {{name}} und {{total:F2}}.

Und eine Erinnerung aus „Sprachgrundlagen“: var lässt den Compiler den Typ ermitteln. Bei den langen Typen aus den Collections spart es am meisten:

var prices = new Dictionary<string, decimal>();
prices["Kaffee"] = 18.50m;
prices["Honig"] = 12.00m;
foreach (var pair in prices)
{
Console.WriteLine(pair.Key + ": " + pair.Value + " €");
}
Ausgabe
Kaffee: 18.50 €
Honig: 12.00 €

var gibt es nur für lokale Variablen mit einem Wert. Der Typ wird einmal festgelegt und ändert sich nicht. Felder, Parameter und var x = null; sind nicht erlaubt.

Übung

Modernes C#

Löse die Aufgaben in deinem Editor.

1. Formen. Ein abstract record Shape mit drei sealed record-Untertypen: Circle(double Radius), Rectangle(double Width, double Height) und Triangle(double Base, double Height). Schreib Area(Shape) mit einem switch-Ausdruck und Positionsmustern, mit einem _, das eine ArgumentException wirft. Schreib auch Name(Shape), das für ein Rechteck mit gleichen Seiten „Quadrat“ sagt (mit when). Gib einen Kreis mit Radius 1, ein Rechteck 2 mal 3, ein Quadrat 4 mal 4, ein Dreieck mit Grundseite 3 und Höhe 5 und die Gesamtfläche aus.
Ausgabe
Kreis: 3.14
Rechteck: 6.00
Quadrat: 16.00
Dreieck: 7.50
Gesamt: 32.64
2. Lieferpreis. Ein abstract record Delivery mit ToOffice(string Office), ToAddress(string City, decimal WeightKg) und Pickup(). Der Preis: in eine Filiale 4,50 €; an eine Adresse 5,90 € in Berlin und 6,90 € anderswo, plus 1 € für jedes Kilogramm über 5; Abholung im Laden 0 €. Gib jede Lieferung (mit dem ToString des record) und ihren Preis aus.
Ausgabe
ToOffice { Office = Hamburg Zentrum } -> 4.50 €
ToAddress { City = Berlin, WeightKg = 2 } -> 5.90 €
ToAddress { City = Hamburg, WeightKg = 8 } -> 9.90 €
Pickup { } -> 0.00 €
3. Ein Punkt als Schlüssel. Der Kurier fährt Punkte auf der Karte ab: (2, 3), (5, 1), (2, 3), (0, 0), (5, 1), (2, 3). Mach record struct Point(int X, int Y) und zähle in einem Dictionary<Point, int> mit visits.GetValueOrDefault(p) + 1, wie oft er an jedem Punkt war. Schlag am Ende new Point(2, 3) im Dictionary nach.
Ausgabe
Point { X = 2, Y = 3 }: 3
Point { X = 5, Y = 1 }: 2
Point { X = 0, Y = 0 }: 1
Besuche bei (2, 3): 3

Weitere Aufgaben

  • Ein Schüler als Record. Ein record Student mit den Properties Name und Grade und einem Konstruktor, der eine Note außerhalb von 2 bis 6 ablehnt, wie bei Price. Sortiere eine Liste von Schülern mit OrderBy(s => s.Grade).
  • Wochentag. Schreib die Aufgabe „Wochentag“ aus der Lektion „Bedingungen“ mit einem switch-Ausdruck neu: 1 or 2 or 3 or 4 or 5 => "Werktag".
  • Ein Brief. Mach mit einem Raw-String und $ einen Brief, der eine Reservierung bestätigt: Name, Datum, Anzahl der Gäste und Preis in Euro.

Test

Teste dich

1. Was bekommst du mit record Seat(int Row, int Number); fertig dazu?
2. Warum funktioniert ein Record als Schlüssel in einem Dictionary, eine normale Klasse ohne Zusätze aber nicht?
3. Was ist der Vorteil des switch-Ausdrucks gegenüber dem alten switch?
4. Payment ist ein abstrakter Record mit drei Untertypen. Warum hat ein switch über Payment am Ende ein _?
5. Was macht BankTransfer(var iban) when iban.StartsWith("DE") => ...?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·