Lab 14
LINQ
Where, Select, GroupBy, Join, агрегати
Лабораторна робота №14. LINQ
Мета
Зрозуміти різницю між імперативним і декларативним стилем роботи з колекціями. Навчитись виражати запити до даних через LINQ-оператори: .Where, .Select, .GroupBy, .Join, .OrderBy, .Any, .Sum, .Count, .Max, .Take, .Distinct — і розуміти коли і чому кожен з них доречний.
Гілка
feature/linqЧому виник LINQ
Відкрийте src/Managers/AnalyticsManager.cs. Знайдіть метод ComputeDoctorStats. Він рахує статистику по кожному лікарю: скільки прийомів, яка виручка, коли останній прийом. Подивіться на код — скільки рядків займає ця логіка? Що саме вона робить?
Тепер прочитайте той самий алгоритм словами:
"Для кожного лікаря — знайди всі його прийоми, порахуй їх кількість, склади вартість, знайди найпізнішу дату."
Одне речення. А у коді — десятки рядків із вкладеними циклами і тимчасовими змінними.
LINQ (Language Integrated Query) — це набір методів що дозволяє писати запити до колекцій так само лаконічно, як ви їх описуєте словами. Це не нова мова — це бібліотека методів розширення над IEnumerable<T>, яку ви вже знаєте з Lab 10.
Головна відмінність від циклів:
| Цикл (imperative) | LINQ (declarative) |
|---|---|
| Описує як перебирати | Описує що отримати |
| Тимчасові змінні-лічильники | Результат — вираз |
| Важко читати з першого погляду | Читається майже як речення |
Завдання 1. Рефакторинг `AnalyticsManager` ⭐⭐
Що зараз
Відкрийте src/Managers/AnalyticsManager.cs і уважно прочитайте обидва методи. Спробуйте відповісти:
- Яка роль змінних-лічильників (
count,revenue,lastDate)? - Що відбувається якщо лікар не має жодного прийому?
- Що означає умова
if (a.DoctorId == d.Id)?
Ідея рефакторингу
Кожен for-цикл по прийомах всередині циклу по лікарях — це по суті фільтрація (Where) і агрегація (Count, Sum, Max). LINQ дозволяє виразити це в один вираз.
Для кожного лікаря ми хочемо:
- Відібрати його прийоми —
.Where(a => a.DoctorId == d.Id) - Порахувати кількість —
.Count() - Скласти виручку —
.Sum(a => a.GetCost()) - Знайти останній прийом —
.Max(a => a.ScheduledAt)
Всіх лікарів разом — трансформувати через .Select().
Важлива деталь
Перед тим як викликати .Max(), перевірте чи є хоч один прийом через .Any(). Якщо прийомів немає — .Max() кине виняток на порожній послідовності. У такому разі повертайте DateTime.MinValue як "немає дати".
Що потрібно зробити
Перепишіть обидва методи — ComputeDoctorStats і ComputePatientStats — замінивши for-цикли на LINQ-ланцюг. Сигнатури методів і тип результату не змінюються.
Після переписування запустіть програму, відкрийте пункт "8. Аналітика" і переконайтесь що всі сортування дають ті самі результати що й раніше.
Ключові питання
- Чому
.Select()всередині містить блок{ var own = ...; return ...; }а не просто вираз? - Чи змінюється поведінка якщо прибрати
Appointment[] appointments = _appointments.GetAll()на початку і звертатись до_appointments.GetAll()кожного разу всередині.Select()? Чому це погано?
Завдання 2. DTO `SpecialityReport` ⭐
Навіщо окремий клас
Звіт по спеціальностях повертатиме кілька числових полів для кожної спеціальності одночасно: кількість лікарів, кількість прийомів, загальна виручка. Можна було б повертати tuple — але іменований клас читається краще і дозволяє додати ToString().
Що потрібно зробити
Створіть src/Models/SpecialityReport.cs.
Клас повинен містити чотири readonly-властивості: Speciality (тип Speciality з enum), DoctorCount, AppointmentCount, TotalRevenue. Всі заповнюються через конструктор.
Реалізуйте ToString() так, щоб результат читався в одному рядку і містив усі чотири значення. Подивіться на DoctorStats.ToString() як на зразок форматування.
Завдання 3. `ReportManager` — нові звіти через LINQ ⭐⭐⭐
Чому новий клас, а не додавати в `AnalyticsManager`
AnalyticsManager вже має чітку відповідальність — статистика по лікарях і пацієнтах для сортування. Нові звіти мають іншу природу: групування по спеціальностях, пошук топ-N, місячна виручка. Це окремий модуль звітності.
Створіть src/Managers/ReportManager.cs. Конструктор приймає ті самі три залежності що й AnalyticsManager.
Метод `GetSpecialityStats()` — GroupBy ⭐⭐
Задача: для кожної спеціальності показати скільки лікарів з цією спеціальністю є у клініці, скільки прийомів вони провели разом і яка загальна виручка.
Що таке GroupBy. Уявіть що у вас список лікарів і ви раскладаєте їх у стопки за спеціальністю. .GroupBy(d => d.Speciality) повертає колекцію груп — кожна група має Key (спеціальність) і елементи (лікарі з цією спеціальністю).
Алгоритм:
- Згрупувати лікарів за
Speciality - Для кожної групи: взяти ID всіх лікарів групи, знайти в масиві прийомів ті що належать цим лікарям
- Зібрати
SpecialityReportзg.Key,g.Count(), кількістю та сумою прийомів - Відсортувати за виручкою спадно
Підказка: щоб відфільтрувати прийоми для групи лікарів, отримайте їхні ID у масив і використовуйте .Contains().
Метод `FindBusiestDoctorName()` — OrderByDescending + FirstOrDefault ⭐
Задача: знайти лікаря з найбільшою кількістю записів і повернути його ім'я.
Підхід: відсортувати лікарів за кількістю їхніх прийомів спадно і взяти першого. .FirstOrDefault() повертає null якщо список порожній — тому тип повернення string?.
Подумайте: .Count(a => a.DoctorId == d.Id) всередині .OrderByDescending() — це ефективно? Для невеликої системи — так. Що можна зробити щоб уникнути повторного перебору?
Метод `GetPatientsWithMultipleVisits(int minVisits)` — GroupBy + Join ⭐⭐
Задача: знайти пацієнтів що мають щонайменше minVisits записів і повернути їхні імена.
Що таке Join. Якщо у вас є дві колекції — groups (згруповані прийоми) і patients (список пацієнтів) — .Join() дозволяє їх з'єднати за спільним ключем, як JOIN у SQL.
Алгоритм:
- Згрупувати прийоми за
PatientId - Залишити тільки групи де кількість
>= minVisits - З'єднати з масивом пацієнтів: ключ з групи —
g.Key(PatientId), ключ з пацієнта —p.Id - Результат —
p.FullName
Метод `GetTopEarners(int n)` — OrderByDescending + Take ⭐
Задача: топ-N лікарів за виручкою.
.Take(n) — обрізає послідовність до перших N елементів. Ставиться після сортування.
Логіка формування DoctorStats для кожного лікаря — така сама як у AnalyticsManager після рефакторингу в Завданні 1. Не копіюйте код — подумайте чи є спосіб уникнути дублювання. (Якщо ні — скопіюйте, але усвідомте проблему.)
Метод `HasAnyUrgentAppointments()` — Any ⭐
Задача: чи є в системі хоч один терміновий прийом?
.Any(predicate) — повертає true як тільки знаходить перший елемент що відповідає умові. Це ефективніше ніж .Count() > 0 — не перебирає всю колекцію.
Перевірте тип через is: a is UrgentAppointment.
Метод `GetActiveSpecialities()` — Distinct + OrderBy ⭐
Задача: список унікальних спеціальностей лікарів що є у клініці.
.Distinct() — прибирає дублікати. Якщо є три кардіологи — Cardiology з'явиться один раз. Для enum-значень порівняння відбувається за значенням — жодних додаткових налаштувань не потрібно.
Метод `GetMonthlyRevenue()` — GroupBy з анонімним типом ⭐⭐
Задача: виручка клініки по кожному місяцю.
Анонімний тип у GroupBy. Щоб згрупувати одночасно за роком і місяцем, ключем GroupBy стає анонімний об'єкт:
.GroupBy(a => new { a.ScheduledAt.Year, a.ScheduledAt.Month })Потім g.Key.Year і g.Key.Month.
Value tuple як тип повернення. Замість окремого DTO поверніть кортеж:
IEnumerable<(int Year, int Month, decimal Total)>У Select формуєте: (g.Key.Year, g.Key.Month, g.Sum(a => a.GetCost())).
Відсортуйте за роком, потім за місяцем — .OrderBy(...).ThenBy(...).
Завдання 4. Підключення до `Clinic` і `Program` ⭐
Clinic.cs
ReportManager — новий компонент системи. За аналогією з AnalyticsManager додайте публічну readonly-властивість і ініціалізуйте її в конструкторі. Передайте ті самі три менеджери: Appointments, Doctors, Patients.
Program.cs
Додайте пункт 11 у головне меню. Реалізуйте ReportsMenu(Clinic clinic) — підменю з 7 пунктами по одному на кожен метод ReportManager.
Зверніть увагу на пункт "3. Пацієнти з кількома візитами": запитайте у користувача мінімальну кількість візитів перед викликом методу.
Зверніть увагу на пункт "4. Топ-3 лікарів": передайте константу 3 у GetTopEarners(3) і виводьте з нумерацією.
Перевірка
dotnet run --project srcПеревірте у розділі 11. Звіти:
- Пункт 1: показує спеціальності з кількістю лікарів і виручкою, відсортовані за виручкою
- Пункт 2: виводить ім'я лікаря що має найбільше записів
- Пункт 3 при введенні
1: всі пацієнти що мають хоч один запис - Пункт 4: три лікарі відсортовані за виручкою спадно
- Пункт 5: повідомлення про наявність або відсутність термінових записів
- Пункт 7: рядки виду
2026/05 — 1350.00 грн
Також перевірте що пункт 8. Аналітика (з Lab 10) продовжує працювати коректно після рефакторингу AnalyticsManager.
Питання для самоперевірки
- Чим
.Where()відрізняється від.Select()? Що кожен з них повертає? - Чому
.Max()на порожній послідовності кидає виняток, а.FirstOrDefault()— ні? - Що таке
g.Keyу.GroupBy()? Якого типу він уGetSpecialityStatsі уGetMonthlyRevenue? .Any()і.Count() > 0дають однаковий результат. В чому різниця з точки зору продуктивності?.Take(n)стоїть після.OrderByDescending(). Що станеться якщо поміняти їх місцями?- У
GetMonthlyRevenueключ GroupBy — анонімний типnew { Year, Month }. Як C# порівнює два такі об'єкти на рівність?