Lab 15
Functional C#
Func<>, Action<>, замикання, методи розширення
Лаба 15 — Функціональне програмування у C#
Мета
Зрозуміти, як у C# методи можна передавати як значення — зберігати у змінних, передавати в параметри, комбінувати між собою. Навчитися будувати гнучкий код через Func<>, Action<>, замикання та методи розширення.
Контекст
Відкрийте будь-який менеджер у проєкті — AppointmentManager, AnalyticsManager — і знайдіть місця, де фільтруються прийоми: умови написані безпосередньо всередині методу. Потрібна нова умова — треба змінити метод або написати новий.
Проблема: логіка, що змінюється, захардкоджена всередині логіки, що не змінюється. Рішення — зробити умову параметром: замість умови всередині методу передати Func<Appointment, bool> predicate ззовні. Саме так влаштований .Where() у LINQ: він не знає вашу умову заздалегідь — він отримує її як параметр.
Структура проєкту на початку лаби
Це результат Лаби 14 — стан main після її злиття:
oop-course/ ← гілка main (після злиття Лаби 14)
├── .gitignore
├── oop-course.slnx
└── ClinicApp/
├── ClinicApp.csproj
├── Program.cs
├── Clinic.cs
├── Enums/ (4 файли)
├── Models/ (16 файлів)
├── Managers/ (10 файлів)
├── Utils/ (12 файлів)
├── Interfaces/ (4 файли)
├── Comparators/ (4 файли)
├── Attributes/ (3 файли)
└── Events/ (4 файли)Структуру наприкінці лаби (з позначками, що створюється і змінюється в кожній задачі) наведено в розділі «Структура проєкту наприкінці лаби» перед перевіркою.
`Func<>`, `Action<>`, замикання, методи розширення
Func<T, TResult> — тип для «методу, що приймає T і повертає TResult». Func<Appointment, bool> — функція, що перевіряє прийом і повертає true/false (предикат).
Action<T> — тип для «методу, що приймає T і нічого не повертає». Action<Appointment> — дія над прийомом: вивести, позначити оплаченим, надіслати сповіщення.
Func<Appointment, bool> isUrgent = a => a is UrgentAppointment;
Action<Appointment> print = a => Console.WriteLine(a);Замикання — лямбда «захоплює» змінну з навколишнього коду і зберігає посилання на неї, а не копію значення:
decimal threshold = 300;
Func<Appointment, bool> expensive = a => a.GetCost() > threshold;Якщо пізніше threshold зміниться — зміниться і результат лямбди. У Задачі 3 ви побачите, як замикання на неправильну змінну ламає логіку.
Метод розширення — статичний метод у статичному класі, перший параметр якого позначено this. Після цього метод викликається так, ніби завжди існував у типі: appointments.Unpaid(). Саме так влаштовані всі LINQ-методи.
Що нового дозволено (і тільки воно)
- змінні й параметри типів
Func<...>іAction<...>; - лямбди, що зберігаються у змінних і полях, замикання;
- власні методи розширення (
staticклас, параметрthis); - методи, що повертають
this(fluent-ланцюг).
Крок 1. Гілка
Робочий процес (повністю — Git Воркшоп): лаба = гілка
Lab-XXвідmain, коміт на кожне завдання (LabXX TaskYY), у кінці — злиття вmain.
Проєкт ClinicApp/ уже існує. Тут лише нова гілка від main:
git checkout main
git checkout -b Lab-15Коміт — на кожне завдання (Lab15 TaskNN).
Ваш домен
За замовчуванням виконуйте завдання як написано (домен «клініка»). Для власного домену дивіться таблицю «Адаптація до вашого домену» в кінці кожного завдання.
Як користуватися підказками
Підказки — напрям думки, не готовий код. «Що реалізувати» і «Специфікація» кажуть що; підказки — як міркувати; блок 📖 Документація — де прочитати синтаксис. Спершу документація і власна спроба.
Задача 1. Методи розширення для записів ⭐
Умова
Щоб отримати «неоплачені майбутні прийоми», зараз щоразу пишуть LINQ-умову вручну. Потрібна в п'яти місцях — повторюється п'ять разів, змінилась — правити всюди. Назвіть кожну умову одним словом і напишіть її один раз — методом розширення.
Що реалізувати:
- Створити теку
ClinicApp/Extensions/і статичний класAppointmentExtensions(простір іменClinicApp.Extensions). - Реалізувати сім методів розширення на
IEnumerable<Appointment>зі специфікації.
Специфікація
| Метод | Повертає | Умова / результат |
|---|---|---|
Unpaid() |
IEnumerable<Appointment> |
не оплачено і не скасовано |
Upcoming() |
IEnumerable<Appointment> |
IsUpcoming == true |
ByDoctor(int doctorId) |
IEnumerable<Appointment> |
DoctorId збігається |
ByPatient(int patientId) |
IEnumerable<Appointment> |
PatientId збігається |
Overdue() |
IEnumerable<Appointment> |
дата в минулому, не скасовано, не оплачено |
CostAbove(decimal minCost) |
IEnumerable<Appointment> |
GetCost() > minCost |
TotalCost() |
decimal |
сума GetCost() |
Приклад
decimal debt = clinic.Appointments.GetAll().Unpaid().TotalCost();
var big = clinic.Appointments.GetAll().Upcoming().CostAbove(500);Підказки
- Клас і методи —
static; перший параметр кожного методу —this IEnumerable<Appointment> source. - Усередині — звичайний LINQ (
Where,Sum) надsource. TotalCost()— «термінальний» метод: повертає число, а не продовження ланцюга.- У
CostAboveпараметрminCostпотрапляє всередину лямбди — це замикання: лямбда пам'ятаєminCostі після завершення методу.
📖 Документація:
Адаптація до вашого домену
| Клініка | Готель | Ресторан | Університет | Прокат авто | Бібліотека | Спортзал |
|---|---|---|---|---|---|---|
AppointmentExtensions |
BookingExtensions |
ReservationExtensions |
EnrollmentExtensions |
RentalExtensions |
LoanExtensions |
SessionExtensions |
Unpaid, Overdue, CostAbove |
Unpaid, Overdue, CostAbove |
Unpaid, Overdue, CostAbove |
Unpaid, Overdue, FeeAbove |
Unpaid, Overdue, CostAbove |
Overdue, FineAbove |
Unpaid, Overdue, CostAbove |
Коміт
git add ClinicApp/Extensions/AppointmentExtensions.cs
git commit -m "Lab15 Task01"Задача 2. Методи розширення для пацієнтів і лікарів ⭐
Умова
Фільтрація пацієнтів за групою крові чи лікарів за спеціальністю розкидана по коду або відсутня. Винесіть ці умови в методи розширення так само, як у Задачі 1.
Що реалізувати:
- Статичний клас
PatientExtensionsуClinicApp/Extensions/з трьома методами наIEnumerable<Patient>. - Статичний клас
DoctorExtensionsуClinicApp/Extensions/з трьома методами наIEnumerable<Doctor>.
Специфікація
| Клас | Метод | Умова |
|---|---|---|
PatientExtensions |
Adults() |
вік 18 і більше |
PatientExtensions |
ByBloodType(BloodType bloodType) |
збіг групи крові |
PatientExtensions |
WithAppointments(IEnumerable<Appointment> appointments) |
у пацієнта є хоч один запис |
DoctorExtensions |
BySpeciality(Speciality speciality) |
збіг спеціальності |
DoctorExtensions |
Available() |
IsAvailableNow == true |
DoctorExtensions |
WithAppointments(IEnumerable<Appointment> appointments) |
у лікаря є хоч один запис |
Приклад
var adults = clinic.Patients.GetAll().Adults();
var busy = clinic.Doctors.GetAll().WithAppointments(clinic.Appointments.GetAll());Підказки
WithAppointmentsперевіряє для кожного пацієнта (лікаря), чи є серед прийомів хоч один його —Any.- Колекція прийомів передається параметром, а не береться з клініки напряму: метод розширення не повинен знати, звідки беруться дані.
📖 Документація:
Адаптація до вашого домену
| Клініка | Готель | Ресторан | Університет | Прокат авто | Бібліотека | Спортзал |
|---|---|---|---|---|---|---|
Adults(), ByBloodType() |
Adults(), ByRoomType() |
Adults(), ByCategory() |
ByFaculty() |
ByCarClass() |
ByGenre() |
ByFitnessLevel() |
BySpeciality(), Available() |
ByDepartment(), OnDuty() |
ByCuisine(), OnShift() |
BySubject(), Available() |
ByBrand(), Available() |
BySection(), OnDuty() |
ByTrainingType(), Available() |
Коміт
git add ClinicApp/Extensions/PatientExtensions.cs ClinicApp/Extensions/DoctorExtensions.cs
git commit -m "Lab15 Task02"Задача 3. `AppointmentFilter` — складання умов з частин ⭐⭐
Умова
Користувач хоче відфільтрувати прийоми за кількома умовами одразу — наприклад, «термінові і не оплачені». Зараз умови зліплені в одну лямбду, їх не можна скласти з окремих частин. Напишіть клас, що збирає предикати по одному і комбінує їх через «і», «або», «не».
Що реалізувати:
- Клас
AppointmentFilterуClinicApp/Managers/. Зберігає одне поле — поточний комбінований предикатFunc<Appointment, bool>?; спочаткуnull(порожній фільтр пропускає все). - Методи зі специфікації.
Add,And,Or,Negateповертаютьthis(fluent-ланцюг).
Специфікація
| Метод | Дія |
|---|---|
Add(Func<Appointment, bool> predicate) |
додає умову через «і»; якщо фільтр порожній — умова стає першою |
And(Func<Appointment, bool> predicate) |
те саме, що Add (для читабельності ланцюга) |
Or(Func<Appointment, bool> predicate) |
додає умову через «або» |
Negate() |
заперечує поточну комбіновану умову |
Apply(IEnumerable<Appointment> source) |
порожній фільтр — уся колекція; інакше — відібрані записи |
Reset() |
очищає фільтр |
Приклад
var filter = new AppointmentFilter();
var result = filter
.Add(a => a is UrgentAppointment)
.And(a => !a.IsPaid)
.Apply(clinic.Appointments.GetAll());Підказки
- Критична пастка — замикання на поле. Якщо в новій лямбді звернутися до поля з поточним предикатом, лямбда захопить саме поле, а не його поточне значення. Коли вона виконається, поле вже міститиме цю саму нову лямбду — і вона викличе сама себе: нескінченна рекурсія.
- Правильно: перед створенням нової лямбди скопіюйте поточний предикат у локальну змінну і використовуйте в лямбді її — локальна змінна після створення вже не змінюється.
OrіNegateбудуються так само, лише з||та!.return thisдозволяє писати виклики ланцюгом — так само, якWithCostStrategyчи LINQ.
📖 Документація:
Адаптація до вашого домену
| Клініка | Готель | Ресторан | Університет | Прокат авто | Бібліотека | Спортзал |
|---|---|---|---|---|---|---|
AppointmentFilter |
BookingFilter |
ReservationFilter |
EnrollmentFilter |
RentalFilter |
LoanFilter |
SessionFilter |
Коміт
git add ClinicApp/Managers/AppointmentFilter.cs
git commit -m "Lab15 Task03"Задача 4. `AppointmentProcessor` — набір дій над записами ⭐⭐
Умова
Щоб «для кожного запису зробити X», пишуть foreach із дією всередині. А якщо дій кілька і вони визначаються під час роботи програми? Напишіть клас, що накопичує дії і виконує їх усі разом.
Що реалізувати:
- Клас
AppointmentProcessorуClinicApp/Managers/: зберігає список дійList<Action<Appointment>>. - Методи зі специфікації.
Run,RunIf,Combineповертаютьthis.
Специфікація
| Метод | Дія |
|---|---|
Run(Action<Appointment> action) |
додає дію в список |
RunIf(Func<Appointment, bool> predicate, Action<Appointment> action) |
додає одну дію, що виконує action лише для записів, які задовольняють predicate |
Combine(Action<Appointment> first, Action<Appointment> second) |
додає одну дію, що викликає first, потім second |
Execute(IEnumerable<Appointment> source) |
для кожного запису виконує всі дії зі списку |
Clear() |
очищає список дій |
Приклад
var processor = new AppointmentProcessor()
.Run(a => Console.WriteLine(a))
.RunIf(a => a is UrgentAppointment, a => Console.WriteLine(" ⚠ терміновий"));
processor.Execute(clinic.Appointments.GetAll());Підказки
RunIfіCombineне виконують нічого одразу — вони створюють нову лямбду і кладуть її у список. Перевірка умови відбувається пізніше, під часExecute.Execute— два вкладені цикли: зовнішній по записах, внутрішній по діях.- Повторний
Executeвиконає дії ще раз: список не очищається сам. Для цього єClear().
📖 Документація:
Адаптація до вашого домену
| Клініка | Готель | Ресторан | Університет | Прокат авто | Бібліотека | Спортзал |
|---|---|---|---|---|---|---|
AppointmentProcessor |
BookingProcessor |
ReservationProcessor |
EnrollmentProcessor |
RentalProcessor |
LoanProcessor |
SessionProcessor |
Коміт
git add ClinicApp/Managers/AppointmentProcessor.cs
git commit -m "Lab15 Task04"Задача 5. `AppointmentPipeline` — фасад «фільтр → дія» ⭐
Умова
AppointmentFilter знає, як фільтрувати, AppointmentProcessor — як обробляти, але вони не пов'язані: порядок «спершу фільтр, потім дії» кожен складає сам. Напишіть фасад — один клас, що ховає обидва і дає простий інтерфейс «опиши умови, опиши дії, запусти».
Що реалізувати:
- Клас
AppointmentPipelineуClinicApp/Managers/з двома полями —AppointmentFilterіAppointmentProcessor, обидва створюються в конструкторі. - Методи зі специфікації.
Специфікація
| Метод | Дія | Повертає |
|---|---|---|
Filter(Func<Appointment, bool> predicate) |
додає умову у фільтр | this |
Then(Action<Appointment> action) |
додає дію в процесор | this |
Execute(IEnumerable<Appointment> source) |
фільтрує, один раз матеріалізує результат у масив, запускає процесор на ньому | int — кількість оброблених |
Reset() |
очищає і фільтр, і процесор |
Приклад
int count = pipeline
.Filter(a => !a.IsPaid)
.Filter(a => a.IsUpcoming)
.Then(a => Console.WriteLine(a))
.Execute(allAppointments);
Console.WriteLine($"Оброблено: {count} записів.");Підказки
- Без матеріалізації (
ToArray()) фільтр виконувався б двічі: раз для обробки, раз для підрахунку. - Фасад нічого не вміє сам — він лише делегує своїм двом полям.
📖 Документація:
Адаптація до вашого домену
| Клініка | Готель | Ресторан | Університет | Прокат авто | Бібліотека | Спортзал |
|---|---|---|---|---|---|---|
AppointmentPipeline |
BookingPipeline |
ReservationPipeline |
EnrollmentPipeline |
RentalPipeline |
LoanPipeline |
SessionPipeline |
Коміт
git add ClinicApp/Managers/AppointmentPipeline.cs
git commit -m "Lab15 Task05"Задача 6. Меню «Фільтри» ⭐
Умова
Підключіть усе до клініки і меню.
Що реалізувати:
- У
Clinic.csдодати властивістьPipelineтипуAppointmentPipelineі створити її в конструкторі. - На початку
Program.csдодатиusing ClinicApp.Extensions;. - У головному меню додати пункт
12— «Фільтри — Func, Action, Pipeline». - У
Program.csдодати функціюFunctionalMenu(Clinic clinic)з вісьмома пунктами зі специфікації.
Специфікація
| Пункт | Що показує | Що використовує |
|---|---|---|
1 |
Неоплачені прийоми і їх загальна сума | Unpaid() + TotalCost() |
2 |
Майбутні записи дорожчі за N грн (N питає програма) | Upcoming().CostAbove(n) |
3 |
Повнолітні пацієнти | Adults() |
4 |
Лікарі з хоч одним записом | WithAppointments(...) |
5 |
Термінові і майбутні | AppointmentFilter: Add(...).And(...) |
6 |
Термінові або прострочені | AppointmentFilter: Add(...).Or(...) |
7 |
Дві дії над кожним записом | AppointmentProcessor.Combine(вивід, запис у лог) |
8 |
Повний пайплайн: фільтр → дія | clinic.Pipeline.Filter(...).Then(...).Execute(...), далі Оброблено: N записів. |
0 |
Назад |
Приклад
── Фільтри ─────────────────────
Оберіть: 2
Поріг, грн: 300
[9] Терміновий (біль у грудях) | … | 450.00 грн
[10] Консультація спеціаліста: кардіологія | … | 780.00 грнПідказки
clinic.Pipelineзберігає умови між викликами — перед кожним запуском пункту 8 викликайтеReset().- Для пункту 7 дію «запис у лог» зберіть із
clinic.Logger.LogInfo(...).
📖 Документація:
Адаптація до вашого домену
| Клініка | Готель | Ресторан | Університет | Прокат авто | Бібліотека | Спортзал |
|---|---|---|---|---|---|---|
12. Фільтри |
12. Фільтри |
12. Фільтри |
12. Фільтри |
12. Фільтри |
12. Фільтри |
12. Фільтри |
Коміт
git add ClinicApp/Clinic.cs ClinicApp/Program.cs
git commit -m "Lab15 Task06"Структура проєкту наприкінці лаби
Так має виглядати ClinicApp/, коли всі завдання виконано:
oop-course/ ← гілка Lab-15 (після злиття — main)
├── .gitignore
├── oop-course.slnx
└── ClinicApp/
├── ClinicApp.csproj
├── Program.cs ✏ Т6
├── Clinic.cs ✏ Т6
├── Enums/ (4 файли)
├── Models/ (16 файлів)
├── Managers/
│ ├── AppointmentFilter.cs 🆕 Т3
│ ├── AppointmentProcessor.cs 🆕 Т4
│ ├── AppointmentPipeline.cs 🆕 Т5
│ └── … ще 10 файлів без змін
├── Utils/ (12 файлів)
├── Interfaces/ (4 файли)
├── Comparators/ (4 файли)
├── Attributes/ (3 файли)
├── Events/ (4 файли)
└── Extensions/
├── AppointmentExtensions.cs 🆕 Т1
├── DoctorExtensions.cs 🆕 Т2
└── PatientExtensions.cs 🆕 Т2Легенда: 🆕 — новий файл · ✏ — змінено вміст · Тn — номер задачі, у якій ви працюєте з файлом. Файли без позначки лишились такими, як були після Лаби 14.
Назви файлів наведено для домену «клініка»; у власному домені назви ваші — важливо, що саме створюється й змінюється.
Перевірка перед здачею
dotnet build ClinicApp
dotnet run --project ClinicAppПереконайтесь, що:
- Структура проєкту збігається зі схемою вище
-
12. Фільтри→1: неоплачені прийоми і загальна сума -
2з порогом300: тільки майбутні записи дорожчі за 300 грн -
5показує записи, що одночасно термінові і майбутні;6— більше записів (об'єднання умов) -
8виводить кількість оброблених записів; повторний запуск дає той самий результат - Комбінований фільтр із 3+ умов не падає з
StackOverflowException -
8. Аналітикаі11. Звітипрацюють, як раніше
Питання для самоперевірки
- Чим
Func<Appointment, bool>відрізняється від методуbool Check(Appointment a)? Що в них спільного? - Яка різниця між
Action<Appointment>іFunc<Appointment, bool>? Коли використовується кожен? - Що станеться в
AppointmentFilter.Add, якщо лямбда звертатиметься до поля напряму, без локальної копії? - Метод розширення можна викликати і як звичайний статичний:
AppointmentExtensions.Unpaid(source). Чому краще писатиsource.Unpaid()? - Навіщо
Combine, якщо можна двічі викликатиRun? - Що означає патерн «фасад»? Що
AppointmentPipelineховає від зовнішнього коду?
Статус гілки
Після всіх 6 завдань (кожне — окремий коміт Lab15 TaskNN на гілці Lab-15):
git push -u origin Lab-15
git checkout main
git merge --no-ff Lab-15 -m "Merge Lab-15: Functional C#"
git pushНаступна лаба:
git checkout main→git checkout -b Lab-16.