Ламбди и делегати
Накратко: Ламбдата е кратка функция без име, която подаваш като стойност; в C# типът ѝ е делегат като Func, Action или Predicate.
Проблем
Цял клас за едно сравнение
Поръчките трябва да се подредят по сума. От модул „Колекции“ знаеш как: Sort с обект от клас, който изпълнява IComparer<Order>.
class Order{ public string Customer { get; } public double Total { get; }
public Order(string customer, double total) { Customer = customer; Total = total; }}class ByTotal : IComparer<Order>{ public int Compare(Order a, Order b) { return a.Total.CompareTo(b.Total); }}
var orders = new List<Order>{ new Order("Мария", 75.50), new Order("Георги", 12.90), new Order("Елена", 40.00)};
orders.Sort(new ByTotal());
foreach (Order o in orders){ Console.WriteLine($"{o.Customer}: {o.Total:F2} €");}Георги: 12.90 €Елена: 40.00 €Мария: 75.50 €Работи. Но за едно сравнение пишеш цял клас: class ByTotal : IComparer<Order>, public int Compare, скоби, а после new ByTotal(). Само един ред казва нещо: a.Total.CompareTo(b.Total). И класът стои далеч от мястото, където го ползваш. Искаш да подадеш само правилото.
Идеята
Ламбда: само правилото
Същото подреждане, на един ред:
orders.Sort((a, b) => a.Total.CompareTo(b.Total));(a, b) => a.Total.CompareTo(b.Total) се казва ламбда израз, накратко ламбда. Отляво на => са параметрите, отдясно е тялото. Това е методът Compare без име, без типове и без return.
Ламбдата има три вида запис:
- Един параметър може да е без скоби:
o => o.Total > 50. Без параметри е() => .... - Типовете на параметрите обикновено се пропускат, защото компилаторът ги знае от
Sort. Може и да ги напишеш:(Order a, Order b) => .... - Тяло с няколко реда е във
{ }и тогаваreturnе задължителен.
Comparison<Order> byTotal = (a, b) => a.Total.CompareTo(b.Total);
Comparison<Order> byTotalLong = (Order a, Order b) =>{ double diff = a.Total - b.Total; return diff < 0 ? -1 : diff > 0 ? 1 : 0;};
orders.Sort(byTotalLong);foreach (Order o in orders){ Console.WriteLine($"{o.Customer}: {o.Total:F2} €");}Георги: 12.90 €Елена: 40.00 €Мария: 75.50 €Comparison<Order>. Какво е той, е в следващата карта.Идеята
Делегат: типът на правилото
Откъде C# знае, че (a, b) => ... е сравнение на две поръчки? От типа: Sort има вариант, който приема Comparison<Order>. Това е делегат: тип, който описва метод, неговите параметри и резултата му. Comparison<Order> е „метод, който приема две поръчки и връща int“. Ламбдата става тялото на такъв метод.
Може да си направиш свой делегат. Пише се като метод с думата delegate и без тяло:
delegate double PriceRule(double price);PriceRule student = price => price * 0.8;PriceRule minusFive = price => price - 5;
Console.WriteLine(student(50));Console.WriteLine(minusFive(50));4045student е обект от тип PriceRule. Викаш го като метод, със скоби: student(50). Две правила за цена, нито един клас. C# печата 40, а не 40.0: числото е цяло.PriceRule student = price => price * 0.8;PriceRule minusFive = price => price - 5;
Console.WriteLine(student(50));Console.WriteLine(minusFive(50));Стъпка по стъпка, с думи
- Ламбдата е обект от тип PriceRule, а student сочи към него. Тялото ѝ още не се изпълнява.
- Второ правило, втори обект. Нито един клас не е написан.
- student(50) подава 50 на ламбдата: price е 50, а price * 0.8 дава 40.
- minusFive(50): същото число, друго правило, 45.
По-точно, за любопитните
- В Java ламбдата застава там, където се очаква интерфейс с един метод (функционален интерфейс). В C# ламбда към интерфейс не може, дори той да има един-единствен метод:
interface IPriceRule{ double Apply(double price);}
IPriceRule student = price => price * 0.8;error CS1660: Cannot convert lambda expression to type 'IPriceRule' because it is not a delegate typeЗатова в C# правило, което се подава като ламбда, е делегат, а не интерфейс.
student(50)е кратко заstudent.Invoke(50). АGetType()показва, че обектът наистина е от делегата:
PriceRule student = price => price * 0.8;Console.WriteLine(student.GetType().Name);Console.WriteLine(student.Invoke(50));PriceRule40Идеята
Готовите: Predicate, Func, Action
Не е нужно да пишеш делегат за всяко правило. В System има готови за най-честите случаи:
Predicate<T>: приемаT, връщаbool. Това е условие: „голяма ли е поръчката?“.Func<T, TResult>: приемаT, връщаTResult. Последният тип в< >винаги е резултатът. Превръща едно в друго: поръчка в етикет.Action<T>: приемаT, не връща нищо. Прави нещо с него: отпечатва го, записва го.Func<TResult>: не приема нищо, връщаTResult. Дава нов обект, когато поискаш.
Всички се викат еднакво, със скоби, като метод.
Predicate<Order> big = o => o.Total >= 50;Func<Order, string> label = o => o.Customer.ToUpper();Action<string> print = text => Console.WriteLine("-> " + text);Func<Order> guest = () => new Order("Гост", 0);
var order = new Order("Мария", 75.50);Console.WriteLine(big(order));print(label(order));Console.WriteLine(guest().Customer);True-> МАРИЯГостВ Java правилата са обекти с методи, които ги сглобяват: условията се свързват с and, or и negate („не“), а функциите се нареждат една след друга с andThen. Делегатите в C# такива методи нямат. Новото правило е нова ламбда, която вика старите: o => big(o) && maria(o). Две функции една след друга също се сглобяват с ламбда, която метод връща:
static Func<double, double> Then(Func<double, double> f, Func<double, double> g){ return x => g(f(x));}
Func<double, double> withVat = price => price * 1.2;Func<double, double> rounded = price => Math.Round(price, 2);
Func<double, double> finalPrice = Then(withVat, rounded);Console.WriteLine(finalPrice(10.555));12.67Then приема два делегата и връща трети: той вика първия, а резултата му подава на втория.
По-точно, за любопитните
- Има варианти за повече параметри:
Func<T1, T2, TResult>приема два и връща резултат,Action<T1, T2>приема два и не връща нищо. Стигат до 16 параметъра. Predicate<Order>иFunc<Order, bool>описват едно и също, но са различни типове. Променлива от единия не може да се подаде там, където се иска другият.- Делегатите на действия се събират с
+=. Тогава едно извикване вика всички по ред:
Action<string> notify = text => Console.WriteLine("Имейл: " + text);notify += text => Console.WriteLine("SMS: " + text);notify("поръчката е изпратена");Имейл: поръчката е изпратенаSMS: поръчката е изпратенаПробвай
Сглобени условия
Какво ще отпечата програмата?
Predicate<Order> big = o => o.Total >= 50;Predicate<Order> maria = o => o.Customer == "Мария";Predicate<Order> bigAndMaria = o => big(o) && maria(o);Predicate<Order> bigOrMaria = o => big(o) || maria(o);Predicate<Order> notBig = o => !big(o);
var small = new Order("Мария", 30);var large = new Order("Георги", 80);
Console.WriteLine(bigAndMaria(small));Console.WriteLine(bigOrMaria(small));Console.WriteLine(notBig(large));bigAndMaria дава False, а bigOrMaria дава True, защото е на Мария. Поръчката на Георги е 80 €, голяма е, а ! обръща отговора: False.Виж го
Списъците приемат ламбди
List<T> има методи, които приемат правило вместо цикъл:var cart = new List<string> { "хляб", "", "мляко", " ", "кафе" };int removed = cart.RemoveAll(item => string.IsNullOrWhiteSpace(item));cart.ForEach(item => Console.WriteLine(item));Console.WriteLine($"Махнати: {removed}");
Console.WriteLine(cart.Find(item => item.StartsWith('м')));Console.WriteLine(cart.Exists(item => item.Length > 5));List<int> lengths = cart.ConvertAll(item => item.Length);Console.WriteLine(string.Join(", ", lengths));хлябмлякокафеМахнати: 2млякоFalse4, 5, 4RemoveAll(условие)маха всички елементи, за които условието е вярно, и връща колко са махнати. Условието еPredicate.ForEach(действие)прави действието с всеки елемент. Това еAction.Find(условие)връща първия елемент, за който условието е вярно, илиnull, ако няма такъв.Existsказва дали има поне един.ConvertAll(превръщане)прави нов списък от резултатите. Тук от думите става списък от дължините им.
Dictionary такива методи няма. computeIfAbsent и forEach от Java в него ги няма, и byCity.ForEach(...) не се компилира:
error CS1061: 'Dictionary<string, List<string>>' does not contain a definition for 'ForEach' and no accessible extension method 'ForEach' accepting a first argument of type 'Dictionary<string, List<string>>' could be found (are you missing a using directive or an assembly reference?)Речникът се обхожда с foreach, а групиране на един ред има в следващия урок.
Идеята
Метод по име, без скоби
s => Console.WriteLine(s). Тогава подай самия метод, по име и без скоби:var names = new List<string> { "мария", "георги" };names.ForEach(Console.WriteLine);
Func<string, int> parse = int.Parse;var vips = new List<string> { "мария" };Predicate<string> isVip = vips.Contains;Func<string, string> upper = s => s.ToUpper();Func<List<string>> newList = () => new List<string>();
Console.WriteLine(upper("елена"));Console.WriteLine(parse("42") + 1);Console.WriteLine(isVip("георги"));List<string> list = newList();list.Add("кафе");Console.WriteLine(string.Join(", ", list));мариягеоргиЕЛЕНА43FalseкафеИмето на метода без скоби се казва група методи (method group): групата са всички методи с това име. Компилаторът избира този, който пасва на делегата. Console.WriteLine има много варианти, а ForEach иска Action<string>, затова се взима WriteLine(string). Java за това има отделен запис, препратка към метод с ::. Видовете са четири, но в C# по име стават само два:
- метод на конкретен обект:
vips.Containsеname => vips.Contains(name); - статичен метод:
int.Parseеs => int.Parse(s), аConsole.WriteLineеs => Console.WriteLine(s); - метод на параметъра: кратък запис няма.
String::toUpperCaseот Java в C# е ламбда,s => s.ToUpper(); - конструктор: кратък запис също няма.
ArrayList::newв C# е() => new List<string>().
Ако ламбдата прави нещо повече, например s => s.Trim().ToUpper(), остави я ламбда.
Виж го
Подреждане по няколко полета
Делегатът Comparison<T> няма методи като comparing и thenComparing в Java. Правилото за няколко полета е една ламбда: ако сравнението по първото поле не е 0, то решава, иначе сравнява следващото.
Учениците са подредени по оценка, от най-високата, а при равни оценки по име:
var students = new List<Student>{ new Student("Иван", 5.50), new Student("Ана", 6.00), new Student("Петър", 5.50), new Student("Борис", 4.75)};students.Sort((a, b) =>{ int byGrade = b.Grade.CompareTo(a.Grade); return byGrade != 0 ? byGrade : string.Compare(a.Name, b.Name);});Ана 6Иван 5.5Петър 5.5Борис 4.75b.Grade.CompareTo(a.Grade) поставя по-високата оценка отпред. При равни оценки byGrade е 0 и string.Compare(a.Name, b.Name) сравнява имената по азбука. Чете се отгоре надолу: „по оценка наобратно, а при равенство по име“.
По-точно, за любопитните
- Без тип отляво компилаторът не знае какъв делегат е ламбдата, защото не знае какво са
aиb:
var byTotal = (a, b) => a.Total.CompareTo(b.Total);error CS8917: The delegate type could not be inferred.Поправката е типът на делегата отляво:
Comparison<Order> byTotal = (a, b) => b.Total.CompareTo(a.Total);orders.Sort(byTotal);- С типове на параметрите
varсе компилира, но даваFunc<Order, Order, int>. Той описва същото катоComparison<Order>, но е друг тип, иSortне го приема:
var byTotal = (Order a, Order b) => a.Total.CompareTo(b.Total);orders.Sort(byTotal);error CS1503: Argument 1: cannot convert from 'System.Func<Order, Order, int>' to 'System.Collections.Generic.IComparer<Order>?'Компилаторът пробва варианта на Sort с IComparer, затова той е в съобщението.
- Подреждане, което се чете като
comparingиthenComparingв Java, C# има в LINQ:OrderByDescending(s => s.Grade).ThenBy(s => s.Name). То е в следващия урок.
Пробвай
Какво обръща размяната на a и b?
byGradeThenName сравнява по оценка, после по име, а Sort получава ламбда, която я вика с разменени a и b.Какво ще отпечата програмата?
var students = new List<Student>{ new Student("Иван", 5.50), new Student("Ана", 6.00), new Student("Петър", 5.50), new Student("Борис", 4.75)};
Comparison<Student> byGradeThenName = (a, b) =>{ int byGrade = a.Grade.CompareTo(b.Grade); return byGrade != 0 ? byGrade : string.Compare(a.Name, b.Name);};
students.Sort((a, b) => byGradeThenName(b, a));students.ForEach(s => Console.WriteLine($"{s.Name} {s.Grade}"));a и b обръща цялото сравнение, а то е „по оценка, после по име“. Затова се обръщат и двете: оценките са от най-високата, а Петър е преди Иван. Ако искаш само оценките наобратно, разменяш a и b само в сравнението на оценките, както в картата преди тази.Пробвай
Намери грешката
1. 12.00 € и така нататък. Спира с грешка още при първата цена. На кой ред е грешката?Избери реда с грешката.
Ламбдата чете i, но не я чете тогава, когато е написана:
ArgumentOutOfRangeException: Index was out of range. Must be non-negative and less than the size of the collection. (Parameter 'index')Ламбдата вижда самата променлива i, не копие на стойността ѝ. Такава ламбда се казва затваряне (closure): тя „затваря“ в себе си променливите отвън, които ползва. Цикълът for има една-единствена i за всички обиколки. Ламбдите се викат чак след цикъла, а тогава i вече е 3. prices[3] го няма, и програмата спира.
Поправката е копие в тялото на цикъла: int n = i; и в ламбдата n вместо i. Всяка обиколка има свое n, затова всяка ламбда помни своето:
var prices = new List<double> { 12, 75, 40 };var lines = new List<Action>();for (int i = 0; i < prices.Count; i++){ int n = i; lines.Add(() => Console.WriteLine($"{n + 1}. {prices[n]:F2} €"));}lines.ForEach(line => line());1. 12.00 €2. 75.00 €3. 40.00 €foreach няма този проблем: в него всяка обиколка има своя променлива.
По-точно, за любопитните
Тук C# е различен от Java. Там ламбдата получава копие на стойността и не може да променя локалната променлива изобщо. В C# ламбдата може и да я променя: сборът от Java тук се компилира и печата 127:
var prices = new List<double> { 12, 75, 40 };double total = 0;prices.ForEach(p => total += p);Console.WriteLine(total);127И щом вижда самата променлива, ламбдата вижда и промяната след себе си:
double rate = 0.2;Func<double, double> discount = price => price * (1 - rate);Console.WriteLine(discount(100));
rate = 0.5;Console.WriteLine(discount(100));8050Ако правилото трябва да помни стойност, подай му я като параметър. За сбор обаче има по-ясен начин, Sum, в следващия урок.
Упражнение
Правила като стойности
1. Подреждане по няколко полета. Клас Product с име, категория и цена. Подреди продуктите по категория, в една категория от най-скъпия, а при равна цена по име, с едно Sort и една ламбда. Продуктите са: мишка, аксесоари, 25 €; лаптоп, компютри, 1299 €; клавиатура, аксесоари, 49,90 €; таблет, компютри, 399 €; слушалки, аксесоари, 49,90 €.
Аксесоари | Клавиатура | 49.90 €Аксесоари | Слушалки | 49.90 €Аксесоари | Мишка | 25.00 €Компютри | Лаптоп | 1299.00 €Компютри | Таблет | 399.00 €2. Филтър на поръчки. Добави към Order град. Напиши метод Select(List<Order> orders, Predicate<Order> rule), който връща нов списък само с поръчките, за които правилото е вярно. Направи две правила, sofia и big (поне 50 €), и отпечатай поръчките, за които са верни и двете, тези извън София и тези „в София или поне 100 €“. Всяко от трите правила е ламбда, която вика sofia и big. Поръчките: Мария, София, 75,50 €; Георги, Варна, 12,90 €; Елена, София, 40 €; Димитър, Пловдив, 120 €.
София, от 50 € нагоре: Мария, София: 75.50 €Извън София: Георги, Варна: 12.90 € Димитър, Пловдив: 120.00 €Безплатна доставка (София или от 100 € нагоре): Мария, София: 75.50 € Елена, София: 40.00 € Димитър, Пловдив: 120.00 €Func<double, double>: студентска (−10%), за Черния петък (−20 €) и „не под нула“. Свържи ги с метода Then(f, g) от урока в два реда: студентската първо и петъчната първо. Пробвай с 120 € и с 15 €. Редът има ли значение?120.00 € -> студент, после петък: 88.00 €; петък, после студент: 90.00 €15.00 € -> студент, после петък: 0.00 €; петък, после студент: 0.00 €Още задачи
- Чистене на списък. От списък с имейли махни празните и тези без
@с единRemoveAll, после отпечатай останалите сForEach(Console.WriteLine). - Ученици по клас. Списък с ученици и класовете им (
"10А","10Б"). За всеки клас отпечатай имената сFindAllи ламбда, която сравнява класа, иConvertAll, която взима името. - Свой делегат. Направи
delegate bool Validator(string value);и три валидатора като ламбди: непразен текст, само цифри (char.IsDigitза всеки знак), дължина до 10. Пробвай ги на няколко низа.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Изключения · Списъци · Dictionary и HashSet · Вложени класове
- Продължава в: LINQ · record, switch и шаблони · Шаблони за дизайн · Тестове с xUnit
- Задачи по темата: Модерен C#