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 = "meow"
}
fun main() {
val a: Animal = Cat()
println(a.sound()) // meow
}
Ключевой момент: переменная 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("January report")
println(line.render()) // == January report ==
}
Это уже полиморфизм: тип переменной ReportLine, а поведение — HeaderLine.
5. Коллекция базового типа: один список — разные объекты
Теперь самое вкусное: не один объект, а сразу много, и разных видов. Именно на коллекциях новички чаще всего впервые чувствуют, зачем всё это нужно.
После этого заголовка важно понять сценарий. В реальном приложении вы почти всегда имеете набор элементов: задачи, сообщения, транзакции, строки отчёта. И вот там полиморфизм начинает экономить вам десятки строк if/when.
fun main() {
val lines: List<ReportLine> = listOf(
HeaderLine("Expenses"),
SeparatorLine(),
HeaderLine("End") // да, странно, но это пример
)
for (line in lines) {
println(line.render())
}
// == Expenses ==
// ----------------
// == End ==
}
Один 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: $total"
}
Функция, которая строит список строк отчёта
Подводка важная: пусть отчёт строится отдельной функцией. Она получает данные (список расходов) и превращает их в «строки отчёта». На этом этапе мы не печатаем — мы только строим структуру. Так код становится проще тестировать и расширять.
fun buildReport(expenses: List<Expense>): List<ReportLine> {
val total = expenses.sumOf { it.amount }
return listOf(
HeaderLine("Expense report"),
SeparatorLine(),
ExpenseLine(Expense("Coffee", 250)),
TotalLine(total)
)
}
Пока здесь есть «заглушка» ExpenseLine(Expense("Coffee", 250)) — сейчас исправим, просто держали пример коротким.
Сделаем версию, которая добавляет строки на основе списка:
fun buildReport(expenses: List<Expense>): List<ReportLine> {
val lines = mutableListOf<ReportLine>()
lines.add(HeaderLine("Expense report"))
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("Coffee", 250),
Expense("Lunch", 600),
Expense("Taxi", 900)
)
val reportLines = buildReport(expenses)
printReport(reportLines)
// == Expense report ==
// ----------------
// Coffee: 250
// Lunch: 600
// Taxi: 900
// ----------------
// Total: 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("Total: ${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 = "base"
}
class ChildLine : BaseLine() {
fun render(): String = "child" // это НЕ переопределение
}
Такой код не даст вам полиморфизма, потому что render() в базовом классе не open, и render() в наследнике не override. В Kotlin это не «скрытое переопределение», а просто другая функция с тем же именем внутри другого класса (и компилятор, кстати, может ругнуться в ряде случаев).
Правильная версия:
open class BaseLine {
open fun render(): String = "base"
}
class ChildLine : BaseLine() {
override fun render(): String = "child"
}
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).
Поначалу это кажется проще, потому что «всё в одном месте». Через неделю там появляется десять веток, ещё через неделю — двадцать, а затем вы начинаете бояться трогать этот файл (классический эффект «хрупкая башня из Jenga»). Полиморфизм помогает распределить ответственность: каждый класс отвечает за своё поведение.
Ошибка №4: перегружать базовый класс слишком многими open-методами «на всякий случай».
Иногда студент делает базовый класс, где всё open, потому что «а вдруг понадобится». Это снижает предсказуемость и усложняет поддержку. Открывайте (open) только то, что действительно является частью контракта и будет переопределяться.
Ошибка №5: пытаться получить доступ к полям наследника через переменную базового типа.
Если переменная типа ReportLine, вы не можете обратиться к expense внутри ExpenseLine. И это нормально: базовый тип обещает только то, что в нём объявлено. Если вам нужно поведение — оформляйте его как open fun в базовом типе, а не как «достать внутренности».
Ошибка №6: вызывать open-методы в init базового класса и получать «мистические» баги.
Это не самый частый баг в маленьких примерах, но в реальных проектах он неприятный. Базовый класс может вызвать переопределённый метод наследника до того, как наследник успел инициализировать свои поля. Правило простое: базовая инициализация должна быть самодостаточной.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ