JavaRush /Курси /Kotlin SELF /Overloading vs overriding: як не переплутати

Overloading vs overriding: як не переплутати

Kotlin SELF
Рівень 35 , Лекція 4
Відкрита

1. Вступ

Якщо ви щойно почали писати код зі спадкуванням, «перевантаження» й «перевизначення» звучать як два слова про одне й те саме: «я зробив метод із таким самим імʼям — отже, він якось замінився». І ось тут Kotlin (разом із компілятором) починає поводитися як суворий викладач: «ні-ні, це різні речі». Розуміти різницю важливо не заради термінів. Інакше код працюватиме, але не так, як ви очікуєте. А це ідеальні умови для тихих помилок, які потім раптово проявляються у звітах, логах і (так) у вашій самооцінці.

Почнімо з простої, «людської» картини:

  • Overloading — це коли в одному й тому ж місці (зазвичай в одному класі) ви оголошуєте кілька функцій з одним імʼям, але з різними параметрами.
  • Overriding — це коли ви створюєте нову реалізацію методу з базового класу в нащадку, і саме вона має спрацьовувати під час поліморфного виклику.

2. Overloading: вибір за параметрами на етапі компіляції

Перевантаження — це ситуація, коли у вас є кілька функцій з однаковим імʼям, але різними сигнатурами. Під сигнатурою тут насамперед маємо на увазі набір параметрів (кількість, типи, порядок). Тип, що повертається, сам по собі перевантаження «не рятує»: у Kotlin не можна зробити дві функції, які відрізняються лише типом результату. Інакше компілятору буде складно однозначно визначити, що саме ви мали на увазі.

Перевантаження вибирається на етапі компіляції: компілятор дивиться на аргументи виклику й підбирає відповідну версію. Це схоже на підбір ключа до замка: «ага, у вас тут Int, отже беремо format(Int)».

Мініприклад: перевантажуємо format(...) усередині одного класу

Уявімо, що в нашому консольному застосунку для обліку фінансів (умовно назвімо його BudgetCLI) ми хочемо гарно друкувати числа й рядки. Для цього можемо зробити дві версії однієї функції:


class ValueFormatter {

    fun format(x: Int): String = "int=$x"

    fun format(x: String): String = "str=\"$x\""
}

fun main() {
    val f = ValueFormatter()

    println(f.format(10))        // int=10
    println(f.format("ok"))      // str="ok"
}

Тут немає спадкування, немає open, немає override. Це просто два різні методи з одним імʼям. Якщо ви передасте 10, компілятор вибере format(Int). Якщо передасте "ok" — вибере format(String).

Часта думка новачка: «перевантаження — це щоб було зручніше викликати»

Це правда, але є нюанс. Іноді ви робите перевантаження, щоб дати «зручний короткий виклик» і «детальний виклик»:

class MoneyPrinter {

    fun print(amountCents: Int) {
        println("Сума: ${amountCents / 100.0}")  // Сума: 12.34 (наприклад)
    }

    fun print(amountCents: Int, currency: String) {
        println("Сума: ${amountCents / 100.0} $currency") // Сума: 12.34 USD
    }
}

Це перевантаження: два методи print, але параметри різні.

Overloading і значення за замовчуванням: схожі за відчуттями, різні за механікою

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

Ось варіант без перевантаження — лише з параметром за замовчуванням (ми це проходили раніше, коли вивчали функції):

class MoneyPrinter {

    fun print(amountCents: Int, currency: String = "USD") {
        println("Сума: ${amountCents / 100.0} $currency") // наприклад: Сума: 12.34 USD
    }
}

На практиці ви частіше обиратимете параметри за замовчуванням, щоб не плодити багато перевантажень. Але ключова думка цієї лекції така: overloading — це не про спадкування й не про заміну поведінки.

3. Overriding: вибір реалізації на етапі виконання

Перевизначення — це історія суто про спадкування: у нас є базовий клас, у ньому є метод (або властивість), дозволений до перевизначення через open, а в нащадку ми пишемо override і даємо свою реалізацію. Важливо, що Kotlin робить це максимально «явним»: якщо ви забули override, компілятор не промовчить. Це одна з причин, чому Kotlin часто називають мовою, яка «не дає вам випадково зламати проєкт».

Перевизначення вибирається на етапі виконання (runtime), коли програма вже працює. Тобто виклик x.render() вирішується не за тим, який тип має змінна x у коді, а за тим, обʼєкт якого реального класу лежить усередині.

Мініприклад: перевизначаємо метод у нащадку

open class ReportLine {
    open fun render(): String = "line"
}

class HeaderLine(private val title: String) : ReportLine() {
    override fun render(): String = "== $title =="
}

fun main() {
    val line: ReportLine = HeaderLine("January")
    println(line.render())   // == January ==
}

Зверніть увагу на ключову річ: змінна line має тип ReportLine, але всередині лежить HeaderLine. Під час виклику render() ми отримуємо реалізацію нащадка. Це і є поліморфізм у дії, заснований на перевизначенні.

У Kotlin overriding потрібно «дозволяти» через open

У Kotlin не можна «випадково» щось перевизначити, тому що:

1) базовий клас має бути open, щоб від нього взагалі можна було успадковувати;
2) метод у базовому класі має бути open, щоб його можна було перевизначити;
3) у нащадку потрібно явно написати override.

Цей потрійний захист інколи здається занудством, але на практиці він рятує від маси «магічних збігів»: ви думали, що перевизначили, а насправді просто написали інший метод.

4. Головна пастка: хотіли overriding, а зробили overloading

Найнебезпечніша плутанина виглядає так: ви пишете метод у нащадку з тим самим імʼям, але з трохи іншими параметрами. Компілятор не скаржиться, проєкт збирається, ви задоволені… а потім під час виклику через базовий тип спрацьовує базова реалізація. І ви починаєте підозрювати змову.

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

Базовий клас

open class Sender {
    open fun send(text: String) {
        println("надсилання: $text") // надсилання: ...
    }
}

Нащадок із пасткою

class EmailSender : Sender() {

    fun send(text: String, to: String) { // це НЕ override, а overload
        println("надсилання до $to: $text")     // надсилання до ...
    }
}

З погляду людини «логіка зрозуміла»: EmailSender же «вміє надсилати», отже це «заміна». Але з погляду мови це два різні методи: send(String) і send(String, String).

Тепер найважливіше: подивімося на код, який приймає базовий тип (це рівно той стиль, який ви бачили в лекції про поліморфізм).

fun notifyUser(sender: Sender) {
    sender.send("Звіт за бюджетом готовий")
}

fun main() {
    val s: Sender = EmailSender()
    notifyUser(s) // надсилання: Звіт за бюджетом готовий
}

Вивід буде базовим:

надсилання: Звіт за бюджетом готовий

Чому? Тому що функція notifyUser знає лише контракт Sender.send(String). А метод send(String, String) узагалі не є частиною цього контракту. Він існує, але «поруч», і поліморфізм його не бачить.

Як виглядає справжній overriding

Щоб це було перевизначенням, сигнатура має збігтися, а в нащадку має бути override:

class EmailSender(private val defaultTo: String) : Sender() {

    override fun send(text: String) {
        println("надсилання до $defaultTo: $text") // надсилання до ...
    }
}

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

5. Як компілятор вибирає метод

Дуже корисно тримати в голові, коли вибирається перевантаження, а коли — перевизначення. Це різні «моменти істини».

Намалюємо маленьку схему. Так, програмісти малюють схеми — інакше важко пояснити, що саме відбувається «в голові» компілятора.

flowchart TD
    A["Код: f(x)"] --> B{"Є кілька f(...) з різними параметрами?"}
    B -- "Так" --> C["Overloading: вибір за типами аргументів (compile-time)"]
    B -- "Ні" --> D{"Метод open + override в ієрархії класів?"}
    D -- "Так" --> E["Overriding: вибір за реальним типом обʼєкта (runtime)"]
    D -- "Ні" --> F["Звичайний виклик одного методу"]

Перевантаження — це здебільшого про «набір параметрів підходить / не підходить». Перевизначення — про «який обʼєкт насправді лежить усередині змінної базового типу».

Таблиця: overloading vs overriding

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

Характеристика Overloading Overriding
Де відбувається Зазвичай в одному класі (або в одному наборі функцій) В ієрархії «база → нащадок»
Що змінюється Набір параметрів (сигнатура) Реалізація того самого методу
Чи потрібні open/override Ні Так: open у базі та override у нащадку
Коли вибирається На етапі компіляції На етапі виконання
Який тип важливіший Типи аргументів виклику Реальний тип обʼєкта (поліморфізм)
Типова мета Зручність API: різні способи викликати Змінити або розширити поведінку базового класу

6. Практичний приклад: друк операцій

Зараз зробимо приклад, схожий на реальний код у навчальному застосунку, а не на абстрактний Sender. Припустімо, що в BudgetCLI у нас є базовий клас операції й два нащадки. Ми не йдемо в sealed class (ми це бачили раніше, але зараз нам важлива саме механіка спадкування).

Базова модель операцій

open class Transaction(
    val amountCents: Int,
    val note: String
)

class Expense(amountCents: Int, note: String, val category: String) :
    Transaction(amountCents, note)

class Income(amountCents: Int, note: String, val source: String) :
    Transaction(amountCents, note)

Синтаксис спадкування в Kotlin використовує двокрапку : і виклик конструктора базового класу.

Форматувальник з overriding

Тепер зробімо базовий форматувальник і «розумніший» форматувальник. Базовий друкує «як уміє», а розумніший — детальніше.

open class TransactionFormatter {

    open fun format(tx: Transaction): String {
        return "amount=${tx.amountCents} note=${tx.note}"
    }
}

class VerboseFormatter : TransactionFormatter() {

    override fun format(tx: Transaction): String {
        return "[TX] " + super.format(tx)
    }
}

Тут VerboseFormatter саме перевизначає format(Transaction), і виклик через базовий тип має вибирати правильну реалізацію.

fun printAll(formatter: TransactionFormatter, items: List<Transaction>) {
    for (tx in items) {
        println(formatter.format(tx))
    }
}

fun main() {
    val list = listOf<Transaction>(
        Expense(1500, "Lunch", "Food"),
        Income(250000, "Salary", "Job")
    )

    val f: TransactionFormatter = VerboseFormatter()
    printAll(f, list)
    // [TX] amount=1500 note=Lunch
    // [TX] amount=250000 note=Salary
}

Це overriding + поліморфізм.

Додаємо overloading і дивимося, де легко заплутатися

Припустімо, вам захотілося «трохи розумніше» форматувати саме Expense, щоб друкувати категорію. І ви пишете:

class VerboseFormatter : TransactionFormatter() {

    override fun format(tx: Transaction): String {
        return "[TX] amount=${tx.amountCents} note=${tx.note}"
    }

    fun format(tx: Expense): String { // overload, не override
        return "[EXPENSE] amount=${tx.amountCents} category=${tx.category}"
    }
}

Ніби логічно. Але тепер подивіться на наслідки.

Якщо ви викликаєте format напряму з Expense, компілятор вибере перевантаження:

fun main() {
    val f = VerboseFormatter()
    val e = Expense(1500, "Lunch", "Food")

    println(f.format(e)) // [EXPENSE] amount=1500 category=Food
}

Але якщо ви зберігаєте Expense у змінній типу Transaction (а в колекціях базового типу так буде постійно), тоді перевантаження може «зникнути» з вибору:

fun main() {
    val f = VerboseFormatter()

    val tx: Transaction = Expense(1500, "Lunch", "Food")
    println(f.format(tx)) // [TX] amount=1500 note=Lunch
}

І це не помилка. Це прямий наслідок того, що overload вибирається за типами аргументів, які компілятор бачить у місці виклику, а не за реальним типом обʼєкта. Реальний тип важливий для overriding, а не для overloading.

Якщо ви очікували, що всюди друкуватиметься "[EXPENSE] ...", ви щойно отримали маленьке, але дуже життєве «чому воно не працює».

7. Як не потрапляти в пастку

У Kotlin є хороший захисний механізм: якщо ви справді перевизначаєте метод, ви зобовʼязані написати override. Із цього випливає просте практичне правило, яке економить години:

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

Це корисно навіть тоді, коли ви не впевнені, чи збігається сигнатура. Спробуйте написати override — і компілятор або підтвердить, або пояснить, чому це не override. Kotlin тут виступає вашим напарником і не дає вам «тихо помилитися».

8. Типові помилки

Помилка № 1: «Додав метод із тим самим імʼям у нащадку — значить, перевизначив».
Так здається, доки ви не додасте або не приберете параметр чи не зміните тип аргументу. У цей момент ви насправді створюєте перевантаження, а не перевизначення. Симптом зазвичай такий: під час виклику через базовий тип виконується базова версія, а «ваша нова» ніби ігнорується.

Помилка № 2: очікувати, що overload вибирається за реальним типом обʼєкта.
Це дуже поширена плутанина: «але ж обʼєкт насправді Expense — чому не викликалося format(Expense)?». Тому що overload вибирає компілятор, який дивиться на тип змінної в місці виклику. Якщо змінна має тип Transaction, компілятор чесно вибере format(Transaction), навіть якщо всередині лежить Expense.

Помилка № 3: намагатися «лагодити» це перевірками типів у коді, що викликає.
Після попередньої помилки часто зʼявляється спокуса зробити when (tx) { is Expense -> ... }. Іноді це виправдано, але дуже часто це означає, що ви невдало спроєктували контракт базового класу. Якщо поведінка справді має різнитися за типами, зазвичай краще дати базовому класу open-метод і перевизначити його в нащадках, ніж розносити логіку по всіх місцях, де обʼєкт використовується.

Помилка № 4: занадто багато перевантажень заради «зручності», які починають конфліктувати.
Перевантаження добре, доки воно очевидне. Але якщо ви робите пʼять версій format(...) зі схожими параметрами (Int, Long, Number, nullable/не-nullable), то одного дня отримаєте overload resolution ambiguity і почнете ненавидіти все живе. У таких місцях часто простіше зробити один метод і використовувати іменовані аргументи або значення за замовчуванням (якщо це справді один смисловий контракт).

Помилка № 5: вважати, що перевизначення можливе без open у базовому класі.
У Kotlin клас і його члени за замовчуванням final, тож їх не можна перевизначати, доки ви явно не дозволили це через open. Якщо ви намагаєтеся будувати спадкування «в дусі старого Java» (де багато речей віртуальні за замовчуванням), компілятор постійно нагадуватиме: «спочатку домовтеся про API базового класу».

Помилка № 6: думати, що різниця лише термінологічна, а на практиці «все одно».
На практиці різниця величезна: перевантаження впливає на те, що компілятор вибере за аргументами, а перевизначення — на те, що буде вибрано під час поліморфного виклику. Якщо ви будуєте код із колекціями базового типу й обробкою «через спільний контракт», плутанина overload/override перетворюється на джерело дуже неприємних, але цілком логічних помилок.

1
Опитування
Успадкування та поліморфізм, рівень 35, лекція 4
Недоступний
Успадкування та поліморфізм
Успадкування та поліморфізм
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ