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.
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)));freiFalseMaria 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);}KeyNotFoundException: The given key 'Seat' was not present in the dictionary.Die Idee
record: eine Klasse für Daten
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);MariaTrueSeat { Row = 5, Number = 12 }Reihe 5, Platz 12Ein 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:
RowundNumber. Sie wird nur beim Erzeugen gesetzt; - einen Konstruktor mit allen Parametern;
EqualsundGetHashCode, die die Properties vergleichen. Deshalb findet dasDictionaryMaria jetzt. Auch die Operatoren==und!=vergleichen die Properties;ToStringin der FormSeat { 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
- Ein leeres Dictionary: Der Schlüssel ist ein Kinoplatz, der Wert ist ein Name.
- Add legt den Schlüssel Seat(5, 12) mit dem Wert „Maria“ an.
- GetValueOrDefault bekommt ein neues Seat-Objekt. Das ist ein anderes Objekt im Speicher, nicht der Schlüssel aus Add.
- Der GetHashCode eines Records wird aus den Properties berechnet, deshalb ist er gleich: Die Suche geht an dieselbe Stelle.
- 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.
- ContainsKey macht dasselbe mit einem weiteren neuen Seat-Objekt: True.
- Auch ToString und die Properties Row und Number schreibt der Record selbst.
Genauer, für Neugierige
withmacht eine Kopie des Objekts und ändert nur die genannten Properties. Das alte Objekt bleibt, wie es ist. Ein positionalerrecordlä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}");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;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
recordkann neben den positionalen auch eigene Methoden, Properties und Felder haben. Auch sie zählen beiEqualsmit. Er kann von einem anderenrecorderben (das nutzen wir in der Karte über Zahlungen), aber nicht von einer normalen Klasse, und er kann Interfaces implementieren. Von einemsealed recordkann man nicht erben. - Ein
recordist eine Klasse: Die Variable zeigt auf das Objekt.record struct Point(int X, int Y);ist ein Werttyp, wieint. Auch er vergleicht nach seinen Feldern. - Die Unveränderlichkeit reicht nur eine Ebene tief. Wenn eine Property eine
Listist, zeigt sie immer auf dieselbe Liste, aber die Liste selbst kann sich ändern. UndEqualsvergleicht 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" }));Kaffee, HonigFalse- Im Modul „Collections“ hast du
EqualsundGetHashCodevon Hand geschrieben. Mitrecordhast 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
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);}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 neuenPricezurück. So arbeitest du mit Objekten, die sich nicht ändern:coffeebleibt bei 18,50 €.Math.Round(..., 2)rundet auf den Cent.decimalbehält die Nachkommastellen: Ohne Runden ergibt 18,50 mal 0,80 den Wert 14,800.- Die Properties haben hier nur
get. Deshalb kannwithsie 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));a und b sind zwei Objekte, aber das == eines record vergleicht die Properties, nicht die Pfeile, deshalb ist es True. Auch GetHashCode wird aus den Properties berechnet und ist gleich: genau das, was das Dictionary braucht. Ob die beiden Pfeile auf dasselbe Objekt zeigen, sagt dir ReferenceEquals: nein, es sind zwei Objekte.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.
case "filiale": in Zeile 5 hat keinen eigenen Code. In C# kann ein Fall mit Code nicht in den nächsten durchfallen: Der Java-Code ohne break kompiliert nicht einmal:
switch (method){ case "filiale": price = 4.50m; case "kurier": price = 6.90m; break;}error CS0163: Control cannot fall through from one case label ('case "filiale":') to anotherLeere case-Marken stapeln sich dagegen: Marken untereinander teilen sich ein Stück Code. Deshalb bekommt die Filiale den Preis des Kuriers. Die Lösung ist, dass Zeile 5 ihren eigenen Code bekommt: price = 4.50m; und break;. Noch klarer ist ein switch, der einen Wert zurückgibt. Das ist die nächste Karte.
Die Idee
Ein switch, der einen Wert zurückgibt
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 + " €");Lieferung: 4.50 €- Der geprüfte Wert steht vor
switch. Jeder Fall istMuster => Wert. Es gibt kein Durchfallen, und es wird keinbreakgeschrieben. - Mehrere Werte in einem Fall werden mit
orverbunden:"kurier" or "adresse". _fängt alles Übrige, wiedefault. 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, };}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"):
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));6.909.90Genauer, 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
switchmitcase "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
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));3.000.502.00CardPayment => ... 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, };}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);}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()));Karte ****4444Überweisung von einem deutschen KontoÜberweisung aus dem AuslandNachnahmeGenauer, 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 mitrecord:
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));}Die Karte endet auf 4444Und mit dem Namen der Property: 4444- Wenn das Objekt
nullist, passt kein Typ, und es kommt bei_an. Wennnulleine sinnvolle Eingabe ist, fügenull => ...vor_hinzu. - Muster lassen sich verschachteln:
Order(Customer(var name), var total).
Probier es aus
Welcher Fall 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));when und landen beim zweiten. Die Kartenzahlung beträgt 650 €, und eine Bestätigung braucht es erst über 1000 €, also gewinnt der Fall ohne Bedingung: „Karte“.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",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);Der EckladenKaffeebohnen 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 hierWriteLine. - 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 + " €");}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. Einabstract 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.Kreis: 3.14Rechteck: 6.00Quadrat: 16.00Dreieck: 7.50Gesamt: 32.64abstract 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.ToOffice { Office = Hamburg Zentrum } -> 4.50 €ToAddress { City = Berlin, WeightKg = 2 } -> 5.90 €ToAddress { City = Hamburg, WeightKg = 8 } -> 9.90 €Pickup { } -> 0.00 €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.Point { X = 2, Y = 3 }: 3Point { X = 5, Y = 1 }: 2Point { X = 0, Y = 0 }: 1Besuche bei (2, 3): 3Weitere Aufgaben
- Ein Schüler als Record. Ein
record Studentmit den PropertiesNameundGradeund einem Konstruktor, der eine Note außerhalb von 2 bis 6 ablehnt, wie beiPrice. Sortiere eine Liste von Schülern mitOrderBy(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
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Dictionary und HashSet · Hashcodes und Hashfunktionen · Lambdas und Delegates · LINQ
- Geht weiter in: Entwurfsmuster · Datenbanken · Observer · Factory Method und Simple Factory
- Aufgaben zum Thema: Modernes C#