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

Ламбди и делегати

Накратко: Ламбдата е кратка функция без име, която подаваш като стойност; в C# типът ѝ е делегат като Func, Action или Predicate.

Проблем

Цял клас за едно сравнение

Поръчките трябва да се подредят по сума. От модул „Колекции“ знаеш как: Sort с обект от клас, който изпълнява IComparer<Order>.

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));
Изход
40
45
student е обект от тип 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));
Стъпка по стъпка, с думи
  1. Ламбдата е обект от тип PriceRule, а student сочи към него. Тялото ѝ още не се изпълнява.
  2. Второ правило, втори обект. Нито един клас не е написан.
  3. student(50) подава 50 на ламбдата: price е 50, а price * 0.8 дава 40.
  4. 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));
Изход
PriceRule
40

Идеята

Готовите: 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.67

Then приема два делегата и връща трети: той вика първия, а резултата му подава на втория.

По-точно, за любопитните
  • Има варианти за повече параметри: 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));
Отговор

Виж го

Списъците приемат ламбди

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
мляко
False
4, 5, 4
  • RemoveAll(условие) маха всички елементи, за които условието е вярно, и връща колко са махнати. Условието е 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));
Изход
мария
георги
ЕЛЕНА
43
False
кафе

Името на метода без скоби се казва група методи (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.75

b.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}"));
Отговор

Пробвай

Намери грешката

Програмата трябва да отпечата цените с номерата им: 1. 12.00 € и така нататък. Спира с грешка още при първата цена. На кой ред е грешката?

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

По-точно, за любопитните

Тук 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));
Изход
80
50

Ако правилото трябва да помни стойност, подай му я като параметър. За сбор обаче има по-ясен начин, 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 €
3. Отстъпки една след друга. Направи три отстъпки като 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. Пробвай ги на няколко низа.

Тест

Провери се

1. Къде може да стои ламбда израз?
2. Кой готов делегат приема поръчка и връща true или false?
3. С какво може да се замени ламбдата s => int.Parse(s)?
4. byGradeThenName сравнява по оценка, после по име. Какво обръща students.Sort((a, b) => byGradeThenName(b, a))?
5. В цикъл for (int i = 0; i < 3; i++) всяка ламбда печата i. Какво печатат трите, извикани след цикъла?

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