JavaRush/Courses/Kotlin SELF/Overloading vs overriding: how not to mix them up

Overloading vs overriding: how not to mix them up

Open

1. Introduction

If you’ve only just started writing code with inheritance, “overloading” and “overriding” can sound like two words for the same thing: “I made a method with the same name, so it must have been replaced somehow.” And this is where Kotlin (together with the compiler) starts acting like a strict teacher: “No-no, my friend, these are different things.” Understanding the difference matters not for the sake of terminology, but because otherwise the code will work—just not the way you expect. Which means it’s perfect for quiet bugs that will later show up unexpectedly in reports, logs, and your self-esteem.

Let’s start with a simple, human-friendly picture:

  • Overloading — when, in the same place (usually in the same class), you define several functions with the same name but different parameters.
  • Overriding — when you create a new implementation of a base-class method in a subclass, and that implementation must be used for a polymorphic call.

2. Overloading: selection by parameters at compile time

Overloading is when you have multiple functions with the same name but different signatures. By “signature” here we primarily mean the parameter list (count, types, order). The return type by itself does not “save” an overload: in Kotlin you cannot define two functions that differ only by return type, because then the compiler cannot unambiguously choose what you meant.

An overload is selected at compile time: the compiler looks at the call arguments and picks a suitable version. It’s like choosing the right key for a lock: “aha, you’ve got an Int here, so we use format(Int).”

Mini example: overloading format(...) inside one class

Imagine that in our console app for tracking finances (let’s call it BudgetCLI) we want to print numbers and strings nicely. We can provide two versions of the same function:

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"
}

There is no inheritance here, no open, no override. These are simply two different methods with the same name. If you pass 10, the compiler chooses format(Int). If you pass "ok", it chooses format(String).

A common beginner thought: “overloading is just to make calls more convenient”

That’s true, but there’s a nuance. Sometimes you overload to provide a “convenient short call” and a “detailed call”:

class MoneyPrinter {

    fun print(amountCents: Int) {
        println("Amount: ${amountCents / 100.0}")  // Amount: 12.34 (for example)
    }

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

This is overloading: two print methods, but different parameters.

Overloading and default values: feel similar, work differently

It’s very easy to confuse overloading with default parameters, because from the caller’s point of view the effect is similar: “I can call it shorter or more explicitly.” But the mechanism is different: default parameters are one method that can be called in different ways; overloading is multiple methods.

Here is a variant without overloading, only with a default parameter (we covered this earlier when studying functions):

class MoneyPrinter {

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

In practice, you will more often choose default parameters so you don’t have to create lots of overloads. But the key idea of today’s lecture is that overloading is not about inheritance and not about replacing behavior.

3. Overriding: selecting an implementation at runtime

Overriding is strictly about inheritance: we have a base class, it has a method (or property) that is allowed to be overridden via open, and in the subclass we write override and provide our own implementation. Importantly, Kotlin makes this as explicit as possible: if you forget override, the compiler will not stay silent. This is one reason Kotlin is often called a language that “doesn’t let you accidentally break the project.”

Overriding is chosen at runtime (runtime), when the program is already running. That is, the call x.render() is resolved not by what type the variable x has in code, but by what real class the object inside actually is.

Mini example: overriding a method in a subclass

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 ==
}

Notice the key point: the variable line has type ReportLine, but it actually contains a HeaderLine. And when calling render() we get the subclass implementation—this is polymorphism in action, based on overriding.

In Kotlin, overriding must be “allowed” via open

In Kotlin, you cannot “accidentally” override something, because:

1) the base class must be open so it can be inherited from at all;
2) the method in the base class must be open so it can be overridden;
3) in the subclass you must explicitly write override.

This triple protection can feel pedantic sometimes, but in practice it saves you from a lot of “magical coincidences” where you thought you overrode something but in fact just wrote a different method.

4. The main trap: you wanted overriding, but you made overloading

The most dangerous confusion looks like this: you write a method in the subclass with the same name but slightly different parameters, the compiler does not complain, the project builds, you’re happy... and then when calling through the base type, the base implementation runs, and you start suspecting a conspiracy.

To see this, let’s embed an example into our hypothetical BudgetCLI. Imagine we have sending notifications (even if it’s just to the console): the base sender can “send a message”, and the concrete “email” sender also wants to know the address.

Base class

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

Subclass with the trap

class EmailSender : Sender() {

    fun send(text: String, to: String) { // this is NOT override, but overload
        println("send to $to: $text")     // send to ...
    }
}

From a human perspective, the “logic is clear”: EmailSender “knows how to send”, so this is a “replacement.” But from the language’s perspective these are two different methods: send(String) and send(String, String).

Now the most important part: let’s look at code that accepts the base type (this is exactly our style after the polymorphism lecture).

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

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

The output will be the base one:

send: Budget report is ready

Why? Because the function notifyUser only knows the contract Sender.send(String). And the method send(String, String) is not part of that contract at all. It exists, but it is “next to it”, and polymorphism does not see it.

What real overriding looks like

For this to be overriding, the signature must match, and the subclass must have override:

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

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

Now the same call through the base type will produce different behavior, which is exactly what we usually expect from inheritance.

5. How the compiler chooses a method

It’s very useful to keep in mind when overloading is chosen and when overriding is chosen. These are different “moments of truth.”

Let’s draw a small diagram: yes, programmers draw diagrams—otherwise how do you explain what’s happening in the compiler’s head?

flowchart TD
    A["Kod: f(x)"] --> B{"Est' neskol'ko f(...) s raznymi parametrami?"}
    B -- "Da" --> C["Overloading: vybor po tipam argumentov (compile-time)"]
    B -- "Net" --> D{"Metod open + override v ierarkhii klassov?"}
    D -- "Da" --> E["Overriding: vybor po real'nomu tipu ob"ekta (runtime)"]
    D -- "Net" --> F["Obychnyy vyzov odnogo metoda"]

Overloading is mostly about “does the parameter set fit or not.” Overriding is about “what object actually sits inside a variable of the base type.”

Table: overloading vs overriding

To reinforce the differences, it’s convenient to compare these concepts in one table. It won’t replace understanding, but it helps a lot when, in code, you’re “not sure what this is.”

Characteristic Overloading Overriding
Where it happens Usually in one class (or one set of functions) In a “base → subclass” hierarchy
What changes Parameter set (signature) Implementation of the same method
Do you need open/override No Yes: open in the base and override in the subclass
When it is chosen At compile time At runtime
Which type matters more Types of the call arguments The real type of the object (polymorphism)
Typical goal API convenience: different ways to call Change or extend the behavior of the base class

6. Practical example: printing transactions

Now we’ll make an example that looks like real code in a learning app rather than an abstract Sender. Suppose that in BudgetCLI we have a base transaction class and two subclasses. We won’t go into sealed class (we saw it earlier, but right now we care specifically about inheritance mechanics).

Base transaction model

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 inheritance syntax uses a colon : and a call to the base-class constructor.

Formatter with overriding

Now let’s create a base formatter and a “smarter” formatter. The base prints “as it can”, and the smart one prints more verbosely.

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)
    }
}

Here VerboseFormatter is truly overriding format(Transaction), and a call through the base type should select the correct implementation.

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
}

This is overriding + polymorphism.

Add overloading and see where it’s easy to get confused

Suppose you want to format specifically an Expense a bit “smarter” so you can print the category. And you write:

class VerboseFormatter : TransactionFormatter() {

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

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

Seems logical. But now look at the consequences.

If you call format directly with an Expense, the compiler will pick the overload:

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

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

But if you store an Expense in a variable of type Transaction (and in base-type collections this will be constant), then the overload can “disappear” from selection:

fun main() {
    val f = VerboseFormatter()

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

And this is not a bug. It’s a direct consequence of the fact that an overload is chosen based on the argument types that the compiler sees at the call site, not on the real type of the object. The real type matters for overriding, not for overloading.

If you expected "[EXPENSE] ..." to be printed everywhere, you have just encountered a small but very real “why doesn’t it work?”

7. How not to fall into the trap

Kotlin has a good safety mechanism: if you are truly overriding a method, you must write override. And that leads to a practical rule that saves hours:

If you write a method in a subclass and think “I’m replacing base behavior”, your first thought should be “where is override here?” If override doesn’t compile (the compiler complains), then you are not overriding—you’re doing something else.

And this is useful even when you’re not sure whether the signature matches. Try writing override—and the compiler will either confirm it or explain why it is not an override. Kotlin acts like a partner here, preventing you from “silently making a mistake.”

8. Typical mistakes

Mistake #1: “I added a method with the same name in the subclass—so I overrode it.”
It feels that way until you add or remove a parameter, or change an argument type. At that moment you are actually creating an overload, not an override. The symptom usually looks like this: when called through the base type, the base version runs, and “your new one” seems to be ignored.

Mistake #2: expecting an overload to be chosen by the real runtime type of the object.
This is a very common confusion: “but the object is really an Expense, why didn’t it call format(Expense)?” Because overload selection is done by the compiler, which looks at the variable type at the call site. If the variable has type Transaction, the compiler will honestly choose format(Transaction), even if an Expense is stored inside.

Mistake #3: trying to “fix” this with type checks in the calling code.
After the previous mistake, there’s often a temptation to write when (tx) { is Expense -> ... }. Sometimes it’s justified, but very often it means you designed the base-class contract poorly. If behavior really must differ by type, it’s usually better to give the base class an open method and override it in subclasses than to spread the logic across every place where the object is used.

Mistake #4: too many overloads “for convenience” that start to conflict.
Overloading is good as long as it’s obvious. But if you make five versions of format(...) with similar parameters (Int, Long, Number, nullable/non-nullable), one day you’ll get overload resolution ambiguity and start hating everything alive. In such places, it’s often simpler to make one method and use named arguments or default values (if it’s really one semantic contract).

Mistake #5: thinking overriding is possible without open in the base class.
In Kotlin, a class and its members are final by default, and you cannot override them until you explicitly allow it via open. If you try to build inheritance “like old-school Java style” (where many things are virtual by default), the compiler will keep reminding you: “first agree on the base-class API.”

Mistake #6: thinking the difference is only terminological and in practice “it’s all the same.”
In practice, the difference is huge: overloading affects what the compiler selects based on arguments, while overriding affects what is selected for a polymorphic call. If you build code around collections of a base type and processing “through a common contract”, mixing up overload/override becomes a source of very unpleasant—but logical—bugs.

1
Task
Kotlin SELF,  35 level,  4 lecture
Locked
Value Formatter
Value Formatter
1
Task
Kotlin SELF,  35 level,  4 lecture
Locked
Friendly bot
Friendly bot
1
Task
Kotlin SELF,  35 level,  4 lecture
Locked
Mail in a Trap
Mail in a Trap
1
Task
Kotlin SELF,  35 level,  4 lecture
Locked
Smart sender
Smart sender
Комментарии
  • популярные
  • новые
  • старые
Для того, чтобы оставить комментарий Вы должны sign in
This page has no comments yet