Lab 13
Events & Delegates
EventArgs, event, обробники
Лабораторна робота №13. Events & Delegates
Мета
Зрозуміти проблему жорсткого зв'язування між класами та навчитись її вирішувати через механізм подій. Опанувати delegate, EventArgs, event EventHandler<T>, підписку через += і побудову системи де компоненти реагують на зміни не знаючи один про одного.
Гілка
feature/eventsПроблема, яку вирішує ця лаба
Відкрийте src/Program.cs і подивіться на будь-який пункт меню. Після кожної дії ви побачите щось подібне:
clinic.Appointments.Book(patientId, doctorId, scheduledAt);
clinic.Logger.LogInfo($"Запис створено: пацієнт {patientId}...");Тобто кожна дія в меню вручну повідомляє Logger. Зараз Logger один — але що якщо потрібно ще й записати у файл статистики? Або оновити паспорт пацієнта? Тоді після кожної дії буде три рядки, потім чотири, потім п'ять.
Це жорстке зв'язування — Program.cs знає про Logger, PassportWriter, Tracker і повинен викликати кожен з них вручну. Якщо додати нового підписника — доведеться змінювати Program.cs.
Правильно було б щоб менеджер просто повідомляв: "запис створено". А всі зацікавлені слухачі реагують самі — незалежно, не знаючи одне про одного. Саме це і є патерн Publisher-Subscriber, реалізований через механізм подій у C#.
Завдання 1. Перша подія — зрозуміти механіку ⭐⭐
Що зараз не так
AppointmentManager.Book() створює запис — але нікому про це не повідомляє. Program.cs мусить сам писати у лог після кожного виклику. Якщо десь забудеш — лог неповний.
Що таке delegate і event
Delegate — це тип що описує сигнатуру методу. Думайте про нього як про "контракт обробника": "я очікую метод, що приймає object? sender і AppointmentEventArgs e і нічого не повертає".
EventHandler<T> — це вбудований у .NET delegate з саме такою сигнатурою. Вам не потрібно оголошувати delegate вручну — достатньо EventHandler<T> де T — ваш клас аргументів.
Event — це поле типу delegate з обмеженнями: ззовні класу дозволено лише += і -=. Не можна присвоїти = null або викликати напряму. Це захищає від випадкового знищення всіх підписників.
Що таке EventArgs
EventArgs — базовий клас для "посилки з даними про подію". Коли AppointmentManager повідомляє про створення запису, він передає AppointmentEventArgs з усіма деталями: id запису, id пацієнта, id лікаря, час. Підписник отримує цю посилку і робить з нею що потрібно.
Що потрібно зробити
Крок 1. Створіть папку src/Events/ і в ній файл AppointmentEventArgs.cs.
Цей клас успадковує EventArgs і містить readonly-властивості з даними про подію: AppointmentId, PatientId, DoctorId, ScheduledAt, Notes. Всі властивості заповнюються через конструктор — після створення об'єкт незмінний, бо подія вже відбулась.
Подумайте: чому Notes має значення за замовчуванням ""? Коли воно може бути порожнім?
Крок 2. Відкрийте src/Managers/AppointmentManager.cs.
Додайте поле події:
public event EventHandler<AppointmentEventArgs>? AppointmentBooked;Знак ? означає що підписників може не бути — і це нормально. Якщо не перевірити, виклик кине NullReferenceException.
Після успішного створення запису в методі Book() підніміть подію:
AppointmentBooked?.Invoke(this, new AppointmentEventArgs(...));?.Invoke — безпечний виклик: якщо немає підписників, просто нічого не відбувається.
Крок 3. Відкрийте src/Program.cs.
Підпишіть простий обробник ще до ініціалізації тестових даних:
clinic.Appointments.AppointmentBooked += OnAppointmentBookedConsole;Нижче (поза циклом меню) оголосіть статичний метод:
static void OnAppointmentBookedConsole(object? sender, AppointmentEventArgs e)
{
Console.WriteLine($" [EVENT] Запис #{e.AppointmentId} створено...");
}Зверніть увагу: Program.cs не викликає Logger — він просто "слухає" подію від менеджера.
Перевірка. Запустіть програму, запишіть пацієнта через меню. Рядок [EVENT] з'являється автоматично — без жодного виклику у коді меню.
Ключові питання для розуміння
- Чому обробник має саме таку сигнатуру
(object? sender, T e)? Що означаєsender? - Що станеться якщо написати
AppointmentBooked.Invoke(...)без?.? - Спробуйте написати
clinic.Appointments.AppointmentBooked = null— чому компілятор не дозволяє?
Завдання 2. Всі події + Logger як підписник ⭐⭐
Чому Logger не повинен викликатись вручну
Подивіться на Program.cs: скрізь де є дія — є clinic.Logger.LogInfo(...). Це означає що Program.cs знає про Logger і пам'ятає його викликати. Якщо хтось додасть новий пункт меню і забуде — дія не залогується.
Правильніше: нехай AppointmentManager підніме подію → Logger як підписник сам відреагує. Автоматично. Завжди. Без залежності від Program.cs.
Що потрібно зробити
Крок 1. Нові EventArgs.
Аналогічно Task 1 створіть в src/Events/:
PatientEventArgs.cs—PatientId,FullNamePaymentEventArgs.cs—AppointmentId,AmountTreatmentPlanEventArgs.cs—PlanId,PatientId,Diagnosis
Поля беріть ті, що логічно описують "що сталося": мінімально достатньо для обробника.
Крок 2. Нові події в менеджерах.
В AppointmentManager додайте ще три події — AppointmentCancelled, AppointmentCompleted, UrgentAppointmentBooked. Підніміть їх у відповідних методах: Cancel(), Complete(), BookUrgent().
Зверніть увагу: в BookUrgent() варто підняти дві події — AppointmentBooked і UrgentAppointmentBooked. Терміновий запис — це все одно запис, тому перша подія теж доречна. Logger підписаний на обидві і залогує обидві — це задумана поведінка.
В PatientManager — PatientAdded у методі Add().
В BillingManager — PaymentReceived у методі PayAppointment(). Яку суму передавати в PaymentEventArgs? Подумайте: GetCost() доступний через IPayable.
В TreatmentPlanManager — PlanCompleted. Але зараз зміна статусу плану відбувається всередині самого TreatmentPlan. Вам потрібно додати метод-обгортку Complete(int id) у менеджері — саме він підніме подію після успішного завершення.
Крок 3. Logger як підписник.
Відкрийте src/Utils/ClinicLogger.cs. Додайте методи-обробники — по одному на кожну подію:
public void OnPatientAdded(object? sender, PatientEventArgs e)
=> LogInfo($"Новий пацієнт #{e.PatientId}: {e.FullName}");Аналогічно для всіх інших подій. Для OnUrgentBooked — використайте LogWarning і додатково запишіть у файл alerts/urgent_{дата}.txt через File.AppendAllText.
Підказка для методів що вже мали LogInfo у меню: після цього кроку ці ручні виклики в Program.cs видаляються — Logger сам знає коли логувати.
Крок 4. Підписка в Clinic.cs.
Відкрийте src/Clinic.cs. Додайте приватний метод SubscribeEvents() і викличте його в кінці конструктора.
Чому саме тут, а не в Program.cs? Тому що Clinic — це оркестратор системи. Саме він знає про всі менеджери і вирішує хто на що підписаний. Program.cs не повинен знати про внутрішні зв'язки між компонентами.
private void SubscribeEvents()
{
Patients.PatientAdded += Logger.OnPatientAdded;
Appointments.AppointmentBooked += Logger.OnAppointmentBooked;
// ... решта подій
}Крок 5. Прибрати ручні виклики Logger з Program.cs.
Тепер всі clinic.Logger.LogInfo(...) що стосуються подій — зайві. Logger підписаний і реагує сам. Знайдіть і видаліть їх.
Перевірка. Два підписники на AppointmentBooked: ваш OnAppointmentBookedConsole з Task 1 і Logger.OnAppointmentBooked. При записі пацієнта — обидва спрацьовують. У консолі з'явиться рядок [EVENT], у clinic.log — рядок від Logger. Менеджер не знає про жодного з них.
Ключові питання для розуміння
AppointmentManagerне імпортуєClinicLogger. Де відбувається їх зв'язок?- Чому Logger — підписник, а не навпаки (Logger не викликає менеджер)?
BookUrgentпіднімає дві події. Скільки рядків у лозі після одногоBookUrgent? Чому?
Завдання 3. PatientPassportWriter — другий незалежний підписник ⭐⭐⭐
Нова вимога без зміни менеджерів
Уявіть: замовник каже "при реєстрації пацієнта і при кожному завершеному прийомі — генеруйте файл паспорту пацієнта". Скільки файлів треба змінити в поточній системі?
З подіями відповідь: один новий файл — PatientPassportWriter. Менеджери вже піднімають відповідні події. Потрібно лише підписати новий клас. AppointmentManager, PatientManager, Program.cs — жоден не змінюється.
Це і є головна перевага подій: відкрита для розширення, закрита для змін.
Що таке паспорт пацієнта
Файл patients/passport_{id}.txt — повна картка пацієнта в текстовому форматі. Генерується заново при кожному тригері (простіше ніж відстежувати що змінилось). Містить:
- Особисті дані: ім'я, дата народження, вік, група крові, телефон
- Медичні записи: діагнози, аналізи, рецепти (з
MedicalRecordManager) - Записи на прийом (з
AppointmentManager) - Плани лікування (з
TreatmentPlanManager) - Фінансова заборгованість (з
BillingManager)
Що потрібно зробити
Крок 1. Створіть src/Utils/PatientPassportWriter.cs.
Клас отримує Clinic через конструктор — щоб мати доступ до всіх менеджерів при генерації файлу. Також приймає baseDir (за замовчуванням "patients") — папка де зберігаються паспорти. Папку варто створити одразу в конструкторі через Directory.CreateDirectory.
Три публічні обробники, всі викликають один приватний метод Write(int patientId):
public void OnPatientAdded(object? sender, PatientEventArgs e) => Write(e.PatientId);
public void OnAppointmentCompleted(object? sender, AppointmentEventArgs e) => Write(e.PatientId);
public void OnPlanCompleted(object? sender, TreatmentPlanEventArgs e) => Write(e.PatientId);Чому так? Тому що логіка генерації однакова — зібрати всі дані пацієнта і записати у файл. Не дублюйте цей код тричі.
Крок 2. Реалізуйте приватний метод Write(int patientId).
Алгоритм:
- Знайдіть пацієнта за ID. Якщо не знайдено — вийти (ранній вихід
return). - Складіть шлях:
Path.Combine(_baseDir, $"passport_{patientId}.txt"). - Відкрийте
StreamWriterзappend: false— перезаписуємо файл, не дописуємо. Паспорт завжди актуальний. - Запишіть кожну секцію.
Для секцій медичних записів використовуйте is з оголошенням змінної:
if (records[i] is Diagnosis d) { /* записати d.DiagnosisCode, d.Description... */ }Три окремих проходи по масиву для трьох секцій — простіше ніж один складний з кількома if.
Крок 3. Додайте PatientPassportWriter у Clinic.cs.
Оголосіть властивість public PatientPassportWriter Passport { get; }, ініціалізуйте в конструкторі. В SubscribeEvents() підпишіть:
Patients.PatientAdded += Passport.OnPatientAdded;
Appointments.AppointmentCompleted += Passport.OnAppointmentCompleted;
TreatmentPlans.PlanCompleted += Passport.OnPlanCompleted;Перевірка. Зареєструйте пацієнта → файл patients/passport_N.txt з'явився. Завершіть прийом → файл оновився: дата генерації змінилась, прийом тепер Completed. Logger при цьому також спрацював — обидва підписники незалежні.
Ключові питання для розуміння
PatientPassportWriterпідписаний на подіїPatientManagerіAppointmentManager. Чи знають ці менеджери проPatientPassportWriter?- Чому
StreamWriterзappend: false, а неappend: true? - Якщо завтра замовник попросить "ще й надсилати email при завершенні прийому" — які файли потрібно змінити?
Завдання 4. SessionEventTracker — крос-доменна реакція ⭐⭐⭐
Що таке крос-доменна реакція
До цього кожен підписник реагував у своїй "зоні відповідальності": Logger пише у файл, PassportWriter генерує документ. Але інколи реакція на одну подію зачіпає інший підсистему.
Приклад: лікар скасував прийом → звільнився часовий слот → логічно перевірити чи є хтось у черзі і повідомити. Скасування — це подія AppointmentManager. Черга — це WaitingQueue у Clinic. Як їх зв'язати без прямої залежності між менеджерами?
Відповідь: SessionEventTracker підписаний на подію скасування і має доступ до Clinic — він і робить перевірку черги.
Що потрібно зробити
Крок 1. Створіть src/Utils/SessionEventTracker.cs.
Клас отримує Clinic через конструктор. Зберігає лічильники для кожного типу події як public int з private set:
PatientsAdded, AppointmentsBooked, UrgentBooked,
AppointmentsCancelled, AppointmentsCompleted,
PaymentsReceived, PlansCompletedКрок 2. Реалізуйте обробники.
Більшість обробників просто збільшують лічильник. Винятком є OnAppointmentCancelled — він також перевіряє чергу:
public void OnAppointmentCancelled(object? sender, AppointmentEventArgs e)
{
AppointmentsCancelled++;
if (!_clinic.WaitingRoom.IsEmpty)
{
Patient next = _clinic.WaitingRoom.Peek();
Console.WriteLine($" [ЧЕРГА] Слот звільнився. Наступний: {next.FullName}");
}
}Крок 3. Реалізуйте PrintSummary() і SaveSummary(string path).
PrintSummary() — виводить підсумок сесії в консоль: скільки пацієнтів додано, записів створено, завершено, оплачено тощо.
SaveSummary() — записує те саме у файл session_summary.txt через StreamWriter. Додайте дату і час формування звіту.
Крок 4. Додайте Tracker у Clinic.cs, підпишіть всі обробники у SubscribeEvents().
Крок 5. У Program.cs при виході (case "0") перед збереженням сесії:
clinic.Tracker.PrintSummary();
clinic.Tracker.SaveSummary();Чому виклик PrintSummary залишається у Program.cs явним, а не через подію? Бо це не реакція на подію — це явна дія користувача "переглянь підсумок перед виходом".
Перевірка.
- Додайте пацієнта до черги, потім скасуйте запис → консоль:
[ЧЕРГА] Слот звільнився... - Вийдіть з програми →
session_summary.txtзберігся з коректними лічильниками - У
clinic.log— ті ж події від Logger. Обидва підписники (Logger і Tracker) спрацювали незалежно на одні й ті ж події
Ключові питання для розуміння
LoggerіTrackerпідписані на ті самі події. В якому порядку вони спрацьовують? Чи важливий цей порядок?- Чому
PrintSummary()викликається явно, а не як підписник на якусь подію "вихід"? - Спробуйте прибрати
?зevent EventHandler<T>?— що зміниться?
Перевірка перед здачею
Запустіть:
dotnet run --project srcВиконайте послідовно і перевірте кожен пункт:
- Зареєстрував пацієнта →
patients/passport_N.txtз'явився - Записав пацієнта → рядок
[EVENT]у консолі і рядок уclinic.log— два підписники - Оформив терміновий запис → у
clinic.logдва рядки + файлalerts/urgent_{дата}.txt - Завершив прийом →
passport_N.txtоновився, дата генерації змінилась - Скасував запис (черга не порожня) → консоль:
[ЧЕРГА] Слот звільнився... - Вийшов →
session_summary.txtз коректними лічильниками - Спробував
clinic.Appointments.AppointmentBooked = null→ помилка компілятора
Питання для самоперевірки
- У чому різниця між
delegateполем іevent? Що саме забороняєeventззовні класу? AppointmentManagerне знає ні проClinicLogger, ні проPatientPassportWriter. Де відбувається їх зв'язок? Чому це добре?BookUrgentпіднімає дві події. Logger підписаний на обидві. Скільки рядків у лозі після одногоBookUrgent?- Якщо підписати один і той же метод двічі:
event += handler; event += handler— скільки разів він спрацює? - Чому
PatientPassportWriter.Write()перезаписує файл а не дописує? - Яку ще автоматичну реакцію можна додати до системи не змінюючи жодного менеджера?