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("Amount: ${amountCents / 100.0}")  // Amount: 12.34 (например)
    }

    fun print(amountCents: Int, currency: String) {
        println("Amount: ${amountCents / 100.0} $currency") // Amount: 12.34 USD
    }
}

Это перегрузка: два метода print, но параметры разные.

Overloading и значения по умолчанию: похожи на ощущение, разные по механике

Очень легко спутать перегрузку с параметрами по умолчанию, потому что с точки зрения вызывающего кода эффект похож: «я могу вызывать короче или подробнее». Но механизм другой: параметры по умолчанию — это один метод, который можно вызывать разными способами; перегрузка — это несколько методов.

Вот вариант без перегрузки, только с дефолтным параметром (мы это проходили раньше, когда изучали функции):

class MoneyPrinter {

    fun print(amountCents: Int, currency: String = "USD") {
        println("Amount: ${amountCents / 100.0} $currency") // например: Amount: 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. Представим, что у нас есть отправка уведомлений (пусть даже просто в консоль): базовый отправитель умеет «отправить сообщение», а конкретный отправитель «в email» хочет ещё знать адрес.

Базовый класс

open class Sender {
    open fun send(text: String) {
        println("send: $text") // send: ...
    }
}

Наследник с ловушкой

class EmailSender : Sender() {

    fun send(text: String, to: String) { // это НЕ override, а overload
        println("send to $to: $text")     // send to ...
    }
}

С точки зрения человека «логика понятная»: EmailSender же «умеет отправлять», значит это «замена». Но с точки зрения языка это два разных метода: send(String) и send(String, String).

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

fun notifyUser(sender: Sender) {
    sender.send("Budget report is ready")
}

fun main() {
    val s: Sender = EmailSender()
    notifyUser(s) // send: Budget report is ready
}

Вывод будет базовым:

send: Budget report is ready

Почему? Потому что функция notifyUser знает только контракт Sender.send(String). А метод send(String, String) вообще не является частью этого контракта. Он существует, но он «рядом», и полиморфизм его не видит.

Как выглядит настоящий overriding

Чтобы это было переопределением, сигнатура должна совпасть, и у наследника должен быть override:

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

    override fun send(text: String) {
        println("send to $defaultTo: $text") // send to ...
    }
}

Теперь тот же вызов через базовый тип даст другое поведение, и это как раз то, чего мы обычно ждём от наследования.

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 использует двоеточие : и вызов конструктора базового класса.

Formatter с overriding

Теперь сделаем базовый formatter и «более умный» formatter. Базовый печатает «как умеет», а умный печатает подробнее.

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
Задача
Kotlin SELF, 35 уровень, 4 лекция
Недоступна
Форматтер значений
Форматтер значений
1
Задача
Kotlin SELF, 35 уровень, 4 лекция
Недоступна
Дружелюбный бот
Дружелюбный бот
1
Задача
Kotlin SELF, 35 уровень, 4 лекция
Недоступна
Письмо в ловушку
Письмо в ловушку
1
Задача
Kotlin SELF, 35 уровень, 4 лекция
Недоступна
Умный отправитель
Умный отправитель
1
Опрос
Наследование и полиморфизм, 35 уровень, 4 лекция
Недоступен
Наследование и полиморфизм
Наследование и полиморфизм
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ