1. Що таке поліморфізм і навіщо він потрібен
Якщо без пафосу, поліморфізм — це ситуація, коли ви пишете код один раз, а поводиться він по-різному залежно від того, який обʼєкт насправді лежить усередині змінної. Ніби у вас є одна кнопка «Зроби справу», а «під капотом» — різні виконавці: один друкує чек, другий формує звіт, третій просто мовчки киває й нічого не робить (що теж іноді корисно на мітингах).
Важливо одразу прибрати відчуття магії: поліморфізм не зʼявляється «сам по собі». Він виростає з трьох цеглинок, які ви вже знаєте:
- є спільний базовий тип (базовий клас),
- є open-метод (або open-властивість) у цьому базовому класі,
- є перевизначення override у нащадках.
І тоді відбувається головне: ви викликаєте метод через базовий тип — а виконується реалізація нащадка.
2. Тип змінної та реальний тип обʼєкта
Коли ви тільки починаєте, мозок хоче вірити, що «змінна — це обʼєкт». Але на практиці змінна — це радше «ярлик» (із типом!), який вказує на обʼєкт. І цей ярлик може бути доволі загальним.
Зробімо невеличку таблицю, щоб код було легше читати «очима»:
| Що ми бачимо в коді | Що це означає | Чому це важливо |
|---|---|---|
|
Змінна x має тип Base, але всередині лежить обʼєкт Child | Через x доступні лише члени Base, але поведінка open-методів буде як у Child |
|
І змінна, і обʼєкт — Child | Доступні всі члени Child, поліморфізм теж працює |
|
Функції байдуже, хто там усередині: Child, AnotherChild… | Це і є «пишемо один раз — працює по-різному» |
Мініприклад (обіцяю: без філософії й без «це посилання на обʼєкт у купі»):
open class Animal {
open fun sound(): String = "..."
}
class Cat : Animal() {
override fun sound(): String = "няв"
}
fun main() {
val a: Animal = Cat()
println(a.sound()) // няв
}
Ключовий момент: змінна a має тип Animal, але метод sound() виконається «по-котячому», бо обʼєкт усередині насправді Cat.
3. Поліморфізм як ліки від розгалуження за типом
Скажімо чесно: коли у вас зʼявляється кілька типів, дуже хочеться зробити так:
when (x) {
is A -> ...
is B -> ...
is C -> ...
}
Іноді це нормально. Але якщо ви бачите, що в проєкті зʼявляється «диспетчерська» функція на 100 рядків, де кожен новий тип вимагає ще однієї гілки, це тривожний дзвіночок.
Поліморфізм пропонує інший стиль: «нехай обʼєкт сам знає, як себе обробити». Тоді замість розгалуження за типом ви робите один виклик методу — і все.
Особливо добре це працює у звітах, форматуванні, логуванні, побудові тексту, генерації рядків для UI/CLI — там, де результат залежить від «виду» сутності.
4. Мініприклад: рядки звіту з єдиним методом render()
Зараз зберемо приклад, який розвиватиметься в бік нашого навчального консольного застосунку (умовно назвемо його ExpenseTracker — трекер витрат). Ми не ускладнюватимемо ані архітектуру, ані інтерфейси, ані «дорослі патерни» — лише класи та наслідування.
Ідея така: у звіті є різні рядки — заголовок, розділювач, рядок із витратою, підсумок. Ми хочемо зберігати їх в одному списку й друкувати одним циклом.
Базовий клас ReportLine і два нащадки
Після цього заголовка не кидаємося в код стрімголов. Спершу тримаймо в голові мету: нам потрібна одна функція друку, яка не знає, який саме це тип рядка. Вона просто викликає render(). Для цього всі рядки мають мати спільний базовий тип і спільний метод.
open class ReportLine {
open fun render(): String = ""
}
class HeaderLine(private val title: String) : ReportLine() {
override fun render(): String = "== $title =="
}
class SeparatorLine : ReportLine() {
override fun render(): String = "----------------"
}
Зверніть увагу: render() оголошено open, інакше нащадки не зможуть його перевизначити. І так, Kotlin змушує писати override — це його спосіб не дати вам випадково перевизначити щось «мимохідь».
Поліморфний виклик напряму
fun main() {
val line: ReportLine = HeaderLine("Січневий звіт")
println(line.render()) // == Січневий звіт ==
}
Це вже поліморфізм: тип змінної ReportLine, а поведінка — як у HeaderLine.
5. Колекція базового типу: один список — різні обʼєкти
Тепер найцікавіше: не один обʼєкт, а одразу багато — і різних видів. Саме на колекціях новачки найчастіше вперше відчувають, навіщо все це потрібно.
Тут важливо зрозуміти сценарій. У реальному застосунку ви майже завжди маєте набір елементів: задачі, повідомлення, транзакції, рядки звіту. І саме в таких місцях поліморфізм починає економити вам десятки рядків if/when.
fun main() {
val lines: List<ReportLine> = listOf(
HeaderLine("Витрати"),
SeparatorLine(),
HeaderLine("Кінець") // так, дивно, але це приклад
)
for (line in lines) {
println(line.render())
}
// == Витрати ==
// ----------------
// == Кінець ==
}
Один for, один виклик render() — і жодної перевірки типів. Кожен обʼєкт сам «уміє» перетворитися на текст.
6. Вбудовуємо в застосунок: звіт за витратами без when (type)
Тепер перейдемо до більш прикладного фрагмента для ExpenseTracker. Тут важливо «перекинути міст»: у нас уже є модель витрат (ми робили data class, поля, колекції, можливо enum для категорій). Зараз ми додамо подання звіту — так, щоб код друку не залежав від типів рядків.
Нехай витрата виглядає так (мінімально):
data class Expense(
val title: String,
val amount: Int
)
Додаємо рядок «витрата» і рядок «підсумок»
class ExpenseLine(private val expense: Expense) : ReportLine() {
override fun render(): String =
"${expense.title}: ${expense.amount}"
}
class TotalLine(private val total: Int) : ReportLine() {
override fun render(): String = "Разом: $total"
}
Функція, яка будує список рядків звіту
Підводка проста: нехай звіт будується окремою функцією. Вона отримує дані (список витрат) і перетворює їх на «рядки звіту». На цьому етапі ми нічого не друкуємо — лише будуємо структуру. Так код простіше тестувати й розширювати.
fun buildReport(expenses: List<Expense>): List<ReportLine> {
val total = expenses.sumOf { it.amount }
return listOf(
HeaderLine("Звіт про витрати"),
SeparatorLine(),
ExpenseLine(Expense("Кава", 250)),
TotalLine(total)
)
}
Поки що тут є «заглушка» ExpenseLine(Expense("Кава", 250)) — зараз це виправимо. Просто хотілося, щоб приклад був коротким.
Зробімо версію, яка додає рядки на основі списку:
fun buildReport(expenses: List<Expense>): List<ReportLine> {
val lines = mutableListOf<ReportLine>()
lines.add(HeaderLine("Звіт про витрати"))
lines.add(SeparatorLine())
for (e in expenses) {
lines.add(ExpenseLine(e))
}
val total = expenses.sumOf { it.amount }
lines.add(SeparatorLine())
lines.add(TotalLine(total))
return lines
}
Тут важливо відчути ідею: lines — це MutableList<ReportLine>. Але всередину ми кладемо обʼєкти різних класів. І це нормально, бо всі вони — нащадки ReportLine.
Друк звіту: один цикл, один метод
fun printReport(lines: List<ReportLine>) {
for (line in lines) {
println(line.render())
}
}
І тепер main:
fun main() {
val expenses = listOf(
Expense("Кава", 250),
Expense("Обід", 600),
Expense("Таксі", 900)
)
val reportLines = buildReport(expenses)
printReport(reportLines)
// == Звіт про витрати ==
// ----------------
// Кава: 250
// Обід: 600
// Таксі: 900
// ----------------
// Разом: 1750
}
Зверніть увагу на архітектурну перевагу: printReport() узагалі не знає, що таке «підсумок» чи «витрата». Вона вміє друкувати ReportLine. Усе.
7. Як виглядало б без поліморфізму і чому це гірше
Тут важливо не лаяти when «як явище». Він корисний. Але ми хочемо побачити різницю: в одному підході «правила поведінки» розмазані по коду, в іншому — лежать поруч із даними.
Уявімо, що в нас немає поліморфізму й ми зробили одну структуру:
data class RawLine(val kind: String, val text: String, val number: Int?)
І друк:
fun printRaw(lines: List<RawLine>) {
for (line in lines) {
when (line.kind) {
"header" -> println("== ${line.text} ==")
"sep" -> println("----------------")
"total" -> println("Разом: ${line.number}")
else -> println(line.text)
}
}
}
Проблема проявиться в той момент, коли ви захочете додати новий тип рядка, наприклад «попередження» або «групування за категорією». Тоді вам доведеться:
- домовитися про новий kind,
- знайти всі місця, де є when (kind),
- додати туди гілку,
- не забути про форматування й «стиль» виводу.
А в поліморфному варіанті ви додаєте новий клас WarningLine : ReportLine() — і все. Старий код друку навіть не доводиться чіпати.
8. Корисні нюанси: when, open/override і super
Коли is/when за типами все-таки доречні
Тут важливо не впасти в крайність «розгалуження за типом — зло». Іноді це справді єдиний нормальний варіант. Наприклад, коли ви отримуєте обʼєкт надто загального типу (скажімо, Any) або коли дія не належить обʼєкту за змістом.
Наприклад, якщо ви робите «відлагоджувальний друк» або «збирання статистики» для різних типів і це справді зовнішня логіка, when (x) { is ... } може бути зрозумілішим.
А правило, яке добре працює в навчальних проєктах, таке: якщо ви бачите, що робите розгалуження за типами, щоб отримати «правильну поведінку», спершу запитайте себе: «А чи не має це вміти сам обʼєкт через open fun?»
Для наших рядків звіту відповідь очевидна: обʼєкт рядка й має вміти перетворитися на текст.
Поліморфізм працює лише на open + override
Тут корисно зупинитися й проговорити типову ситуацію: студент пише два методи з однаковим імʼям у базовому класі й нащадку, але забуває open/override. А потім дивується, що «викликається не те».
Приклад «поліморфізм не увімкнувся»:
class BaseLine {
fun render(): String = "база"
}
class ChildLine : BaseLine() {
fun render(): String = "нащадок" // це НЕ перевизначення
}
Такий код не дасть вам поліморфізму, бо render() у базовому класі не open, а render() у нащадка — не override. У Kotlin це не «приховане перевизначення», а просто інша функція з тим самим імʼям усередині іншого класу (і компілятор, до речі, може насваритися в низці випадків).
Правильна версія:
open class BaseLine {
open fun render(): String = "база"
}
class ChildLine : BaseLine() {
override fun render(): String = "нащадок"
}
super і поліморфізм: «додати зверху, а не замінити»
Підводка тут така: у реальних проєктах ви часто не хочете повністю замінювати поведінку. Натомість хочете «взяти базове й дописати». Це як узяти звичайний бутерброд і додати до нього сир. Бутерброд від цього не перестає бути бутербродом (хоча може стати небезпечно смачним).
Наприклад, базовий рядок може забезпечувати спільний префікс:
open class ReportLine {
open fun render(): String = ""
}
open class LabeledLine(private val label: String) : ReportLine() {
override fun render(): String = "[$label] " + super.render()
}
Тут приклад радше ілюстративний. У реальному коді ви б обережніше спроєктували базовий клас, щоб super.render() не повертав порожній рядок. Але сама ідея важлива: super допомагає розширювати поведінку без копіювання.
І нагадування з минулої лекції: у базових конструкторах і в init не варто смикати open-члени, бо можна випадково потрапити в перевизначення нащадка завчасно.
9. Типові помилки під час роботи з поліморфізмом
Помилка №1: робити «колекцію всього» типу List<Any> і потім розгалужуватися за типами.
Новачку здається, що Any — це «універсально». Насправді це відмова від типізації та автопідказок. Якщо елементи за змістом належать до однієї групи (як рядки звіту), краще дати їм базовий тип і спільний контракт.
Помилка №2: очікувати поліморфізму без open/override.
У Kotlin поліморфізм «не вмикається за замовчуванням», тому що методи final, і це зроблено спеціально. Якщо забути open у базовому класі або override у нащадка, ви отримаєте або помилку компіляції, або поведінку не таку, як очікували.
Помилка №3: намагатися запхати всю логіку в один гігантський when (type).
Спочатку це здається простішим, бо «все в одному місці». За тиждень там зʼявляється десять гілок, ще за тиждень — двадцять. А потім ви починаєте боятися чіпати цей файл (класичний ефект «крихка вежа з дженґи»). Поліморфізм допомагає розподілити відповідальність: кожен клас відповідає за свою поведінку.
Помилка №4: перевантажувати базовий клас надто багатьма open-методами «про всяк випадок».
Іноді студент робить базовий клас, де все open, бо «а раптом знадобиться». Це знижує передбачуваність і ускладнює підтримку. Відкривайте (open) лише те, що справді є частиною контракту й буде перевизначатися.
Помилка №5: намагатися отримати доступ до полів нащадка через змінну базового типу.
Якщо змінна має тип ReportLine, ви не можете звернутися до expense усередині ExpenseLine. І це нормально: базовий тип обіцяє лише те, що в ньому оголошено. Якщо вам потрібна поведінка — оформлюйте її як open fun у базовому типі, а не як спробу «дістати нутрощі».
Помилка №6: викликати open-методи в init базового класу й отримувати «містичні» баги.
Це не найчастіший баг у маленьких прикладах, але в реальних проєктах він неприємний. Базовий клас може викликати перевизначений метод нащадка до того, як нащадок встиг ініціалізувати свої поля. Правило просте: базова ініціалізація має бути самодостатньою.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ