record, switch и шаблони
Накратко: record е тип за данни, за който C# сам пише конструктор, равенство по стойност и ToString, а switch с шаблони избира според типа и свойствата.
Проблем
Мястото е заето, но Dictionary не го намира
Резервации в кино: ключът е мястото (ред и номер), стойността е името на човека.
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), "Мария");
Console.WriteLine(reservations.GetValueOrDefault(new Seat(5, 12), "свободно"));Console.WriteLine(reservations.ContainsKey(new Seat(5, 12)));свободноFalseМария е на ред 5, място 12, но GetValueOrDefault(ключ, резерва), който дава стойността или резервата, ако ключа го няма, казва „свободно“. Помниш защо от урока за хеширането: new Seat(5, 12) два пъти са два различни обекта, а Seat не казва кога са равни. Поправката е Equals и GetHashCode, а за отпечатване и ToString, плюс свойства за четене. Около 30 реда за клас, който само носи две числа.
По-точно, за любопитните
Индексаторът reservations[...] с ключ, който го няма, не дава резерва, а хвърля изключение:
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.Идеята
record: клас за данни
record Seat(int Row, int Number);Dictionary<Seat, string> reservations = new Dictionary<Seat, string>();reservations.Add(new Seat(5, 12), "Мария");
Console.WriteLine(reservations.GetValueOrDefault(new Seat(5, 12), "свободно"));Console.WriteLine(reservations.ContainsKey(new Seat(5, 12)));
Seat seat = new Seat(5, 12);Console.WriteLine(seat);Console.WriteLine("Ред " + seat.Row + ", място " + seat.Number);МарияTrueSeat { Row = 5, Number = 12 }Ред 5, място 12record е клас, чиято работа е да носи данни. В скобите са позиционните параметри му, а C# пише вместо теб:
- свойство за всеки параметър, със същото име:
RowиNumber. То се задава само при създаването; - конструктор с всички параметри;
EqualsиGetHashCode, които сравняват свойствата. ЗатоваDictionaryвече намира Мария. Операторите==и!=също сравняват свойствата;ToStringвъв видаSeat { Row = 5, Number = 12 }.
Обект от record не се променя след създаването си. Нов обект с промяна прави with (виж „По-точно“).
Dictionary<Seat, string> reservations = new Dictionary<Seat, string>();reservations.Add(new Seat(5, 12), "Мария");
Console.WriteLine(reservations.GetValueOrDefault(new Seat(5, 12), "свободно"));Console.WriteLine(reservations.ContainsKey(new Seat(5, 12)));
Seat seat = new Seat(5, 12);Console.WriteLine(seat);Console.WriteLine("Ред " + seat.Row + ", място " + seat.Number);Стъпка по стъпка, с думи
- Празен речник: ключът е място в киното, стойността е име.
- Add слага ключ Seat(5, 12) със стойност „Мария“.
- GetValueOrDefault получава нов обект Seat. Това е друг обект в паметта, не ключът от Add.
- GetHashCode на record се смята от свойствата, затова е еднакъв: търсенето отива на същото място.
- Equals на record сравнява свойство по свойство: ред 5 и 5, място 12 и 12. Равни са, и GetValueOrDefault връща „Мария“.
- ContainsKey прави същото с още един нов Seat: True.
- ToString и свойствата Row и Number record-ът също пише сам.
По-точно, за любопитните
withправи копие на обекта и сменя само изброените свойства. Старият обект си остава същият. Позиционниятrecordсе разглобява и на отделни променливи:
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}, място {number}");Seat { Row = 5, Number = 13 }Seat { Row = 5, Number = 12 }Ред 5, място 13- Опит да се смени свойство не се компилира:
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.recordможе да има свои методи, свойства и полета, освен позиционните. Те също влизат вEquals. Може да наследи другrecord(в картата за плащанията го ползваме), но не обикновен клас, и може да имплементира интерфейси.sealed recordне може да се наследи.recordе клас: променливата сочи обекта.record struct Point(int X, int Y);е стойностен тип, катоint. Той също сравнява по полетата.- Неизменността е само на първо ниво. Ако свойството е
List, то винаги сочи същия списък, но самият списък може да се променя. АEqualsсравнява списъците като стрелки, не по съдържание:
record Cart(List<string> Items);
Cart cart = new Cart(new List<string> { "кафе" });cart.Items.Add("мед");Console.WriteLine(string.Join(", ", cart.Items));Console.WriteLine(cart == new Cart(new List<string> { "кафе", "мед" }));кафе, медFalse- В модул „Колекции“ правеше
EqualsиGetHashCodeна ръка. Сrecordтова вече го има. Ако ти трябва друго правило за равенство, пак можеш да напишеш свои.
Идеята
Проверка и методи в record
record Price(decimal Amount, string Currency); място за проверка няма. Затова тук свойствата и конструкторът са написани на ръка, а record пак дава Equals, GetHashCode и ToString.record Price{ public decimal Amount { get; } public string Currency { get; }
public Price(decimal amount, string currency) { if (amount < 0) { throw new ArgumentException("Цената не може да е отрицателна: " + 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 }Цената не може да е отрицателна: -5- Проверката е преди свойствата да се запишат. Ако конструкторът хвърли изключение, обектът не се създава.
WithDiscountне променя цената. Връща новаPrice. Така се работи с обекти, които не се променят:coffeeси остава 18,50 €.Math.Round(..., 2)закръгля до цент.decimalпази знаците след запетаята: без закръгляне 18,50 по 0,80 е 14.800.- Свойствата тук са само с
get. Затоваwithне може да ги смени и не може да заобиколи проверката.
Пробвай
Равни ли са?
Seat е record, както в картата преди тази.Какво ще отпечата програмата?
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 и b са два обекта, но == на record сравнява свойствата, а не стрелките, затова е True. GetHashCode също е от свойствата и е еднакъв: точно това иска Dictionary. Дали двете стрелки сочат един и същ обект, казва ReferenceEquals: не, обектите са два.Пробвай
Доставката излиза скъпа
Доставката до офис е 4,50 €, а програмата казва 6,90 €. На кой ред е грешката?
Избери реда с грешката.
case "офис": на ред 5 няма свой код. В C# случай с код не може да пропадне в следващия: кодът от Java без break дори не се компилира:
switch (method){ case "офис": price = 4.50m; case "куриер": price = 6.90m; break;}error CS0163: Control cannot fall through from one case label ('case "офис":') to anotherПразните case обаче се трупат: етикетите един под друг делят един код. Затова офисът взима цената на куриера. Поправката е ред 5 да има свой код: price = 4.50m; и break;. А още по-ясно е switch, който връща стойност. Това е следващата карта.
Идеята
switch, който връща стойност
switch може да е израз: дава стойност, която записваш в променлива или връщаш.string method = "офис";decimal price = method switch{ "офис" => 4.50m, "куриер" or "до адрес" => 6.90m, "магазин" => 0, _ => throw new ArgumentException("Непознат начин на доставка: " + method),};Console.WriteLine("Доставка: " + price + " €");Доставка: 4.50 €- Стойността, която се проверява, е преди
switch. Всеки случай ешаблон => стойност. Пропадане няма,breakне се пише. - Няколко стойности в един случай се свързват с
or:"куриер" or "до адрес". _хваща всичко останало, катоdefault. В случая може да хвърлиш изключение.- Изразът трябва да даде стойност за всеки вход. При текст входовете са безброй, затова
_трябва да го има. Без него C# не спира компилирането, а само предупреждава:
static decimal Price(string method){ return method switch { "офис" => 4.50m, "куриер" => 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.И програмата спира чак когато дойде вход без случай, Price("поща"):
Unhandled exception. System.Runtime.CompilerServices.SwitchExpressionException: Non-exhaustive switch expression failed to match its input.Unmatched value was поща.Ако случаят зависи и от друго условие, то се добавя с when:
static decimal Delivery(string method, double weightKg){ return method switch { "офис" => 4.50m, "куриер" when weightKg > 10 => 6.90m + 3, "куриер" => 6.90m, _ => 0, };}Console.WriteLine(Delivery("куриер", 4));Console.WriteLine(Delivery("куриер", 12));6.909.90По-точно, за любопитните
- Всеки случай е само един израз: блок с няколко реда тук няма. Ако сметката е дълга, сложи я в метод и го извикай от случая.
- Старият
switchсcase "офис": ... break;още работи и ще го срещаш в стар код. И в него случай с код не пропада (предната карта).
Идеята
Семейство от типове
Магазинът приема три начина на плащане: карта, банков превод и наложен платеж. Друг няма. Всеки начин е свой record, наследник на общ abstract record:
abstract record Payment;
sealed record CardPayment(string CardNumber) : Payment;sealed record BankTransfer(string Iban) : Payment;sealed record CashOnDelivery() : Payment;abstract record Payment; е общият тип: обект от него не може да се създаде, само от наследниците. : Payment след наследника казва „това е вид Payment“. Тялото е празно, затова записът завършва с ;. Наследниците са record, защото всеки начин на плащане е само данни: номер на карта, IBAN или нищо.
sealed в C# значи друго: класът не може да се наследява. Тук всеки начин на плащане е sealed, за да няма „вид карта“. Но самото семейство не може да се затвори: начин да се каже „само тези три наследяват Payment“ в C# няма. Четвърти вид се компилира без грешка:
sealed record Voucher(string Code) : Payment;Как да се пазиш от него, е в следващата карта.
Виж го
Шаблони в switch
switch може да проверява и типа на обекта: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("Непознато плащане: " + 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 => ... значи „ако обектът е CardPayment“. Ако ти трябва и самият обект, дай му име: CardPayment card => .... Това е is с шаблон от модул „Наследяване и полиморфизъм“, но за много типове наведнъж.
_ в края е задължителен. Компилаторът не знае всички наследници на Payment: всеки може да добави нов. Без _ той само предупреждава:
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.Тогава четвърти вид спира програмата чак при изпълнение, със SwitchExpressionException. _ с throw поне я спира с ясно съобщение, което казва кой вид липсва:
try{ Fee(new Voucher("SALE10"), 200);}catch (ArgumentException e){ Console.WriteLine(e.Message);}Непознато плащане: Voucher { Code = SALE10 }Позиционният шаблон вади свойствата направо, в реда на позиционните параметри, а when добавя условие към случая:
static string Describe(Payment payment){ return payment switch { CardPayment(var number) => "карта ****" + number.Substring(number.Length - 4), BankTransfer(var iban) when iban.StartsWith("BG") => "превод от българска сметка", BankTransfer => "превод от чужбина", CashOnDelivery => "наложен платеж", _ => throw new ArgumentException("Непознато плащане: " + payment), };}Console.WriteLine(Describe(new CardPayment("4111222233334444")));Console.WriteLine(Describe(new BankTransfer("BG80BNBG96611020345678")));Console.WriteLine(Describe(new BankTransfer("DE89370400440532013000")));Console.WriteLine(Describe(new CashOnDelivery()));карта ****4444превод от българска сметкапревод от чужбинаналожен платежПо-точно, за любопитните
- Позиционният шаблон работи и с
is. Свойство може да се посочи и по име:CardPayment { CardNumber: var digits }. Това е шаблон за свойства и работи с всеки клас, не само сrecord:
Payment payment = new CardPayment("4111222233334444");if (payment is CardPayment(var number)){ Console.WriteLine("Картата завършва на " + number.Substring(12));}if (payment is CardPayment { CardNumber: var digits }){ Console.WriteLine("И по име на свойството: " + digits.Substring(12));}Картата завършва на 4444И по име на свойството: 4444- Ако обектът е
null, никой тип не пасва и той стига до_. Акоnullе смислен вход, добавиnull => ...преди_. - Шаблоните се вгнездяват:
Order(Customer(var name), var total).
Пробвай
Кой случай печели?
Какво ще отпечата програмата?
static string Label(Payment payment, decimal amount){ return payment switch { CashOnDelivery when amount > 500 => "наложен платеж: не се приема над 500 €", CashOnDelivery => "наложен платеж", CardPayment when amount > 1000 => "карта: нужно е потвърждение", CardPayment => "карта", BankTransfer => "превод", _ => throw new ArgumentException("Непознато плащане: " + payment), };}
Console.WriteLine(Label(new CashOnDelivery(), 650));Console.WriteLine(Label(new CashOnDelivery(), 80));Console.WriteLine(Label(new CardPayment("4111222233334444"), 650));when и стига до втория. Картата е за 650 €, а потвърждение се иска над 1000 €, затова печели случаят без условие: „карта“.По-точно, за любопитните
Случаят с when трябва да е преди общия за същия тип. Обратното не се компилира, защото общият случай хваща всичко и този с when никога не би се стигнал:
CashOnDelivery => "наложен платеж",CashOnDelivery when amount > 500 => "наложен платеж: не се приема над 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.Идеята
Сурови низове и var
Текст на няколко реда, като касова бележка или JSON, с " и \n на всеки ред се чете трудно. От C# 11 има суров низ (raw string literal) между """ и """:
string receipt = """ Магазин „Ъгъла“ Кафе на зърна 18.50 € Мед 12.00 € Общо: 30.50 € """;Console.WriteLine(receipt);
string name = "Мария";decimal total = 75.5m;string json = $$""" {"customer": "{{name}}", "total": {{total:F2}}} """;Console.WriteLine(json);Магазин „Ъгъла“Кафе на зърна 18.50 €Мед 12.00 €Общо: 30.50 €{"customer": "Мария", "total": 75.50}- След отварящите
"""текстът започва на нов ред, а затварящите"""са на свой ред. Новият ред преди тях не влиза в текста, затова еWriteLine. - Общият отстъп отляво се маха. Мярката е отстъпът на затварящите
""". - Кавичките вътре не се екранират.
$пред низа попълва стойностите в къдрави скоби. Тук самият JSON има къдрави скоби, затова отпред са два знака$$, а стойностите са в двойни скоби:{{name}}и{{total:F2}}.
И едно напомняне от „Основи на езика“: var оставя компилатора да изведе типа. С дългите типове от колекциите той спестява най-много:
var prices = new Dictionary<string, decimal>();prices["кафе"] = 18.50m;prices["мед"] = 12.00m;foreach (var pair in prices){ Console.WriteLine(pair.Key + ": " + pair.Value + " €");}кафе: 18.50 €мед: 12.00 €var е само за локални променливи със стойност. Типът е определен веднъж и не се сменя. Полета, параметри и var x = null; не може.
Упражнение
Модерен C#
Реши задачите в редактора си.
1. Фигури.abstract record Shape с три sealed record наследника: Circle(double Radius), Rectangle(double Width, double Height) и Triangle(double Base, double Height). Напиши Area(Shape) със switch израз и позиционни шаблони, с _, който хвърля ArgumentException. Напиши и Name(Shape), който за правоъгълник с равни страни казва „квадрат“ (с when). Отпечатай кръг с радиус 1, правоъгълник 2 на 3, квадрат 4 на 4, триъгълник с основа 3 и височина 5 и общата площ.кръг: 3.14правоъгълник: 6.00квадрат: 16.00триъгълник: 7.50Общо: 32.64abstract record Delivery с ToOffice(string Office), ToAddress(string City, decimal WeightKg) и Pickup(). Цената: до офис 4,50 €; до адрес 5,90 € в София и 6,90 € другаде, плюс 1 € за всеки килограм над 5; от магазина 0 €. Отпечатай всяка доставка (с ToString на record) и цената ѝ.ToOffice { Office = Варна Център } -> 4.50 €ToAddress { City = София, WeightKg = 2 } -> 5.90 €ToAddress { City = Варна, WeightKg = 8 } -> 9.90 €Pickup { } -> 0.00 €record struct Point(int X, int Y) и преброй колко пъти е бил във всяка точка в Dictionary<Point, int> с visits.GetValueOrDefault(p) + 1. Накрая потърси new Point(2, 3) в речника.Point { X = 2, Y = 3 }: 3Point { X = 5, Y = 1 }: 2Point { X = 0, Y = 0 }: 1Посещения в (2, 3): 3Още задачи
- Студент като record.
record Studentсъс свойстваNameиGradeи конструктор, който отказва оценка извън 2–6, кактоPrice. Подреди списък от студенти сOrderBy(s => s.Grade). - Ден от седмицата. Пренапиши задачата „ден от седмицата“ от урока „Условия“ със
switchизраз:1 or 2 or 3 or 4 or 5 => "делник". - Писмо. Със суров низ и
$направи писмо за потвърждение на резервация: име, дата, брой гости и цена в евро.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Dictionary и HashSet · Хеш код и хеш функции · Ламбди и делегати · LINQ
- Продължава в: Шаблони за дизайн · Бази данни · Observer · Factory Method и Simple Factory
- Задачи по темата: Модерен C#