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

LINQ

Накратко: LINQ обработва колекция като поредица от стъпки: Where пресява, Select преобразува, OrderBy подрежда, а Sum и Average смятат.

Проблем

Отчет в цикъл

Поръчките вече имат и град:

Order
class Order
{
public string Customer { get; }
public string City { get; }
public decimal Total { get; }
public Order(string customer, string city, decimal total)
{
Customer = customer;
City = city;
Total = total;
}
}
List<Order> orders = new List<Order>
{
new Order("Мария", "София", 75.50m),
new Order("Георги", "Варна", 12.90m),
new Order("Елена", "София", 40.00m),
new Order("Димитър", "Пловдив", 120.00m),
new Order("Ана", "Варна", 64.00m),
};

Искаш клиентите с поръчки поне 50 €, подредени по азбучен ред:

List<string> result = new List<string>();
foreach (Order o in orders)
{
if (o.Total >= 50)
{
result.Add(o.Customer);
}
}
result.Sort();
Console.WriteLine(string.Join(", ", result));
Изход
Ана, Димитър, Мария

Десет реда, а въпросът е един. Кодът казва как: направи празен списък, обиколи, провери, добави, подреди. За да разбереш какво прави, трябва да го изпълниш наум. Добавиш ли „само първите трима“ или „без повторения“, цикълът ще расте.

Идеята

LINQ: поредица от стъпки

Същият отчет, написан като стъпки:

List<string> result = orders
.Where(o => o.Total >= 50)
.Select(o => o.Customer)
.Order()
.ToList();
Console.WriteLine(string.Join(", ", result));
Изход
Ана, Димитър, Мария

Чете се отгоре надолу: вземи поръчките, остави тези поне за 50 €, вземи клиента, подреди, събери в списък.

Тези методи са LINQ (Language Integrated Query, „заявки, вградени в езика“). Те са в System.Linq и работят върху всичко, което може да се обходи с foreach: списък, масив, речник. Резултатът им е заявка (IEnumerable<T>). Тя не пази данни. Тя е конвейер, по който елементите минават един по един. Всяка стъпка приема ламбда от предишния урок:

  • Where(условие) пропуска нататък само елементите, за които условието е вярно. Ламбдата е Func<Order, bool>.
  • Select(функция) превръща всеки елемент в друг: от Order прави string. Това е Func<Order, string>.
  • Order() подрежда по естествения ред, а OrderBy(o => o.Total) по ключ, който ти избираш.
  • ToList() събира каквото е стигнало до края в нов List<string>.

Списъкът orders не се променя. Заявката само го чете.

По-точно, за любопитните
  • Where, Select, Order, OrderBy и Take връщат нова заявка и още нищо не правят. ToList, Count, Sum, First и foreach искат резултат и пускат конвейера.
  • ToList() връща обикновен List<T>, който можеш да променяш. Масив дава ToArray().
  • Order() е от .NET 7. В по-стар код ще видиш OrderBy(name => name): прави същото.
  • Има и втори запис, който прилича на SQL. Компилаторът го превръща в същите извиквания. В този курс се пишат методите:
List<string> result = (from o in orders
where o.Total >= 50
orderby o.Customer
select o.Customer).ToList();
Console.WriteLine(string.Join(", ", result));
Изход
Ана, Димитър, Мария

Виж го

Какво минава през филтъра

List<int> prices = new List<int> { 12, 75, 40, 130, 58 };
List<string> labels = prices
.Where(p => p >= 50)
.Select(p => p + " €")
.ToList();
Console.WriteLine(string.Join(", ", labels));
Стъпка по стъпка, с думи
  1. Пет цени. Заявката само подготвя пътя: още нищо не е минало.
  2. 12 не минава p >= 50 и отпада. До Select не стига.
  3. 75 минава Where, Select го превръща в текста „75 €“, ToList() го събира.
  4. 40 също отпада. Всеки елемент минава целия път, преди да тръгне следващият.
  5. 130 и 58 минават. Списъкът prices не се променя.
  6. Резултатът е нов списък с три текста.

Петте цени тръгват по конвейера една по една. 12 стига до Where и отпада. 75 минава, Select го прави на текста „75 €“ и той влиза в списъка. После 40 отпада, а 130 и 58 минават.

Всеки елемент изминава целия път, преди да тръгне следващият. И нищо не тръгва, докато ToList() не поиска резултат.

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

Това се казва мързеливо изпълнение. Методите на заявката само записват какво трябва да се направи. Виж го с отпечатване във всяка стъпка:

IEnumerable<int> query = new List<int> { 12, 75, 40 }
.Where(p =>
{
Console.WriteLine("Where " + p);
return p >= 50;
})
.Select(p =>
{
Console.WriteLine("Select " + p);
return p * 2;
});
Console.WriteLine("Заявката е готова, нищо не е минало през нея.");
Console.WriteLine(string.Join(", ", query.ToList()));
Изход
Заявката е готова, нищо не е минало през нея.
Where 12
Where 75
Select 75
Where 40
150
  • Първият ред на изхода е отпечатан, преди през заявката да е минал и един елемент. В документацията на C# това е отложено изпълнение (deferred execution).
  • Select 75 е веднага след Where 75: елементите не чакат всички да минат през Where.
  • Затова Take(2) спира заявката, щом два елемента стигнат до края. Останалите дори не се проверяват:
IEnumerable<int> firstTwo = new List<int> { 75, 130, 58, 12, 99 }
.Where(p =>
{
Console.WriteLine("Where " + p);
return p >= 50;
})
.Take(2);
Console.WriteLine(string.Join(", ", firstTwo));
Изход
Where 75
Where 130
75, 130
  • Заявката не се изразходва. Всяко обхождане я пуска отначало: двата Count() минават по четирите цени, и проверките са 8. Ако резултатът ти трябва няколко пъти, събери го веднъж с ToList().
int checks = 0;
IEnumerable<int> expensive = new List<int> { 12, 75, 40, 130 }.Where(p =>
{
checks++;
return p >= 50;
});
Console.WriteLine(expensive.Count());
Console.WriteLine(expensive.Count());
Console.WriteLine("Проверки: " + checks);
Изход
2
2
Проверки: 8
  • AsParallel() пуска елементите в няколко нишки едновременно (PLINQ). Помага при много данни и тежки сметки. При малки списъци е по-бавно, а ламбдите не трябва да пипат общи данни.

Идеята

Сбор, брой, средно

Резултатът може да е число вместо списък. Сборът на четните числа:

List<int> numbers = new List<int> { 1, 2, 3, 4, 5 };
int sum = numbers.Where(n => n % 2 == 0).Sum();
Console.WriteLine("Сума на четните числа: " + sum);
Изход
Сума на четните числа: 6
Sum() събира числата, които са стигнали до края. Със селектор, Sum(o => o.Total), първо взима числото от всеки елемент.

Същото за поръчките:

int big = orders.Count(o => o.Total >= 50);
decimal revenue = orders.Sum(o => o.Total);
decimal average = orders.Average(o => o.Total);
decimal maxTotal = orders.Max(o => o.Total);
Console.WriteLine("Поръчки от 50 € нагоре: " + big);
Console.WriteLine($"Оборот: {revenue:F2} €");
Console.WriteLine($"Средно: {average:F2} €");
Console.WriteLine($"Най-голяма: {maxTotal:F2} €");
Изход
Поръчки от 50 € нагоре: 3
Оборот: 312.40 €
Средно: 62.48 €
Най-голяма: 120.00 €
  • Count(условие) брои елементите, за които условието е вярно. Типът е int.
  • Average и Max не могат да дадат отговор за празен списък: средно на нищо няма. Тогава хвърлят InvalidOperationException. Sum на празен списък е 0. Какво да правиш с резултат, който може да липсва, е следващата идея.
По-точно, за любопитните

С празен списък. DefaultIfEmpty(0) дава резерва: ако елементи няма, заявката дава един елемент, 0.

List<decimal> prices = new List<decimal>();
Console.WriteLine(prices.Sum());
Console.WriteLine(prices.DefaultIfEmpty(0).Max());
try
{
Console.WriteLine(prices.Max());
}
catch (InvalidOperationException e)
{
Console.WriteLine("InvalidOperationException: " + e.Message);
}
Изход
0
0
InvalidOperationException: Sequence contains no elements

Идеята

Подреди, отрежи, без повторения

List<int> ratings = new List<int> { 5, 3, 5, 4, 2, 4, 5 };
Console.WriteLine(string.Join(", ", ratings.Distinct().Order()));
List<string> top = orders
.OrderByDescending(o => o.Total)
.Take(3)
.Select(o => o.Customer)
.ToList();
Console.WriteLine(string.Join(", ", top));
foreach (int i in Enumerable.Range(1, 3))
{
Console.WriteLine("Маса " + i);
}
Изход
2, 3, 4, 5
Димитър, Мария, Ана
Маса 1
Маса 2
Маса 3
  • Distinct() маха повторенията. Сравнява с Equals и GetHashCode.
  • OrderByDescending(o => o.Total) подрежда от най-голямата поръчка.
  • Take(3) пропуска само първите три. Заедно с подреждането това е „топ 3“.
  • Enumerable.Range(1, 3) дава числата 1, 2, 3. Вторият параметър е броят на числата, а не краят.
  • ForEach има само List<T>. Заявка се обхожда с обикновен foreach.

Пробвай

Кое стига до края?

Какво ще отпечата програмата?

List<string> drinks = new List<string> { "кафе", "чай", "кафе", "вода", "чай", "сок" };
List<string> result = drinks
.Distinct()
.Order()
.Take(3)
.Select(d => d.ToUpper())
.ToList();
Console.WriteLine(string.Join(", ", result));
Отговор

Идеята

null: резултат, който може да липсва

Коя е най-голямата поръчка? Ако списъкът е празен, отговор няма. Тогава резултатът е null: „няма обект“. Но викащият трябва да помни да провери. Забрави ли, програмата спира с NullReferenceException някъде по-късно.

Java за това има Optional: кутия, в която има стойност или е празна. В C# такава кутия няма. Методите на LINQ, които търсят един елемент, връщат null, ако такъв няма, а проверката е твоя работа:

Order biggest = orders.MaxBy(o => o.Total);
if (biggest != null)
{
Console.WriteLine("Най-голяма: " + biggest.Customer);
}
Order huge = orders.FirstOrDefault(o => o.Total > 500);
Console.WriteLine(huge != null);
Console.WriteLine(huge?.Customer ?? "няма такава");
Изход
Най-голяма: Димитър
False
няма такава
  • MaxBy(o => o.Total) дава поръчката с най-голяма сума, а не самата сума. За празен списък от обекти дава null.
  • FirstOrDefault(условие) дава първия елемент, за който условието е вярно, или null, ако такъв няма.
  • huge?.Customer: ако huge е null, целият израз е null, вместо програмата да спре.
  • x ?? y дава y, ако x е null, иначе x.

Твоите методи също връщат null, когато нямат отговор:

static string Nickname(string user)
{
// Връща прякора или null, ако човекът няма.
return user == "maria" ? "Мими" : null;
}
string maria = Nickname("maria");
if (maria != null)
{
Console.WriteLine("Прякор: " + maria);
}
string georgi = Nickname("georgi");
Console.WriteLine(georgi ?? "без прякор");
Изход
Прякор: Мими
без прякор
По-точно, за любопитните
  • Ако забравиш проверката:
Order huge = orders.FirstOrDefault(o => o.Total > 500);
Console.WriteLine(huge.Customer);
Грешка
Unhandled exception. System.NullReferenceException: Object reference not set to an instance of an object.
at Program.Main()
  • Ако празният резултат е грешка, пиши First, а не FirstOrDefault. Името казва честно какво ще стане:
try
{
Order huge = orders.First(o => o.Total > 500);
Console.WriteLine(huge.Customer);
}
catch (InvalidOperationException e)
{
Console.WriteLine("InvalidOperationException: " + e.Message);
}
Изход
InvalidOperationException: Sequence contains no matching element
  • За int, decimal и другите стойностни типове null няма. Тогава FirstOrDefault дава 0, освен ако не му дадеш друга резерва:
List<int> ratings = new List<int> { 5, 3, 4 };
Console.WriteLine(ratings.FirstOrDefault(r => r > 5));
Console.WriteLine(ratings.FirstOrDefault(r => r > 5, -1));
Изход
0
-1
  • В нов проект (dotnet new console) проверките за null са включени (<Nullable>enable</Nullable>). Там тип, който може да е null, се пише с ?: Order? huge. Ако запишеш резултата на FirstOrDefault в Order без ?, компилаторът предупреждава.
  • Any и All отговарят с True или False дали някой или всички елементи отговарят на условието:
Console.WriteLine(orders.Any(o => o.Total > 100));
Console.WriteLine(orders.All(o => o.Total >= 10));
Изход
True
True

Пробвай

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

Програмата брои скъпите цени и после ги отпечатва. Между двете в списъка влиза нова цена, 99, но отчетът трябва да е за цените към момента на преброяването. Броят е 2, а цените излизат три. На кой ред е грешката?

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

Идеята

Групиране и съединяване

Колко поръчки има от всеки град и какъв е оборотът?

var byCity = orders
.GroupBy(o => o.City)
.OrderBy(g => g.Key);
foreach (var group in byCity)
{
Console.WriteLine($"{group.Key}: брой {group.Count()}, оборот {group.Sum(o => o.Total):F2} €");
}
string customers = string.Join(", ", orders.Select(o => o.Customer).Order());
Console.WriteLine(customers);
Изход
Варна: брой 2, оборот 76.90 €
Пловдив: брой 1, оборот 120.00 €
София: брой 2, оборот 115.50 €
Ана, Георги, Димитър, Елена, Мария

GroupBy(o => o.City) дели поръчките на групи по град. Всяка група има Key (градът) и се обхожда като списък с поръчките си, затова Count() и Sum(...) работят върху нея.

  • OrderBy(g => g.Key) подрежда групите по град. Без него групите излизат в реда, в който градовете се срещат за първи път: София, Варна, Пловдив.
  • string.Join(", ", ...) съединява текстовете в един, със запетая между тях.

Типът на групите е дълъг (IOrderedEnumerable<IGrouping<string, Order>>), затова е var: компилаторът го извежда сам.

По-точно, за любопитните
  • ToDictionary прави речник от групите: за всеки град списък с имената. Редът на ключовете в Dictionary не е гарантиран, затова градовете се подреждат изрично:
Dictionary<string, List<string>> namesByCity = orders
.GroupBy(o => o.City)
.ToDictionary(g => g.Key, g => g.Select(o => o.Customer).ToList());
foreach (string city in namesByCity.Keys.Order())
{
Console.WriteLine(city + ": " + string.Join(", ", namesByCity[city]));
}
Изход
Варна: Георги, Ана
Пловдив: Димитър
София: Мария, Елена
  • ToDictionary(ключ, стойност) прави речник с една стойност за ключ и хвърля изключение, ако ключът се повтори:
try
{
Dictionary<string, string> customerByCity = orders.ToDictionary(o => o.City, o => o.Customer);
}
catch (ArgumentException e)
{
Console.WriteLine("ArgumentException: " + e.Message);
}
Изход
ArgumentException: An item with the same key has already been added. Key: София

Упражнение

Отчети с LINQ

Реши задачите в редактора си. В нов проект System.Linq е включен сам.

1. Продажби по категория. Клас Sale с продукт, категория, брой и единична цена (decimal) и свойство Revenue (брой по цена). Групирай по категория с GroupBy, сумирай всяка група със Sum, отпечатай категориите по азбука и общия оборот. Продажбите: кафе на зърна, напитки, 12 бр. по 18,50 €; чай, напитки, 30 по 4,20 €; шоколад, сладки, 40 по 2,80 €; бисквити, сладки, 25 по 3,10 €; мед, храни, 8 по 12 €; пак кафе на зърна, 5 по 18,50 €.

Изход
Напитки: 440.50 €
Сладки: 189.50 €
Храни: 96.00 €
Общо: 726.00 €

2. Топ 3 продукта. Със същите продажби намери трите продукта с най-голям оборот. Кафето е в две продажби, затова първо групирай по продукт и сумирай, после подреди групите по сбора. Подсказка: GroupBy(s => s.Product), после OrderByDescending(g => g.Sum(s => s.Revenue)) и Take(3): всяка група има Key (продуктът) и продажбите си.

Изход
Кафе на зърна: 314.50 €
Чай: 126.00 €
Шоколад: 112.00 €

3. Средна оценка. Прочети един ред с оценки, разделени с интервал. Отпечатай средната, най-високата и колко са отличните (5,50 и нагоре). Подсказка: Console.ReadLine().Split(' ').Select(double.Parse).

Вход
4.50 5.75 6.00 3.00 5.50
Изход
Средно: 4.95
Най-висока: 6.00
Отлични: 3

4. Думите в текст. Прочети ред текст и отпечатай думите в него без повторения, по азбучен ред, с малки букви, разделени със запетая. Направи всеки знак, който не е буква, на интервал: text.Select(c => char.IsLetter(c) ? c : ' '), а new string(...) от тези знаци. После раздели с Split(' ', StringSplitOptions.RemoveEmptyEntries): празните части се махат.

Вход
Кафе с мляко, кафе без захар. Чай с мед!
Изход
без, захар, кафе, мед, мляко, с, чай

Още задачи

  • Най-дългата дума. С MaxBy(w => w.Length) намери най-дългата дума в изречение. Какво ще върне програмата за празен ред?
  • Средно по клас. Ученици с име, клас и оценка: средната оценка за всеки клас с GroupBy и Average.
  • Големи и малки поръчки. С Any провери дали има поръчка над 1000 €, а с All дали всички са поне 10 €.

Тест

Провери се

1. Какво става със списъка orders след orders.Where(o => o.Total > 50).ToList()?
2. Кое от тези пуска заявката?
3. orders.FirstOrDefault(o => o.Total > 500) не намира поръчка. Какво връща?
4. Защо отчетът по градове има OrderBy(g => g.Key) след GroupBy?
5. IEnumerable<int> q = prices.Where(p => p >= 50); Console.WriteLine(q.Count()); prices.Add(99); Console.WriteLine(q.Count()); Какво става?

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