record, switch and patterns
In short: A record is a data type for which C# writes the constructor, value equality and ToString, and a switch with patterns chooses by type and properties.
Problem
The seat is taken, but Dictionary cannot find it
Movie theater reservations: the key is the seat (row and number), the value is the person's name.
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), "free"));Console.WriteLine(reservations.ContainsKey(new Seat(5, 12)));freeFalseMaria is in row 5, seat 12, but GetValueOrDefault(key, fallback), which gives the value or the fallback if the key is missing, says "free". You remember why from the lesson on hashing: new Seat(5, 12) written twice is two different objects, and Seat does not say when they are equal. The fix is Equals and GetHashCode, plus ToString for printing, and properties for reading. About 30 lines for a class that only carries two numbers.
More precisely, if you are curious
The indexer reservations[...] with a key that is missing does not give a fallback, it throws an 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.The idea
record: a class for data
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), "free"));Console.WriteLine(reservations.ContainsKey(new Seat(5, 12)));
Seat seat = new Seat(5, 12);Console.WriteLine(seat);Console.WriteLine("Row " + seat.Row + ", seat " + seat.Number);MariaTrueSeat { Row = 5, Number = 12 }Row 5, seat 12A record is a class whose job is to carry data. In the parentheses are its positional parameters, and C# writes for you:
- a property for each parameter, with the same name:
RowandNumber. It is set only at creation; - a constructor with all the parameters;
EqualsandGetHashCode, which compare the properties. That is whyDictionarynow finds Maria. The operators==and!=also compare the properties;ToStringin the formSeat { Row = 5, Number = 12 }.
An object of a record does not change after it is created. A new object with a change comes from with (see "More precisely").
Dictionary<Seat, string> reservations = new Dictionary<Seat, string>();reservations.Add(new Seat(5, 12), "Maria");
Console.WriteLine(reservations.GetValueOrDefault(new Seat(5, 12), "free"));Console.WriteLine(reservations.ContainsKey(new Seat(5, 12)));
Seat seat = new Seat(5, 12);Console.WriteLine(seat);Console.WriteLine("Row " + seat.Row + ", seat " + seat.Number);Step by step, in words
- An empty map: the key is a theater seat, the value is a name.
- Add adds the key Seat(5, 12) with the value "Maria".
- GetValueOrDefault receives a new Seat object. It is a different object in memory, not the key from Add.
- The GetHashCode of a record is computed from the properties, so it is the same: the lookup goes to the same place.
- The Equals of a record compares property by property: row 5 and 5, seat 12 and 12. They are equal, and GetValueOrDefault returns "Maria".
- ContainsKey does the same with yet another new Seat: True.
- The record also writes ToString and the properties Row and Number by itself.
More precisely, if you are curious
withmakes a copy of the object and changes only the properties listed. The old object stays the same. A positionalrecordcan also be taken apart into separate variables:
Seat seat = new Seat(5, 12);Seat next = seat with { Number = 13 };Console.WriteLine(next);Console.WriteLine(seat);
var (row, number) = next;Console.WriteLine($"Row {row}, seat {number}");Seat { Row = 5, Number = 13 }Seat { Row = 5, Number = 12 }Row 5, seat 13- An attempt to change a property does not compile:
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.- A
recordcan have its own methods, properties and fields besides the positional ones. They also count inEquals. It can extend anotherrecord(we use that in the card about payments), but not a plain class, and it can implement interfaces. Asealed recordcannot be extended. - A
recordis a class: the variable points to the object.record struct Point(int X, int Y);is a value type, likeint. It also compares by its fields. - The immutability is only one level deep. If a property is a
List, it always points to the same list, but the list itself can change. AndEqualscompares the lists as arrows, not by contents:
record Cart(List<string> Items);
Cart cart = new Cart(new List<string> { "coffee" });cart.Items.Add("honey");Console.WriteLine(string.Join(", ", cart.Items));Console.WriteLine(cart == new Cart(new List<string> { "coffee", "honey" }));coffee, honeyFalse- In the "Collections" module you wrote
EqualsandGetHashCodeby hand. Withrecordyou already have them. If you need a different rule for equality, you can still write your own.
The idea
Checks and methods in a record
record Price(decimal Amount, string Currency); has no place for a check. So here the properties and the constructor are written by hand, and record still gives Equals, GetHashCode and ToString.record Price{ public decimal Amount { get; } public string Currency { get; }
public Price(decimal amount, string currency) { if (amount < 0) { throw new ArgumentException("The price cannot be negative: " + 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 }The price cannot be negative: -5- The check comes before the properties are set. If the constructor throws an exception, the object is not created.
WithDiscountdoes not change the price. It returns a newPrice. That is how you work with objects that do not change:coffeestays at €18.50.Math.Round(..., 2)rounds to the cent.decimalkeeps the digits after the decimal point: without rounding, 18.50 times 0.80 is 14.800.- The properties here have only
get. Sowithcannot change them and cannot get around the check.
Try it
Are they equal?
Seat is a record, as in the card before this one.What will the program print?
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 and b are two objects, but the == of a record compares the properties, not the arrows, so it is True. GetHashCode is also computed from the properties and is the same: exactly what Dictionary needs. Whether the two arrows point to the same object is what ReferenceEquals tells you: no, there are two objects.Try it
The delivery comes out expensive
Delivery to an office is €4.50, but the program says €6.90. Which line is the bug on?
Pick the line with the bug.
case "office": on line 5 has no code of its own. In C# a case with code cannot fall through into the next one: the Java code without break does not even compile:
switch (method){ case "office": price = 4.50m; case "courier": price = 6.90m; break;}error CS0163: Control cannot fall through from one case label ('case "office":') to anotherEmpty case labels, however, stack up: labels one under another share one piece of code. That is why the office gets the courier's price. The fix is for line 5 to have its own code: price = 4.50m; and break;. Even clearer is a switch that returns a value. That is the next card.
The idea
A switch that returns a value
switch can be an expression: it gives a value that you store in a variable or return.string method = "office";decimal price = method switch{ "office" => 4.50m, "courier" or "address" => 6.90m, "store" => 0, _ => throw new ArgumentException("Unknown delivery method: " + method),};Console.WriteLine("Delivery: €" + price);Delivery: €4.50- The value being checked comes before
switch. Each case ispattern => value. There is no fall-through, and nobreakis written. - Several values in one case are joined with
or:"courier" or "address". _catches everything else, likedefault. In that case you can throw an exception.- The expression has to give a value for every input. With text the inputs are endless, so
_has to be there. Without it C# does not stop the compilation, it only warns:
static decimal Price(string method){ return method switch { "office" => 4.50m, "courier" => 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.And the program stops only when an input with no case comes along, Price("post"):
Unhandled exception. System.Runtime.CompilerServices.SwitchExpressionException: Non-exhaustive switch expression failed to match its input.Unmatched value was post.If the case also depends on another condition, it is added with when:
static decimal Delivery(string method, double weightKg){ return method switch { "office" => 4.50m, "courier" when weightKg > 10 => 6.90m + 3, "courier" => 6.90m, _ => 0, };}Console.WriteLine(Delivery("courier", 4));Console.WriteLine(Delivery("courier", 12));6.909.90More precisely, if you are curious
- Each case is only one expression: there is no block of several lines here. If the calculation is long, put it in a method and call it from the case.
- The old
switchwithcase "office": ... break;still works, and you will see it in old code. In it too a case with code does not fall through (the card before).
The idea
A family of types
The shop accepts three payment methods: card, bank transfer and cash on delivery. There are no others. Each method is its own record, a subtype of a common abstract record:
abstract record Payment;
sealed record CardPayment(string CardNumber) : Payment;sealed record BankTransfer(string Iban) : Payment;sealed record CashOnDelivery() : Payment;abstract record Payment; is the common type: you cannot create an object of it, only of the subtypes. : Payment after a subtype says "this is a kind of Payment". The body is empty, so the declaration ends with ;. The subtypes are records, because each payment method is just data: a card number, an IBAN or nothing.
sealed in C# means something else: the class cannot be extended. Here each payment method is sealed, so that there is no "kind of card". But the family itself cannot be closed: there is no way in C# to say "only these three extend Payment". A fourth kind compiles with no error:
sealed record Voucher(string Code) : Payment;How to guard against it is in the next card.
See it
Patterns in switch
switch can also check the type of the object: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("Unknown payment: " + payment), };}Console.WriteLine(Fee(new CardPayment("4111222233334444"), 200));Console.WriteLine(Fee(new BankTransfer("BG80BNBG96611020345678"), 200));Console.WriteLine(Fee(new CashOnDelivery(), 200));3.000.502.00CardPayment => ... means "if the object is a CardPayment". If you also need the object itself, give it a name: CardPayment card => .... This is is with a pattern from the "Inheritance and polymorphism" module, but for many types at once.
The _ at the end is required. The compiler does not know all the subtypes of Payment: anyone can add a new one. Without _ it only warns:
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.Then a fourth kind stops the program only at run time, with a SwitchExpressionException. _ with throw at least stops it with a clear message that says which kind is missing:
try{ Fee(new Voucher("SALE10"), 200);}catch (ArgumentException e){ Console.WriteLine(e.Message);}Unknown payment: Voucher { Code = SALE10 }A positional pattern takes the properties out directly, in the order of the positional parameters, and when adds a condition to the case:
static string Describe(Payment payment){ return payment switch { CardPayment(var number) => "card ****" + number.Substring(number.Length - 4), BankTransfer(var iban) when iban.StartsWith("BG") => "transfer from a Bulgarian account", BankTransfer => "transfer from abroad", CashOnDelivery => "cash on delivery", _ => throw new ArgumentException("Unknown payment: " + payment), };}Console.WriteLine(Describe(new CardPayment("4111222233334444")));Console.WriteLine(Describe(new BankTransfer("BG80BNBG96611020345678")));Console.WriteLine(Describe(new BankTransfer("DE89370400440532013000")));Console.WriteLine(Describe(new CashOnDelivery()));card ****4444transfer from a Bulgarian accounttransfer from abroadcash on deliveryMore precisely, if you are curious
- The positional pattern also works with
is. A property can also be named:CardPayment { CardNumber: var digits }. That is a property pattern, and it works with any class, not only withrecord:
Payment payment = new CardPayment("4111222233334444");if (payment is CardPayment(var number)){ Console.WriteLine("The card ends in " + number.Substring(12));}if (payment is CardPayment { CardNumber: var digits }){ Console.WriteLine("And by property name: " + digits.Substring(12));}The card ends in 4444And by property name: 4444- If the object is
null, no type matches and it gets to_. Ifnullis a meaningful input, addnull => ...before_. - Patterns can be nested:
Order(Customer(var name), var total).
Try it
Which case wins?
What will the program print?
static string Label(Payment payment, decimal amount){ return payment switch { CashOnDelivery when amount > 500 => "cash on delivery: not accepted over €500", CashOnDelivery => "cash on delivery", CardPayment when amount > 1000 => "card: confirmation needed", CardPayment => "card", BankTransfer => "transfer", _ => throw new ArgumentException("Unknown payment: " + payment), };}
Console.WriteLine(Label(new CashOnDelivery(), 650));Console.WriteLine(Label(new CashOnDelivery(), 80));Console.WriteLine(Label(new CardPayment("4111222233334444"), 650));when and gets to the second one. The card is for €650, and confirmation is needed over €1000, so the case with no condition wins: "card".More precisely, if you are curious
The case with when has to come before the general one for the same type. The other way around does not compile, because the general case catches everything and the one with when could never be reached:
CashOnDelivery => "cash on delivery",CashOnDelivery when amount > 500 => "cash on delivery: not accepted over €500",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.The idea
Raw strings and var
Text on several lines, like a receipt or JSON, is hard to read with " and \n on every line. Since C# 11 there is the raw string literal between """ and """:
string receipt = """ The Corner Shop Coffee beans €18.50 Honey €12.00 Total: €30.50 """;Console.WriteLine(receipt);
string name = "Maria";decimal total = 75.5m;string json = $$""" {"customer": "{{name}}", "total": {{total:F2}}} """;Console.WriteLine(json);The Corner ShopCoffee beans €18.50Honey €12.00Total: €30.50{"customer": "Maria", "total": 75.50}- After the opening
"""the text starts on a new line, and the closing"""are on their own line. The newline before them is not part of the text, which is why it isWriteLine. - The common indentation on the left is removed. The measure is the indentation of the closing
""". - Quotes inside are not escaped.
- A
$in front of the string fills in values in curly braces. Here the JSON itself has curly braces, so there are two$$in front, and the values are in double braces:{{name}}and{{total:F2}}.
And a reminder from "Language basics": var lets the compiler infer the type. With the long types from collections it saves the most:
var prices = new Dictionary<string, decimal>();prices["coffee"] = 18.50m;prices["honey"] = 12.00m;foreach (var pair in prices){ Console.WriteLine(pair.Key + ": €" + pair.Value);}coffee: €18.50honey: €12.00var is only for local variables with a value. The type is set once and does not change. Fields, parameters and var x = null; are not allowed.
Exercise
Modern C#
Solve the tasks in your editor.
1. Shapes. Anabstract record Shape with three sealed record subtypes: Circle(double Radius), Rectangle(double Width, double Height) and Triangle(double Base, double Height). Write Area(Shape) with a switch expression and positional patterns, with a _ that throws ArgumentException. Also write Name(Shape), which says "square" for a rectangle with equal sides (with when). Print a circle with radius 1, a 2 by 3 rectangle, a 4 by 4 square, a triangle with base 3 and height 5, and the total area.circle: 3.14rectangle: 6.00square: 16.00triangle: 7.50Total: 32.64abstract record Delivery with ToOffice(string Office), ToAddress(string City, decimal WeightKg) and Pickup(). The price: to an office €4.50; to an address €5.90 in Sofia and €6.90 elsewhere, plus €1 for every kilogram over 5; from the store €0. Print each delivery (with the ToString of the record) and its price.ToOffice { Office = Varna Center } -> €4.50ToAddress { City = Sofia, WeightKg = 2 } -> €5.90ToAddress { City = Varna, WeightKg = 8 } -> €9.90Pickup { } -> €0.00record struct Point(int X, int Y) and count how many times it has been at each point in a Dictionary<Point, int> with visits.GetValueOrDefault(p) + 1. At the end, look up new Point(2, 3) in the dictionary.Point { X = 2, Y = 3 }: 3Point { X = 5, Y = 1 }: 2Point { X = 0, Y = 0 }: 1Visits to (2, 3): 3More tasks
- A student as a record. A
record Studentwith propertiesNameandGradeand a constructor that rejects a grade outside 2 to 6, likePrice. Sort a list of students withOrderBy(s => s.Grade). - Day of the week. Rewrite the "day of the week" task from the lesson "Conditions" with a
switchexpression:1 or 2 or 3 or 4 or 5 => "weekday". - A letter. With a raw string and
$, make a letter confirming a reservation: name, date, number of guests and price in euros.
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Dictionary and HashSet · Hash codes and hash functions · Lambdas and delegates · LINQ
- Continues in: Design patterns · Databases · Observer · Factory Method and Simple Factory
- Tasks on this topic: Modern C#