Към съдържанието
wannacode

record, switch и шаблони

Накратко: record е тип за данни, за който C# сам пише конструктор, равенство по стойност и ToString, а switch с шаблони избира според типа и свойствата.

Проблем

Мястото е заето, но Dictionary не го намира

Резервации в кино: ключът е мястото (ред и номер), стойността е името на човека.

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), "Мария");
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: клас за данни

От C# 9 същото е на един ред:
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);
Изход
Мария
True
Seat { Row = 5, Number = 12 }
Ред 5, място 12

record е клас, чиято работа е да носи данни. В скобите са позиционните параметри му, а 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);
Стъпка по стъпка, с думи
  1. Празен речник: ключът е място в киното, стойността е име.
  2. Add слага ключ Seat(5, 12) със стойност „Мария“.
  3. GetValueOrDefault получава нов обект Seat. Това е друг обект в паметта, не ключът от Add.
  4. GetHashCode на record се смята от свойствата, затова е еднакъв: търсенето отива на същото място.
  5. Equals на record сравнява свойство по свойство: ред 5 и 5, място 12 и 12. Равни са, и GetValueOrDefault връща „Мария“.
  6. ContainsKey прави същото с още един нов Seat: True.
  7. 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));
Отговор

Пробвай

Доставката излиза скъпа

Доставката до офис е 4,50 €, а програмата казва 6,90 €. На кой ред е грешката?

Избери реда с грешката.

Идеята

switch, който връща стойност

От C# 8 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.90
9.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.00
0.50
2.00

CardPayment => ... значи „ако обектът е 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 трябва да е преди общия за същия тип. Обратното не се компилира, защото общият случай хваща всичко и този с 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.64
2. Цена на доставка. abstract 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 €
3. Точка като ключ. Куриерът минава през точки по картата: (2, 3), (5, 1), (2, 3), (0, 0), (5, 1), (2, 3). Направи record struct Point(int X, int Y) и преброй колко пъти е бил във всяка точка в Dictionary<Point, int> с visits.GetValueOrDefault(p) + 1. Накрая потърси new Point(2, 3) в речника.
Изход
Point { X = 2, Y = 3 }: 3
Point { X = 5, Y = 1 }: 2
Point { 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 => "делник".
  • Писмо. Със суров низ и $ направи писмо за потвърждение на резервация: име, дата, брой гости и цена в евро.

Тест

Провери се

1. Какво получаваш наготово с record Seat(int Row, int Number);?
2. Защо record работи като ключ в Dictionary, а обикновен клас без нищо добавено не?
3. Какво е предимството на switch израза пред стария switch?
4. Payment е abstract record с три наследника. Защо switch по Payment има _ в края?
5. Какво прави BankTransfer(var iban) when iban.StartsWith("BG") => ...?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·