Lab 13
Events & Delegates
EventArgs, event, обробники
Лаба 13 — Events & Delegates (Події та делегати)
Мета
Зрозуміти проблему жорсткого зв'язування між класами та навчитись її вирішувати через механізм подій. Опанувати delegate, EventArgs, event EventHandler<T>, підписку через += і побудову системи, де компоненти реагують на зміни, не знаючи один про одного.
Контекст
Відкрийте ClinicApp/Program.cs і подивіться на будь-який пункт меню. Після кожної дії ви побачите щось подібне:
clinic.Appointments.Book(patientId, doctorId, scheduledAt);
clinic.Logger.LogInfo($"Запис створено: пацієнт {patientId}...");Тобто кожна дія в меню вручну повідомляє Logger. Зараз слухач один — але що, якщо потрібно ще й оновити паспорт пацієнта чи вести статистику? Тоді після кожної дії буде три рядки, потім чотири, потім п'ять. Це жорстке зв'язування: Program.cs знає про всіх слухачів і мусить викликати кожного вручну.
Правильно, щоб менеджер просто повідомляв: «запис створено». А всі зацікавлені слухачі реагують самі — незалежно, не знаючи одне про одного. Це патерн Publisher–Subscriber, реалізований через механізм подій у C#.
Структура проєкту на початку лаби
Це результат Лаби 12 — стан main після її злиття:
oop-course/ ← гілка main (після злиття Лаби 12)
├── .gitignore
├── oop-course.slnx
└── ClinicApp/
├── ClinicApp.csproj
├── Program.cs
├── Clinic.cs
├── Enums/ (4 файли)
├── Models/ (15 файлів)
├── Managers/
│ ├── PatientManager.cs
│ ├── DoctorManager.cs
│ ├── AppointmentManager.cs
│ ├── GrowablePatientManager.cs
│ ├── MedicalRecordManager.cs
│ ├── BillingManager.cs
│ ├── Repository.cs
│ ├── AnalyticsManager.cs
│ └── TreatmentPlanManager.cs
├── Utils/
│ ├── ClinicLogger.cs
│ └── … ще 9 файлів без змін
├── Interfaces/ (4 файли)
├── Comparators/ (4 файли)
└── Attributes/ (3 файли)Структуру наприкінці лаби (з позначками, що створюється і змінюється в кожній задачі) наведено в розділі «Структура проєкту наприкінці лаби» перед перевіркою.
Що таке делегат, подія і `EventArgs`
Делегат — тип, що описує сигнатуру методу. Думайте про нього як про «контракт обробника»: «я очікую метод, що приймає object? sender і AppointmentEventArgs e і нічого не повертає». EventHandler<T> — вбудований у .NET делегат саме з такою сигнатурою: оголошувати власний не потрібно.
Подія (event) — поле типу делегата з обмеженнями: ззовні класу дозволено лише += і -=. Не можна присвоїти = null чи викликати подію напряму — це захищає від випадкового знищення всіх підписників.
EventArgs — базовий клас для «посилки з даними про подію». Повідомляючи про створення запису, менеджер передає AppointmentEventArgs з усіма деталями; підписник отримує цю посилку і робить з нею що потрібно.
Що нового дозволено (і тільки воно)
event EventHandler<T>, підписка+=/ відписка-=;- власні класи-нащадки
EventArgs; - безпечний виклик події
?.Invoke(...); - методи з тілом-виразом
=>для коротких обробників.
Досі заборонено: LINQ (Лаба 14), лямбди й Func/Action (Лаба 15).
Крок 1. Гілка
Робочий процес (повністю — Git Воркшоп): лаба = гілка
Lab-XXвідmain, коміт на кожне завдання (LabXX TaskYY), у кінці — злиття вmain.
Проєкт ClinicApp/ уже існує. Тут лише нова гілка від main:
git checkout main
git checkout -b Lab-13Коміт — на кожне завдання (Lab13 TaskNN).
Ваш домен
За замовчуванням виконуйте завдання як написано (домен «клініка»). Для власного домену дивіться таблицю «Адаптація до вашого домену» в кінці кожного завдання.
Як користуватися підказками
Підказки — напрям думки, не готовий код. «Що реалізувати» і «Специфікація» кажуть що; підказки — як міркувати; блок 📖 Документація — де прочитати синтаксис. Спершу документація і власна спроба.
Задача 1. Перша подія: `AppointmentBooked` ⭐⭐
Умова
AppointmentManager.Book() створює запис, але нікому про це не повідомляє — Program.cs мусить сам писати в лог після кожного виклику. Додайте першу подію і першого підписника, щоб побачити механіку.
Що реалізувати:
- Створити теку
ClinicApp/Events/і класAppointmentEventArgs : EventArgsз даними про запис (специфікація нижче). Усі властивості лише для читання, заповнюються в конструкторі. - У
AppointmentManagerоголосити подіюAppointmentBookedтипуEventHandler<AppointmentEventArgs>. - Піднімати
AppointmentBookedпісля кожного успішного створення запису — уBook,BookUrgentіBookSpecialist. - У
Program.csоголосити статичний обробникOnAppointmentBookedConsole, що виводить рядок[EVENT] Запис #N створено…, і підписати його до створення початкових даних.
Специфікація
Властивість AppointmentEventArgs |
Тип |
|---|---|
AppointmentId |
int |
PatientId |
int |
DoctorId |
int |
ScheduledAt |
DateTime |
Notes |
string (за замовчуванням "") |
Член AppointmentManager |
Опис |
|---|---|
event EventHandler<AppointmentEventArgs>? AppointmentBooked |
піднімається після успішного запису будь-якого типу |
Приклад
[EVENT] Запис #1 створено: пацієнт #1 → лікар #1, 16.10.2026 10:00
[EVENT] Запис #2 створено: пацієнт #2 → лікар #2, 16.10.2026 11:00Рядки з'являються автоматично — у коді меню немає жодного виклику обробника.
Підказки
- Знак
?у типі події означає, що підписників може не бути, і це нормально. - Піднімайте подію безпечним викликом
?.Invoke(this, args): якщо підписників немає — нічого не відбувається. Без?.порожня подія кинеNullReferenceException. - Якщо в Лабі 08 ви винесли спільну частину
Book/BookUrgent/BookSpecialistу приватний метод — підніміть подію там, і вона спрацює для всіх трьох. sender— об'єкт, що підняв подію (тут — менеджер). Передавайтеthis.Notesмає значення за замовчуванням""— запис може бути без приміток.
📖 Документація:
Адаптація до вашого домену
| Клініка | Готель | Ресторан | Університет | Прокат авто | Бібліотека | Спортзал |
|---|---|---|---|---|---|---|
AppointmentEventArgs |
BookingEventArgs |
ReservationEventArgs |
EnrollmentEventArgs |
RentalEventArgs |
LoanEventArgs |
SessionEventArgs |
AppointmentBooked |
BookingCreated |
ReservationCreated |
StudentEnrolled |
RentalCreated |
BookLoaned |
SessionBooked |
Коміт
git add ClinicApp/Events/AppointmentEventArgs.cs ClinicApp/Managers/AppointmentManager.cs ClinicApp/Program.cs
git commit -m "Lab13 Task01"Задача 2. Усі події системи і `ClinicLogger` як підписник ⭐⭐
Умова
Зараз Program.cs знає про Logger і пам'ятає викликати його після кожної дії. Новий пункт меню без такого виклику — і дія не потрапить у лог. Нехай натомість менеджери піднімають події, а Logger як підписник реагує сам — автоматично і завжди.
Що реалізувати:
- У
ClinicApp/Events/створитиPatientEventArgs,PaymentEventArgs,TreatmentPlanEventArgs(специфікація нижче). - Додати події в менеджери і піднімати їх у відповідних методах (таблиця подій нижче).
- У
TreatmentPlanManagerдодати методComplete(int planId): знаходить план, завершує його і піднімаєPlanCompleted. Пункт меню «Завершити план» (Лаба 11) тепер викликає цей метод. - У
ClinicLoggerдодати обробник для кожної події. Терміновий запис —LogWarningі додатковий рядок у файліalerts/urgent_{yyyy-MM-dd}.txt. - У
Clinic.csдодати приватний методSubscribeEvents(), що підписуєLoggerна всі події, і викликати його в кінці конструктора. - Прибрати з
Program.csручні викликиclinic.Logger.LogInfo(...)для дій, які тепер покриті подіями.
Специфікація
EventArgs |
Властивості |
|---|---|
PatientEventArgs |
PatientId, FullName |
PaymentEventArgs |
AppointmentId, Amount (decimal) |
TreatmentPlanEventArgs |
PlanId, PatientId, Diagnosis |
| Менеджер | Подія | Де піднімається |
|---|---|---|
AppointmentManager |
AppointmentCancelled |
Cancel(); у CancelAll() — для кожного скасованого запису |
AppointmentManager |
AppointmentCompleted |
Complete() |
AppointmentManager |
UrgentAppointmentBooked |
BookUrgent() — разом з AppointmentBooked |
PatientManager |
PatientAdded |
Add() |
BillingManager |
PaymentReceived |
PayAppointment(); сума — GetCost() |
TreatmentPlanManager |
PlanCompleted |
новий Complete(int planId) |
Кожна подія піднімається лише після успішної дії.
Приклад
[2026-10-15 11:00:02] [INFO ] Новий пацієнт #6: Марія Ткач
[2026-10-15 11:01:15] [INFO ] Запис #9 створено: пацієнт #6 → лікар #2
[2026-10-15 11:01:15] [WARN ] ТЕРМІНОВИЙ запис #9: біль у грудях
[2026-10-15 11:03:40] [INFO ] Оплата запису #9: 450.00 грнПідказки
- Нові
EventArgs— за тим самим зразком, щоAppointmentEventArgs: лише ті поля, що описують «що сталося». - Терміновий запис — теж запис, тож
BookUrgent()піднімає обидві події. Logger підписаний на обидві й залогує обидві — це задумано. - Обробник у
ClinicLogger— звичайний публічний метод із сигнатурою(object? sender, XxxEventArgs e); короткий можна записати з тілом-виразом=>. - Підписка — у
Clinic, а не вProgram.cs:Clinic— оркестратор, він знає всі менеджери й вирішує, хто на що підписаний. - Підписка можлива лише після створення і менеджерів, і
Logger— томуSubscribeEvents()викликається в кінці конструктора. - Теку
alerts/створіть черезDirectory.CreateDirectoryперед записом.
📖 Документація:
Адаптація до вашого домену
| Клініка | Готель | Ресторан | Університет | Прокат авто | Бібліотека | Спортзал |
|---|---|---|---|---|---|---|
PatientAdded |
GuestRegistered |
CustomerAdded |
StudentAdded |
ClientAdded |
ReaderAdded |
MemberAdded |
PaymentReceived |
InvoicePaid |
BillPaid |
FeePaid |
RentalPaid |
FinePaid |
MembershipPaid |
UrgentAppointmentBooked |
SuiteBooked |
PrivateRoomReserved |
IntensiveEnrolled |
PremiumRented |
ResearchLoaned |
PersonalTrainingBooked |
Коміт
git add ClinicApp/Events/ ClinicApp/Managers/ ClinicApp/Utils/ClinicLogger.cs ClinicApp/Clinic.cs ClinicApp/Program.cs
git commit -m "Lab13 Task02"Задача 3. `PatientPassportWriter` — другий незалежний підписник ⭐⭐⭐
Умова
Замовник просить: «при реєстрації пацієнта, після кожного завершеного прийому і завершеного плану лікування — генеруйте файл паспорта пацієнта». З подіями це один новий клас: менеджери вже піднімають потрібні події, достатньо підписати нового слухача. PatientManager, AppointmentManager, Program.cs не змінюються.
Що реалізувати:
- Клас
PatientPassportWriterуClinicApp/Utils/: отримуєClinicі теку для паспортів (за замовчуваннямpatients) через конструктор; теку створює в конструкторі. - Три публічні обробники — на
PatientAdded,AppointmentCompleted,PlanCompleted; усі викликають один приватний метод запису паспорта. - Метод запису перезаписує файл
patients/passport_{id}.txtповністю; якщо пацієнта не знайдено — нічого не пише. - У
Clinic.csдодати властивістьPassportі підписати три обробники вSubscribeEvents().
Специфікація
Секції паспорта (у такому порядку):
| Секція | Джерело |
|---|---|
| Особисті дані: ім'я, дата народження, вік, група крові, телефон | Patients |
| Медичні записи: окремо діагнози, аналізи, рецепти | MedicalRecords |
| Записи на прийом | Appointments |
| Плани лікування | TreatmentPlans |
| Заборгованість | Billing |
| Дата генерації паспорта | DateTime.Now |
Приклад
=== Паспорт пацієнта #1 ===
Згенеровано: 15.10.2026 11:20
Ім'я: Іван Петренко
Дата народження: 15.03.1985 (41 рік)
Група крові: A+ Телефон: (050) 123-4567
-- Діагнози --
I10: Гіпертонічна хвороба [хронічне]
-- Аналізи --
Холестерин: 6.2 ммоль/л (норма: < 5.2) ⚠ поза нормою
-- Рецепти --
Лізиноприл 10 мг × 30 днів (вранці)
-- Записи --
[1] Звичайний прийом | 16.10.2026 10:00 | Completed | 300.00 грн
-- Плани лікування --
[1] Гіпертонія | 30 днів | Active
Заборгованість: 0.00 грнПідказки
- Логіка генерації однакова для всіх трьох тригерів — не дублюйте її: обробники лише передають
PatientIdу спільний метод. - Перезапис (
StreamWriterзappend: false), а не дописування: паспорт — це знімок поточного стану, а не журнал. - Для розбиття медичних записів на типи —
isз оголошенням змінної (Лаба 06). Три окремі проходи для трьох секцій простіші за один прохід із кількомаif. - Ранній вихід
return, якщо пацієнта не знайдено, — щоб не створити порожній файл.
📖 Документація:
Адаптація до вашого домену
| Клініка | Готель | Ресторан | Університет | Прокат авто | Бібліотека | Спортзал |
|---|---|---|---|---|---|---|
PatientPassportWriter → passport_{id}.txt |
картка гостя | картка клієнта | залікова книжка | картка клієнта | формуляр читача | картка учасника |
Коміт
git add ClinicApp/Utils/PatientPassportWriter.cs ClinicApp/Clinic.cs
git commit -m "Lab13 Task03"Задача 4. `SessionEventTracker` — реакція на події з іншої підсистеми ⭐⭐⭐
Умова
Досі кожен підписник реагував у своїй зоні: Logger пише у файл, PassportWriter генерує документ. Але інколи реакція на подію зачіпає іншу підсистему: скасовано прийом → звільнився слот → варто перевірити чергу очікування (Лаба 09). Скасування — подія AppointmentManager, черга — у Clinic. Зв'яжіть їх без прямої залежності між менеджерами.
Що реалізувати:
- Клас
SessionEventTrackerуClinicApp/Utils/: отримуєClinicчерез конструктор і рахує події за сесію (лічильники нижче —publicзprivate set). - Обробник на кожну подію збільшує свій лічильник. Обробник скасування додатково перевіряє чергу: якщо вона не порожня — виводить
[ЧЕРГА] Слот звільнився. Наступний: …(без видалення з черги). - Метод
PrintSummary()— підсумок сесії в консоль; методSaveSummary(string path = "session_summary.txt")— той самий підсумок у файл, з датою й часом формування. - У
Clinic.csдодати властивістьTrackerі підписати всі його обробники вSubscribeEvents(). - У
Program.csпри виході (пункт0) перед збереженням сесії викликатиPrintSummary()іSaveSummary().
Специфікація
| Лічильник | Подія |
|---|---|
PatientsAdded |
PatientAdded |
AppointmentsBooked |
AppointmentBooked |
UrgentBooked |
UrgentAppointmentBooked |
AppointmentsCancelled |
AppointmentCancelled |
AppointmentsCompleted |
AppointmentCompleted |
PaymentsReceived |
PaymentReceived |
PlansCompleted |
PlanCompleted |
Приклад
[ЧЕРГА] Слот звільнився. Наступний: Олена Коваль
…
=== Підсумок сесії ===
Пацієнтів додано: 1
Записів створено: 4 (термінових: 1)
Скасовано: 1
Завершено: 2
Оплат: 2
Планів завершено: 1Підказки
- Наступного в черзі дивіться через
Peek(), а неDequeue(): трекер лише повідомляє, а прийом — дія користувача. PrintSummary()викликається явно вProgram.cs, а не через подію: це не реакція на зміну в системі, а дія користувача «підсумок перед виходом».LoggerіTrackerпідписані на ті самі події. Обробники викликаються в порядку підписки — подумайте, чи важливий цей порядок тут.
📖 Документація:
Адаптація до вашого домену
| Клініка | Готель | Ресторан | Університет | Прокат авто | Бібліотека | Спортзал |
|---|---|---|---|---|---|---|
| скасування → черга очікування | скасування броні → лист очікування | звільнився столик → черга | звільнилось місце → черга на курс | авто повернули → черга | книгу повернули → черга | звільнився тренер → черга |
Коміт
git add ClinicApp/Utils/SessionEventTracker.cs ClinicApp/Clinic.cs ClinicApp/Program.cs
git commit -m "Lab13 Task04"Структура проєкту наприкінці лаби
Так має виглядати ClinicApp/, коли всі завдання виконано:
oop-course/ ← гілка Lab-13 (після злиття — main)
├── .gitignore
├── oop-course.slnx
└── ClinicApp/
├── ClinicApp.csproj
├── Program.cs ✏ Т1 Т2 Т4
├── Clinic.cs ✏ Т2 Т3 Т4
├── Enums/ (4 файли)
├── Models/ (15 файлів)
├── Managers/
│ ├── PatientManager.cs ✏ Т2
│ ├── DoctorManager.cs
│ ├── AppointmentManager.cs ✏ Т1 Т2
│ ├── GrowablePatientManager.cs
│ ├── MedicalRecordManager.cs
│ ├── BillingManager.cs ✏ Т2
│ ├── Repository.cs
│ ├── AnalyticsManager.cs
│ └── TreatmentPlanManager.cs ✏ Т2
├── Utils/
│ ├── ClinicLogger.cs ✏ Т2
│ ├── PatientPassportWriter.cs 🆕 Т3
│ ├── SessionEventTracker.cs 🆕 Т4
│ └── … ще 9 файлів без змін
├── Interfaces/ (4 файли)
├── Comparators/ (4 файли)
├── Attributes/ (3 файли)
└── Events/
├── AppointmentEventArgs.cs 🆕 Т1
├── PatientEventArgs.cs 🆕 Т2
├── PaymentEventArgs.cs 🆕 Т2
└── TreatmentPlanEventArgs.cs 🆕 Т2Легенда: 🆕 — новий файл · ✏ — змінено вміст · Тn — номер задачі, у якій ви працюєте з файлом. Файли без позначки лишились такими, як були після Лаби 12.
Назви файлів наведено для домену «клініка»; у власному домені назви ваші — важливо, що саме створюється й змінюється.
Перевірка перед здачею
dotnet build ClinicApp
dotnet run --project ClinicAppПереконайтесь, що:
- Структура проєкту збігається зі схемою вище
- Записали пацієнта → рядок
[EVENT]у консолі і рядок уclinic.log(два підписники) - Терміновий запис → у
clinic.logдва рядки + рядок уalerts/urgent_{дата}.txt - Зареєстрували пацієнта → з'явився
patients/passport_N.txt - Завершили прийом →
passport_N.txtоновився (нова дата генерації, прийомCompleted) - Скасували запис при непорожній черзі →
[ЧЕРГА] Слот звільнився… - Вихід →
session_summary.txtз коректними лічильниками - У
Program.csне лишилось ручнихLogger.LogInfoдля дій, покритих подіями - (Експеримент, не для коміту)
clinic.Appointments.AppointmentBooked = null— помилка компіляції
Питання для самоперевірки
- У чому різниця між полем-делегатом і
event? Що саме забороняєeventззовні класу? - Чому обробник має сигнатуру
(object? sender, T e)? Що передається вsender? AppointmentManagerне знає ні проClinicLogger, ні проPatientPassportWriter. Де відбувається їхній зв'язок? Чому це добре?BookUrgentпіднімає дві події,Loggerпідписаний на обидві. Скільки рядків у лозі після одногоBookUrgent?- Якщо підписати той самий метод двічі (
event += handler; event += handler), скільки разів він спрацює? - Чому паспорт перезаписується, а не дописується?
- Яку ще автоматичну реакцію можна додати до системи, не змінюючи жодного менеджера? Які файли для цього знадобиться змінити?
Статус гілки
Після всіх 4 завдань (кожне — окремий коміт Lab13 TaskNN на гілці Lab-13):
git push -u origin Lab-13
git checkout main
git merge --no-ff Lab-13 -m "Merge Lab-13: Events & Delegates"
git pushНаступна лаба:
git checkout main→git checkout -b Lab-14.