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

Списъци, речници и LINQ

Проблем

Количката расте

Клиентът на онлайн магазина слага в количката кафе, чай, мляко, после още филтри. После маха млякото. Колко продукта ще купи, не знаеш предварително. Масивът обаче иска размера си още при new:

string[] cart = new string[3];
cart[0] = "Кафе на зърна";
cart[1] = "Зелен чай";
cart[2] = "Мляко за кафе";
try
{
cart[3] = "Филтри за кафе";
}
catch (IndexOutOfRangeException e)
{
Console.WriteLine("Не се побира: " + e.Message);
}
Изход
Не се побира: Index was outside the bounds of the array.

Има и втори проблем. Складът пази продукти по код като C-101. За да намериш количеството за един код в масив, обхождаш масива с цикъл всеки път. При 10 000 продукта и 100 поръчки в минута това са много цикли за нищо.

Виж го

List<T>: масив, който расте

Списъкът (List) пази елементи поред, като масива, но расте с Add и намалява с Remove:

List<string> cart = new List<string>();
cart.Add("Кафе на зърна");
cart.Add("Зелен чай");
cart.Add("Мляко за кафе");
cart.Add("Филтри за кафе");
Console.WriteLine("В количката: " + cart.Count);
Console.WriteLine("Първото: " + cart[0]);
cart.Remove("Мляко за кафе");
Console.WriteLine("Има ли мляко? " + cart.Contains("Мляко за кафе"));
foreach (string item in cart)
{
Console.WriteLine("- " + item);
}
Изход
В количката: 4
Първото: Кафе на зърна
Има ли мляко? False
- Кафе на зърна
- Зелен чай
- Филтри за кафе
  • В List<string> ъгловите скоби казват какво има в списъка: текстове. List<int> е списък от цели числа, List<Product> е списък от продукти. Типът в скобите се казва типов параметър. Затова пишем List<T>, където T е „какъвто тип избереш“.
  • Count е броят на елементите (при масива е Length). Индексът е като при масива: cart[0] е първият.
  • Remove маха елемента, ако го има, а Contains казва дали го има. foreach минава през всички.

В този модул програмите са без обвивката class Program и Main: редовете стоят направо във файла. Това са top-level statements от урока „Подготовка на компютъра“: компилаторът сам слага Main около тях. Класовете, когато ги има, са след тези редове.

По-точно, за любопитните
  • Списък може да се напълни още при създаването: new List<int> { 2, 1, 3 }. Има и още методи: Insert(индекс, стойност) слага на място, RemoveAt(индекс) маха по място, Clear() изпразва.
List<int> quantities = new List<int> { 2, 1, 3 };
quantities.Insert(0, 5);
quantities.RemoveAt(2);
Console.WriteLine(string.Join(", ", quantities));
quantities.Clear();
Console.WriteLine(quantities.Count);
Изход
5, 2, 3
0
  • string.Join(", ", списък) слепва елементите в един текст с разделител между тях.
  • Отвътре списъкът е масив с малко резерва. Когато се напълни, List прави нов, два пъти по-голям масив и копира елементите. Затова Add обикновено е бърз, а ти не мислиш за размера.
  • Ако напишеш индекс извън списъка, cart[10], изключението е ArgumentOutOfRangeException, а не IndexOutOfRangeException като при масива.

Пробвай

Какво остава в списъка

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

List<int> quantities = new List<int> { 2, 5, 2, 1 };
quantities.Remove(2);
quantities.Add(3);
Console.WriteLine(quantities.Count + " " + quantities[0] + " " + quantities[3]);
Отговор

Виж го

Речник: търсене по код

Речникът (Dictionary) пази двойки ключ → стойност. Тук ключът е кодът на продукта, а стойността е количеството на склад. Намирането по ключ е почти мигновено, без цикъл, колкото и продукти да има:

Dictionary<string, int> stock = new Dictionary<string, int>();
stock["C-101"] = 12;
stock["T-205"] = 30;
stock["C-101"] = stock["C-101"] - 2;
Console.WriteLine("Кафе на склад: " + stock["C-101"]);
Console.WriteLine("Има ли X-999? " + stock.ContainsKey("X-999"));
if (stock.TryGetValue("T-205", out int tea))
{
Console.WriteLine("Чай на склад: " + tea);
}
try
{
Console.WriteLine(stock["X-999"]);
}
catch (KeyNotFoundException)
{
Console.WriteLine("Няма продукт с код X-999");
}
foreach (KeyValuePair<string, int> pair in stock)
{
Console.WriteLine(pair.Key + ": " + pair.Value + " бр.");
}
Изход
Кафе на склад: 10
Има ли X-999? False
Чай на склад: 30
Няма продукт с код X-999
C-101: 10 бр.
T-205: 30 бр.
  • Dictionary<string, int>: ключовете са текст, стойностите са цели числа.
  • stock["C-101"] = 12 записва. Ако ключът вече го има, стойността се заменя. stock["C-101"] чете.
  • Четене на ключ, който го няма, хвърля KeyNotFoundException. Хващаш го с try/catch, както в модул „Изключения“. По-добре е да не стигаш дотам: ContainsKey казва дали ключът го има, а TryGetValue проверява и чете наведнъж. Стойността идва през out, както при int.TryParse.
  • foreach минава през двойките. Всяка е KeyValuePair с Key и Value.
По-точно, за любопитните
  • Add(ключ, стойност) също записва, но хвърля ArgumentException, ако ключът вече го има. Така не презаписваш цена по погрешка. Речник може да се напълни при създаването с двойки в къдрави скоби:
Dictionary<string, decimal> prices = new Dictionary<string, decimal>
{
{ "C-101", 18.90m },
{ "T-205", 6.40m },
};
try
{
prices.Add("C-101", 19.50m);
}
catch (ArgumentException)
{
Console.WriteLine("C-101 вече има цена: " + prices["C-101"] + " €");
}
prices.Remove("T-205");
Console.WriteLine("Продукти с цена: " + prices.Count);
Изход
C-101 вече има цена: 18.90 €
Продукти с цена: 1
  • Ключовете са уникални, стойностите не са. Два продукта може да имат количество 30, но кодът C-101 е един.
  • Редът, в който foreach дава двойките, не е обещан. Днес е редът на добавяне, ако не си махал нищо, но не разчитай на него. Ако ти трябва ред, подреди ключовете.
  • Речникът намира ключа бързо, защото пресмята от него число (хеш) и по него знае къде да гледа. Затова ключът трябва да не се променя, докато е в речника.

Пробвай

Код, който го няма

Програмата трябва да покаже цената на продукта с код C-102. Вместо това спира с изключение. На кой ред е проблемът?

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

Идеята

Правило като стойност: ламбда

Да намериш цените от 50 € нагоре е цикъл с if:

List<int> prices = new List<int> { 12, 75, 40, 130, 58 };
List<int> expensive = new List<int>();
foreach (int p in prices)
{
if (p >= 50)
{
expensive.Add(p);
}
}
Console.WriteLine(string.Join(", ", expensive));
Изход
75, 130, 58

Утре ще ти трябват продуктите под 10 €, после само чайовете. Цикълът е същият, сменя се само условието в if. Удобно е условието да се подаде като стойност на метод, който прави цикъла вместо теб.

Такова условие се пише като ламбда: p => p >= 50. Чети го „за всяко p дай p >= 50“. Вляво от => е параметърът, вдясно е резултатът. Ламбдата е кратък метод без име, без return и без типове: C# ги извежда сам.

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

Ламбдата има тип като всяка стойност. Func<int, bool> е „метод, който взима int и връща bool“. Можеш да я запишеш в променлива и да я викаш като метод. Ламбдата може да има и тяло с къдрави скоби и return, когато едно изражение не стига:

Func<int, bool> expensive = p => p >= 50;
Console.WriteLine(expensive(75) + " " + expensive(12));
Func<decimal, bool> overWithVat = p =>
{
decimal withVat = p * 1.2m;
return withVat > 50;
};
Console.WriteLine(overWithVat(45.00m));
Изход
True False
True

Виж го

Where и Select

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. Резултатът е нов списък с три текста.

Методите Where и Select са от LINQ (Language Integrated Query), заявки към данни, вградени в езика. Работят за всеки списък и масив:

  • Where(p => p >= 50) пуска нататък само елементите, за които ламбдата дава true. Това е цикълът с if от предишната карта.
  • Select(p => p + " €") превръща всеки елемент в нещо друго, тук в текст.
  • ToList() събира резултата в нов List. Списъкът prices не се променя.

Елементите минават един по един: 12 отпада още на Where и до Select не стига.

Виж го

Продуктите на магазина

Истинският каталог е списък от обекти. Класът Product има само свойства, затова обектите се създават със свойствата в къдрави скоби: new Product { Id = 1, Name = "…" }. Това е същото като new Product() и после p.Id = 1; за всяко свойство.

List<Product> catalog = new List<Product>
{
new Product { Id = 1, Name = "Еспресо на зърна", Price = 24.90m, Category = "кафе" },
new Product { Id = 2, Name = "Зелен чай сенча", Price = 9.80m, Category = "чай" },
new Product { Id = 3, Name = "Колумбия на зърна", Price = 31.50m, Category = "кафе" },
new Product { Id = 4, Name = "Мента", Price = 4.20m, Category = "чай" },
new Product { Id = 5, Name = "Кафемелачка", Price = 64.00m, Category = "уреди" },
};
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
public string Category { get; set; }
}

Ламбдата получава цял продукт, затова условието чете свойствата му:

List<Product> coffee = catalog.Where(p => p.Category == "кафе").ToList();
Console.WriteLine("Кафета: " + coffee.Count);
List<string> byPrice = catalog
.OrderByDescending(p => p.Price)
.Select(p => p.Name + ": " + p.Price + " €")
.ToList();
foreach (string line in byPrice)
{
Console.WriteLine(line);
}
Изход
Кафета: 2
Кафемелачка: 64.00 €
Колумбия на зърна: 31.50 €
Еспресо на зърна: 24.90 €
Зелен чай сенча: 9.80 €
Мента: 4.20 €
  • Where(p => p.Category == "кафе") оставя само кафетата.
  • OrderByDescending(p => p.Price) подрежда по цена, от най-високата. OrderBy подрежда от най-ниската. Ламбдата казва по какво се подрежда.
  • Методите се нижат един след друг: всеки получава резултата на предишния.

Каталогът е същият. Какво ще отпечата?

List<string> names = catalog
.Where(p => p.Price < 30)
.OrderBy(p => p.Price)
.Select(p => p.Name)
.ToList();
Console.WriteLine(names.Count + ": " + names[0]);
Отговор

Виж го

Един отговор от целия списък

Част от методите на LINQ не връщат списък, а една стойност: сума, брой, „има ли“, един продукт:

Console.WriteLine("Общо: " + catalog.Sum(p => p.Price) + " €");
Console.WriteLine("Средно: " + catalog.Average(p => p.Price) + " €");
Console.WriteLine("Под 5 €: " + catalog.Count(p => p.Price < 5));
Console.WriteLine("Има ли уреди? " + catalog.Any(p => p.Category == "уреди"));
Product tea = catalog.First(p => p.Category == "чай");
Console.WriteLine("Първият чай: " + tea.Name);
Product cocoa = catalog.FirstOrDefault(p => p.Category == "какао");
Console.WriteLine(cocoa == null ? "Какао няма" : cocoa.Name);
Изход
Общо: 134.40 €
Средно: 26.88 €
Под 5 €: 1
Има ли уреди? True
Първият чай: Зелен чай сенча
Какао няма
  • Sum и Average събират и усредняват това, което дава ламбдата.
  • Count(правило) брои отговарящите, Any(правило) казва дали има поне един.
  • First(правило) връща първия отговарящ, а ако няма такъв, хвърля InvalidOperationException. FirstOrDefault в същия случай връща null. Ползвай го, когато е нормално да няма резултат, и провери за null.

Кой метод дава общата стойност на каталога?

decimal total = catalog.(p => p.Price);
По-точно, за любопитните
  • GroupBy разделя елементите на групи по ключ. Всяка група има Key и се държи като списък, затова върху нея работят Count и Sum. Типът на групата е дълъг (IGrouping<string, Product>), затова тук е удобен var:
foreach (var group in catalog.GroupBy(p => p.Category))
{
Console.WriteLine(group.Key + ": брой " + group.Count() + ", общо " + group.Sum(p => p.Price) + " €");
}
Изход
кафе: брой 2, общо 56.40 €
чай: брой 2, общо 14.00 €
уреди: брой 1, общо 64.00 €
  • Същата заявка може да се напише и със синтаксис на заявка, който прилича на SQL. Компилаторът го превръща в Where, OrderBy и Select:
List<string> teas = (from p in catalog
where p.Category == "чай"
orderby p.Price
select p.Name).ToList();
Console.WriteLine(string.Join(", ", teas));
Изход
Мента, Зелен чай сенча
  • Мързеливо изпълнение. Where и Select не обхождат нищо, докато някой не поиска резултата: ToList, foreach, Sum, string.Join. Дотогава заявката е само рецепта, от тип IEnumerable<int> („нещо, през което можеш да минеш“). Затова тук 130 влиза в резултата, макар да е добавено след Where:
List<int> prices = new List<int> { 12, 75 };
IEnumerable<int> expensive = prices.Where(p => p >= 50);
prices.Add(130);
Console.WriteLine(string.Join(", ", expensive));
Изход
75, 130
  • Ако ти трябва резултатът към даден момент, извикай ToList() веднага: тогава списъкът е готов и по-късните промени не го засягат.
  • Цените тук са decimal: при пари double дава грешки от закръгляне. Sum пази двата знака след точката: 134.40. Числата се печатат с точка, защото проверката на кода работи без български регионални настройки; на компютър с тях ще видиш 134,40.

Упражнение

Количка, склад и отчет

Реши задачите в редактора си. Ползвай класа Product от урока.

1. Количка. Сложи в List<Product> еспресо (24,90 €), мента (4,20 €) и кафемелачка (64,00 €). Отпечатай броя и общата сума. После намери кафемелачката по Id с First, махни я и отпечатай пак:

Изход
Продукти: 3, общо 93.10 €
Махнах: Кафемелачка
Продукти: 2, общо 29.10 €

2. Склад. Речник код → количество: C-101 12 бр., T-205 30 бр., G-300 2 бр. Напиши метод Sell(stock, code, quantity), който намалява количеството и връща текст за резултата. Ако кода го няма или количеството не стига, речникът не се променя. Продай 5 от C-101, 3 от G-300 и 1 от X-999, после отпечатай склада:

Изход
C-101: продадени 5, остават 7
G-300: има само 2
Няма продукт X-999
C-101: 7
T-205: 30
G-300: 2

3. Отчет. С каталога от урока отпечатай трите най-скъпи продукта и сумата на цените по категория. Take(3) взима първите три елемента. За сумите ползвай речник категория → сума (или GroupBy от „По-точно“):

Изход
Най-скъпите три:
Кафемелачка 64.00 €
Колумбия на зърна 31.50 €
Еспресо на зърна 24.90 €
По категории:
кафе: 56.40 €
чай: 14.00 €
уреди: 64.00 €

Тест

Провери се

1. Защо за количка в магазин List<Product> е по-удобен от масив Product[]?
2. stock е Dictionary<string, int> без ключ "X-999". Какво прави stock["X-999"]?
3. Какво означава p => p.Price > 50?
4. В каталога няма продукт от категория „какао“. Какво връща catalog.FirstOrDefault(p => p.Category == "какао")?
5. Каква е разликата между Count и Count(p => p.Price < 5) на списък?

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