OOP Course
Сьогодні

Lab 08

Поліморфізм

override, sealed, runtime dispatch

Лаба 08 — Поліморфізм

Мета

Навчитися будувати ієрархію класів, у якій кожен підтип поводиться по-своєму через спільний базовий тип: робити методи virtual і перевизначати їх через override, звертатися до реалізації батька через base, закривати ієрархію словом sealed — і розрізняти справжнє перевизначення (override) та приховування методу (new).

Контекст

Після Лаби 07 запис на прийом уміє рахувати вартість (IPayable) і скасовуватись (ICancellable). Але всі записи однакові: будь-який прийом коштує DurationMinutes × 10 грн і нічим не відрізняється від іншого.

Насправді клініка має три види прийомів: звичайний, терміновий (на 50% дорожчий) і консультацію спеціаліста (на 30% дорожча). Ця лаба вводить для них підкласи Appointment. Головне, чого ви навчитесь: код, який працює з масивом Appointment[], отримує правильний опис і правильну ціну кожного запису без жодного if за типом — це і є поліморфізм.

Структура проєкту на початку лаби

Це результат Лаби 07 — стан main після її злиття:

oop-course/                             ← гілка main (після злиття Лаби 07)
├── .gitignore
├── oop-course.slnx
└── ClinicApp/
    ├── ClinicApp.csproj
    ├── Program.cs
    ├── Clinic.cs
    ├── Enums/  (3 файли)
    ├── Models/
    │   ├── Appointment.cs
    │   └── … ще 7 файлів без змін
    ├── Managers/
    │   ├── PatientManager.cs
    │   ├── DoctorManager.cs
    │   ├── AppointmentManager.cs
    │   ├── GrowablePatientManager.cs
    │   ├── MedicalRecordManager.cs
    │   └── BillingManager.cs
    ├── Utils/  (2 файли)
    └── Interfaces/  (3 файли)

Структуру наприкінці лаби (з позначками, що створюється і змінюється в кожній задачі) наведено в розділі «Структура проєкту наприкінці лаби» перед перевіркою.

Що нового дозволено (і тільки воно)

  • virtual і override для методів звичайного (не абстрактного) класу;
  • виклик реалізації батька через base.Метод();
  • sealed на класі (від нього не можна успадкуватись) і sealed override на методі (його не можна перевизначити далі);
  • модифікатор new — приховування методу батька.

Досі заборонено: List<T> / Dictionary та інші generic-колекції (Лаба 09), LINQ (Лаба 14), делегати й лямбди (Лаби 13–15).


Крок 1. Гілка

Робочий процес (повністю — Git Воркшоп): лаба = гілка Lab-XX від main, коміт на кожне завдання (LabXX TaskYY), у кінці — злиття в main.

Проєкт ClinicApp/ уже існує. Тут лише нова гілка від main:

git checkout main
git checkout -b Lab-08

Коміт — на кожне завдання (Lab08 TaskNN).

Ваш домен

За замовчуванням виконуйте завдання як написано (домен «клініка»). Для власного домену дивіться таблицю «Адаптація до вашого домену» наприкінці кожного завдання — вона підказує, які підтипи створити і як зміниться ціна. Структуру рішення зберігайте.

Як користуватися підказками

Підказки — напрям думки, а не готовий код. «Що реалізувати» і «Специфікація» кажуть, що має вийти; підказки — як міркувати; блок 📖 Документація — де прочитати синтаксис. Спершу документація і власна спроба.


Задача 1. Віртуальні методи та перший підклас ⭐

Умова

Щоб підклас міг змінити поведінку методу батька, батько має явно це дозволити — позначити метод virtual. Зробіть вартість і опис запису віртуальними та створіть перший підклас — звичайний прийом.

Що реалізувати:

  1. У Appointment зробити GetCost() віртуальним (формула лишається: DurationMinutes × 10 грн).
  2. Додати в Appointment новий віртуальний метод GetDescription(), який повертає "Прийом".
  3. Додати в Appointment звичайний (не віртуальний) метод GetPriority(), який повертає 3. Він навмисно не virtual — знадобиться в Задачі 3.
  4. Оновити Appointment.ToString(): на початку рядка — опис із GetDescription(), наприкінці — вартість із GetCost().
  5. Створити клас RegularAppointment : Appointment у Models/: конструктор передає параметри батькові, GetDescription() повертає "Звичайний прийом".

Як перевіряти, поки меню не змінене: Задачі 1–2 не змінюють меню. Щоб побачити результат, тимчасово додайте кілька рядків наприкінці початкових даних у Program.cs (створіть записи різних типів і виведіть їх). Перед комітом Задачі 3 цей тимчасовий код приберіть — його місце займе демонстрація із Задачі 3.

Специфікація

Член Appointment Було Стало
GetCost() звичайний метод virtual, та сама формула
GetDescription() — virtual string, повертає "Прийом"
GetPriority() — не virtual, повертає 3
ToString() без опису й ціни "[Id] Опис | … | Статус | Вартість грн"
Член RegularAppointment Опис
конструктор (patientId, doctorId, scheduledAt, durationMinutes = 30) передає всі параметри в конструктор Appointment
GetDescription() override, повертає "Звичайний прийом"

Приклад

Appointment a = new RegularAppointment(1, 1, DateTime.Today.AddDays(1).AddHours(10));
Console.WriteLine(a);
// [7] Звичайний прийом | Пацієнт #1 → Лікар #1 | 15.10.2026 10:00–10:30 | Scheduled | 300.00 грн

Змінна має тип Appointment, а опис — від RegularAppointment: виклик GetDescription() усередині ToString() іде в підклас.

Підказки

  1. virtual — це дозвіл. Без нього override у підкласі не скомпілюється. Ключове слово ставиться в оголошенні методу батька, між модифікатором доступу й типом результату.
  2. Конструктор підкласу не повторює логіку батька. Він лише передає параметри «нагору» — синтаксис : base(...) після списку параметрів (той самий принцип, що й у Лабі 06).
  3. ToString() уже перевизначений (з Лаби 03). Змініть лише його вміст: опис — через GetDescription(), вартість — через GetCost() з форматом "F2". Саме тому, що ToString() викликає віртуальні методи, він автоматично показуватиме дані підкласу.
  4. IPayable не ламається. GetCost() лишається публічним і з тим самим типом — інтерфейс із Лаби 07 і далі задоволений.

📖 Документація:

Адаптація до вашого домену

Клініка Готель Ресторан Університет Прокат авто Бібліотека Спортзал
Appointment Booking TableReservation Enrollment Rental BookLoan Session
virtual GetCost() virtual GetCost() virtual GetCost() virtual GetCost() virtual GetCost() virtual GetFine() virtual GetCost()
RegularAppointment StandardBooking RegularReservation RegularEnrollment BasicRental RegularLoan RegularSession

Коміт

git add ClinicApp/Models/Appointment.cs ClinicApp/Models/RegularAppointment.cs
git commit -m "Lab08 Task01"

Задача 2. Терміновий прийом і консультація спеціаліста ⭐⭐

Умова

Створіть ще два види прийому зі своєю ціною й описом. Обидва зберігаються там само, де й усі записи, — у масиві Appointment[], і виводяться тим самим кодом.

Що реалізувати:

  1. Клас UrgentAppointment : Appointment у Models/:
    • властивість UrgencyNote (причина терміновості), лише для читання, задається в конструкторі;
    • GetCost() — на 50% дорожче за базову ціну;
    • GetDescription() — "Терміновий" і, якщо задано, причина в дужках; метод закритий для подальшого перевизначення (sealed override);
    • GetPriority() повертає 1 і приховує метод батька (модифікатор new, не override).
  2. Клас SpecialistAppointment : Appointment у Models/, закритий для успадкування (sealed class):
    • властивість ConsultationTopic (тема консультації), лише для читання, задається в конструкторі;
    • GetCost() — на 30% дорожче за базову ціну;
    • GetDescription() — "Консультація спеціаліста" і тема.

Специфікація

Клас Конструктор GetCost() GetDescription() GetPriority()
UrgentAppointment (patientId, doctorId, scheduledAt, urgencyNote = "", durationMinutes = 30) базова × 1.5 "Терміновий (біль у грудях)"; без причини — "Терміновий"; sealed override new, повертає 1
SpecialistAppointment (sealed) (patientId, doctorId, scheduledAt, topic = "", durationMinutes = 45) базова × 1.3 "Консультація спеціаліста: кардіологія" успадкований (3)

«Базова» ціна — результат GetCost() класу Appointment.

Приклад

Appointment[] list =
{
    new RegularAppointment(1, 1, tomorrow.AddHours(10)),
    new UrgentAppointment(2, 2, tomorrow.AddHours(11), "біль у грудях"),
    new SpecialistAppointment(3, 3, tomorrow.AddHours(12), "кардіологія", 60),
};

for (int i = 0; i < list.Length; i++)
    Console.WriteLine(list[i]);
// [8]  Звичайний прийом | … | 300.00 грн
// [9]  Терміновий (біль у грудях) | … | 450.00 грн
// [10] Консультація спеціаліста: кардіологія | … | 780.00 грн

Один масив, один цикл, жодного if — і три різні рядки з трьома різними цінами.

Підказки

  1. Не повторюйте формулу батька. Ціна підкласу — це ціна батька, помножена на коефіцієнт. Реалізацію батька викликає base.GetCost(). Якщо завтра базова ставка зміниться з 10 на 12 грн — підкласи порахують правильно без жодної правки.
  2. decimal і коефіцієнт. Множник записуйте як decimal-літерал із суфіксом m (1.5m), інакше компілятор не дозволить множити decimal на double.
  3. sealed override — «перевизначаю тут, але далі вже не можна». Підклас UrgentAppointment (якби такий з'явився) не зможе змінити опис.
  4. sealed class — клас є «листком» ієрархії: від нього не можна успадкуватись узагалі.
  5. new замість override — це не помилка, а навмисний експеримент. Компілятор без new видасть попередження, що ви ховаєте метод батька; new каже «так, я знаю». Що це змінює насправді — з'ясуєте в Задачі 3.
  6. Порожня причина. Перевірте UrgencyNote на порожній рядок (string.IsNullOrWhiteSpace), щоб не виводити порожні дужки.

📖 Документація:

Адаптація до вашого домену

Клініка Готель Ресторан Університет Прокат авто Бібліотека Спортзал
UrgentAppointment (×1.5) SuiteBooking (×2.0) PrivateRoomReservation (×1.5) OnlineEnrollment (×0.9) PremiumRental (×1.3) DigitalLoan (своя логіка штрафу) PersonalTraining (×2.0)
SpecialistAppointment (×1.3, sealed) CorporateBooking (×0.8) EventReservation (×2.0) IntensiveCourse (×1.4) LongTermRental (×0.8) ResearchLoan (довший термін) GroupSession (×0.6)

Коефіцієнт може бути й меншим за 1 (знижка) — механізм той самий.

Коміт

git add ClinicApp/Models/UrgentAppointment.cs ClinicApp/Models/SpecialistAppointment.cs
git commit -m "Lab08 Task02"

Задача 3. Запис різних типів через менеджер; `new` проти `override` ⭐⭐⭐

Умова

Поки що нові типи створюються лише вручну. Навчіть AppointmentManager записувати пацієнтів на прийоми різних типів і покажіть у Program.cs, чим override відрізняється від new.

Що реалізувати:

  1. Змінити AppointmentManager.Book(...): тепер він створює RegularAppointment замість Appointment.
  2. Додати в AppointmentManager методи BookUrgent(...) і BookSpecialist(...) — так само, як Book, але створюють відповідно UrgentAppointment і SpecialistAppointment.
  3. У початкових даних Program.cs створити принаймні по одному запису кожного типу через ці три методи.
  4. Прибрати тимчасовий код перевірок Задач 1–2 і додати в Program.cs демонстрацію new проти override (див. специфікацію).
  5. У коментарі біля демонстрації відповісти на три запитання:
    • Чому GetDescription() через змінну типу Appointment дає опис термінового прийому?
    • Чому GetPriority() через ту саму змінну повертає 3, хоча об'єкт — UrgentAppointment?
    • Що треба змінити в Appointment і UrgentAppointment, щоб GetPriority() теж працював поліморфно?

Специфікація

Метод AppointmentManager Параметри Створює Повертає
Book як і раніше RegularAppointment bool — як і раніше
BookUrgent patientId, doctorId, scheduledAt, urgencyNote = "", durationMinutes = 30 UrgentAppointment bool
BookSpecialist patientId, doctorId, scheduledAt, topic = "", durationMinutes = 45 SpecialistAppointment bool

Перевірки (пацієнт і лікар існують, ліміт масиву) — ті самі, що в Book.

Демонстрація в Program.cs: створіть один об'єкт UrgentAppointment і збережіть посилання на нього у дві змінні — типу Appointment і типу UrgentAppointment. Для кожної змінної виведіть GetDescription() і GetPriority().

Приклад

== new vs override ==
Через Appointment:       Терміновий (тест) | пріоритет 3
Через UrgentAppointment: Терміновий (тест) | пріоритет 1

Підказки

  1. Не копіюйте Book цілком. Три методи відрізняються лише рядком створення об'єкта. Спільну частину (перевірки й додавання в масив) винесіть у приватний допоміжний метод, який приймає вже створений запис.
  2. Тип змінної і тип об'єкта — різні речі. Тип змінної (ліворуч від =) визначає, які методи можна викликати. Тип об'єкта (після new) визначає, яка реалізація виконається — але лише для virtual / override.
  3. Метод, прихований через new, обирається за типом змінної. Тому відповідь залежить від того, через яку змінну ви звертаєтесь.
  4. Не покладайтесь на індекс у масиві. Номер запису в clinic.Appointments[...] залежить від ваших початкових даних — для демонстрації створіть об'єкт явно.

📖 Документація:

Адаптація до вашого домену

Клініка Готель Ресторан Університет Прокат авто Бібліотека Спортзал
BookUrgent / BookSpecialist BookSuite / BookCorporate ReservePrivateRoom / ReserveEvent EnrollOnline / EnrollIntensive RentPremium / RentLongTerm LendDigital / LendResearch BookPersonal / BookGroup

Коміт

git add ClinicApp/Managers/AppointmentManager.cs ClinicApp/Program.cs
git commit -m "Lab08 Task03"

Задача 4. Тип і вартість у меню «Записи» ⭐⭐⭐

Умова

Користувач досі не бачить, який прийом терміновий, а який — консультація. Покажіть тип і ціну в кожному рядку списку записів і додайте перегляд записів за типом.

Що реалізувати:

  1. У AppointmentManager додати методи GetUrgent(), GetSpecialist(), GetRegular() — кожен повертає масив записів відповідного типу.
  2. Оновити AppointmentManager.DisplayAppointment(...): у рядку з'являються опис (GetDescription()) і вартість (GetCost()).
  3. У підменю «Записи» додати пункт 9 — «За типом прийому» з трьома варіантами: термінові, консультації, звичайні.

Специфікація

Метод Повертає
GetUrgent() Appointment[] — лише UrgentAppointment
GetSpecialist() Appointment[] — лише SpecialistAppointment
GetRegular() Appointment[] — лише RegularAppointment

Рядок запису в списку: [Id] Опис | Пацієнт → Лікар | дата час–кінець | Статус | Вартість грн (імена пацієнта й лікаря — як і раніше, через менеджери).

Пункт меню:

Меню Пункт Що запитує Що виводить
«Записи» 9 — За типом прийому 1 — термінові, 2 — консультації, 3 — звичайні Список записів обраного типу або «не знайдено»

Приклад

── Записи ──────────────────────
  …
  8. Скасувати всі записи пацієнта
  9. За типом прийому
  0. Назад
Оберіть: 9
  1. Термінові
  2. Консультації спеціаліста
  3. Звичайні
Оберіть: 1
[9] Терміновий (біль у грудях) | Олена Коваль → Наталія Мороз | 15.10.2026 11:00–11:30 | Scheduled | 450.00 грн

Підказки

  1. Відбір за типом — двопрохідний патерн (як GetByPatient): перший прохід рахує, скільки записів підходить, другий заповнює масив точного розміру. Умова — перевірка типу через is (Лаба 06).
  2. is перевіряє тип об'єкта, а не змінної. У масиві Appointment[] лежать об'єкти різних підкласів, і is UrgentAppointment це розрізнить.
  3. У DisplayAppointment — жодного if за типом. Опис і ціну дають віртуальні методи, кожен підклас повертає своє. Перевірка is потрібна лише для відбору в GetUrgent() тощо, а не для виводу.
  4. Пункт 8 уже зайнятий (Лаба 07 — «Скасувати всі записи пацієнта»), тому новий пункт — 9.

📖 Документація:

Адаптація до вашого домену

Клініка Готель Ресторан Університет Прокат авто Бібліотека Спортзал
GetUrgent / GetSpecialist / GetRegular GetSuites / GetCorporate / GetStandard GetPrivate / GetEvents / GetRegular GetOnline / GetIntensive / GetRegular GetPremium / GetLongTerm / GetBasic GetDigital / GetResearch / GetRegular GetPersonal / GetGroup / GetRegular

Коміт

git add ClinicApp/Managers/AppointmentManager.cs ClinicApp/Program.cs
git commit -m "Lab08 Task04"

Задача 5 (опційна). VIP-знижка для всіх типів ⭐⭐⭐⭐

Умова

Керівник клініки просить VIP-знижку 20% для будь-якого типу прийому: VIP-терміновий коштує базова × 1.5 × 0.8, VIP-консультація — базова × 1.3 × 0.8.

Перша думка — підклас VipUrgentAppointment : UrgentAppointment. Перевірте її: спробуйте створити такий клас і перевизначити в ньому GetDescription(), а також успадкуватись від SpecialistAppointment. Подивіться, що скаже компілятор, і приберіть ці спроби — вони не мають потрапити в коміт.

Порахуйте: якщо для кожного з трьох типів робити VIP-підклас, скільки класів буде? А якщо додати ще дитячий і пенсійний тарифи? Висновок: знижку треба реалізувати не через нові підкласи.

Що реалізувати: один із двох підходів (на вибір):

  • Варіант А — множник знижки в Appointment. Властивість зі знижковим множником (за замовчуванням 1), яку враховує базова ціна. Підкласи нічого не змінюють — їхній base.GetCost() уже містить знижку.
  • Варіант Б — коефіцієнт у конструкторі підкласу. Кожен підклас отримує свій множник параметром конструктора зі значенням за замовчуванням (1.5 для термінового, 1.3 для консультації), тож VIP-запис створюється з множником 1.5 × 0.8.

Над реалізацією залиште коментар: який варіант обрано і чому.

Специфікація

Запис (30 хв, базова ціна 300 грн) Без знижки VIP (−20%)
Звичайний 300.00 240.00
Терміновий 450.00 360.00
Консультація 390.00 312.00

Підказки

  1. Жоден варіант не «єдино правильний». А простіший і працює для всіх типів одразу; Б гнучкіший, але знижку доводиться враховувати при створенні кожного запису.
  2. Варіант А: базова ціна в Appointment множиться на множник знижки. Метод у Appointment лишається virtual — override у самому базовому класі не пишуть.
  3. Варіант Б: коефіцієнт зберігайте в полі лише для читання (readonly), яке задає конструктор.
  4. Це приклад принципу «відкритий для розширення, закритий для змін» (Open/Closed). У Лабі 22 (SOLID) ви повернетесь до цієї ідеї зі стратегіями ціноутворення.

📖 Документація:

Адаптація до вашого домену

Клініка Готель Ресторан Університет Прокат авто Бібліотека Спортзал
VIP-знижка 20% знижка постійного гостя знижка за кількість гостей знижка-стипендія знижка за довгу оренду пільговий читач сімейний абонемент

Коміт

git add ClinicApp/Models/
git commit -m "Lab08 Task05"

Структура проєкту наприкінці лаби

Так має виглядати ClinicApp/, коли виконано Задачі 1–4 (Задача 5 — опційна, її позначки нижче):

oop-course/                             ← гілка Lab-08 (після злиття — main)
├── .gitignore
├── oop-course.slnx
└── ClinicApp/
    ├── ClinicApp.csproj
    ├── Program.cs                      ✏ Т3 Т4
    ├── Clinic.cs
    ├── Enums/  (3 файли)
    ├── Models/
    │   ├── Appointment.cs              ✏ Т1 Т5
    │   ├── RegularAppointment.cs       🆕 Т1
    │   ├── UrgentAppointment.cs        🆕 Т2  ✏ Т5
    │   ├── SpecialistAppointment.cs    🆕 Т2  ✏ Т5
    │   └── … ще 7 файлів без змін
    ├── Managers/
    │   ├── PatientManager.cs
    │   ├── DoctorManager.cs
    │   ├── AppointmentManager.cs       ✏ Т3 Т4
    │   ├── GrowablePatientManager.cs
    │   ├── MedicalRecordManager.cs
    │   └── BillingManager.cs
    ├── Utils/  (2 файли)
    └── Interfaces/  (3 файли)

Легенда: 🆕 — новий файл · ✏ — змінено вміст · Тn — номер задачі, у якій ви працюєте з файлом. Файли без позначки лишились такими, як були після Лаби 07. У Задачі 5 змінюється або Appointment.cs (варіант А), або підкласи (варіант Б).

Назви файлів наведено для домену «клініка»; у власному домені назви ваші — важливо, що саме створюється й змінюється.


Перевірка перед здачею

dotnet build ClinicApp
dotnet run --project ClinicApp

Переконайтесь, що:

  • Структура проєкту збігається зі схемою вище
  • Проєкт компілюється без помилок і попереджень (зокрема без попередження про приховування GetPriority)
  • Список записів показує тип і вартість кожного прийому
  • Терміновий прийом дорожчий за звичайний у 1.5 раза, консультація — у 1.3 раза (за однакової тривалості)
  • Демонстрація в Program.cs: через змінну Appointment — пріоритет 3, через UrgentAppointment — 1
  • «Записи» → 9 → кожен із трьох варіантів показує записи лише свого типу
  • У DisplayAppointment немає if / switch за типом запису
  • (Експеримент, не для коміту) спроба успадкуватись від SpecialistAppointment дає помилку компіляції
  • У коді немає List<T>, Dictionary<,>, LINQ

Питання для самоперевірки

  1. Що таке поліморфізм? Яку роль у ньому відіграють virtual і override?
  2. Тип змінної Appointment, об'єкт — UrgentAppointment. Чому GetDescription() повертає опис термінового прийому, а GetPriority() — 3?
  3. Навіщо модифікатор new, якщо він не дає поліморфізму? Коли він може бути корисним?
  4. Чим sealed на класі відрізняється від sealed override на методі?
  5. Що повертає base.GetCost() в UrgentAppointment для запису на 30 хвилин? Чому краще викликати base, ніж повторити формулу?
  6. Щоб додати четвертий тип прийому, які файли доведеться змінити? Чи зміниться DisplayAppointment?
  7. Поліморфізм через virtual / override (ця лаба) і через інтерфейс (Лаба 07) — коли що обирати?

Статус гілки

Після всіх завдань (кожне — окремий коміт Lab08 TaskNN на гілці Lab-08):

git push -u origin Lab-08
git checkout main
git merge --no-ff Lab-08 -m "Merge Lab-08: Polymorphism"
git push

Наступна лаба: git checkout main → git checkout -b Lab-09.

Розроблено Tomka Yurii · © 2026 ·