JavaRush/Courses/Kotlin SELF/Getting to Know Polymorphism

Getting to Know Polymorphism

Open

1. What polymorphism is and why you need it

If we explain it without pathos, polymorphism is when you write code once, and it behaves differently depending on which object is actually stored inside the variable. As if you have one “Do the thing” button, but under the hood there are different performers: one prints a receipt, another draws a report, a third just silently nods and does nothing (also useful in meetings).

It is important to remove the “magic” right away: polymorphism does not appear “by itself”. It grows out of three building blocks you already know:

  1. there is a common base type (base class),
  2. there is an open method (or an open property) in that base class,
  3. there are override redefinitions in subclasses.

Then the main thing happens: you call a method through the base type, and the subclass implementation is executed.

2. The variable type and the real object type

When you are just starting out, your brain wants to believe that “a variable is an object”. But in practice, a variable is more like a “label” (with a type!) that points to an object. And that label can be quite general.

Let’s make a small table to make the code easier to read at a glance:

What we see in code What it means Why it matters
val x: Base = Child()
Variable x has type Base, but inside it holds an object Child Through x only members of Base are available, but the behavior of open methods will be according to Child
val y = Child()
Both the variable and the object are Child All members of Child are available; polymorphism still works
fun f(b: Base)
The function does not care who is inside: Child, AnotherChild… This is exactly “write once — works differently”

A mini-example (I promise, no philosophy and no “it’s a reference on the heap”):

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
}

Key point: the variable a has type Animal, but the method sound() will run the cat version — because the actual object inside is really Cat.

3. Polymorphism as a cure for branching by type

Let’s be honest: when you get several types, you really want to do this:

when (x) {
    is A -> ...
    is B -> ...
    is C -> ...
}

And sometimes that is fine. But if you see a “dispatcher” function of 100 lines in a project, where every new type requires yet another branch, that is a warning sign.

Polymorphism suggests a different style: “let the object itself know how to handle itself”. Then instead of branching by type, you write one method call, and that’s it.

This is especially great for reports, formatting, logging, building text, generating strings for UI/CLI — anywhere the result depends on the “kind” of entity.

4. Mini-example: report lines with a single render() method

Now we will build an example that will evolve toward our training console app (let’s call it ExpenseTracker — an expense tracker). We won’t complicate the architecture, interfaces, or “grown-up patterns” — just classes and inheritance.

Idea: a report has different lines: a header, a separator, an expense line, a total. We want to store them in one list and print them with one loop.

Base class ReportLine and two subclasses

After this heading, don’t rush into code head-first: first keep the goal in mind. We want one printing function that does not know which specific line type it is. It simply calls render(). For that, all lines must have a common base type and a common method.

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

Note: render() is declared open, otherwise subclasses won’t be able to override it. And yes, Kotlin forces you to write override — that’s its way of preventing you from accidentally overriding something “by accident”.

A straightforward polymorphic call

fun main() {
    val line: ReportLine = HeaderLine("January report")

    println(line.render()) // == January report ==
}

This is already polymorphism: the variable type is ReportLine, but the behavior is HeaderLine.

5. A collection of the base type: one list — different objects

Now the best part: not just one object, but many at once, and of different kinds. It’s usually with collections that beginners first feel why this is needed.

After this heading, it’s important to understand the scenario. In a real app you almost always have a set of elements: tasks, messages, transactions, report lines. And that’s where polymorphism starts saving you dozens of lines of if/when.

fun main() {
    val lines: List<ReportLine> = listOf(
        HeaderLine("Expenses"),
        SeparatorLine(),
        HeaderLine("End") // yes, it's weird, but it's an example
    )

    for (line in lines) {
        println(line.render())
    }
    // == Expenses ==
    // ----------------
    // == End ==
}

One for, one call to render(), and no type checks. Each object “knows” how to turn itself into text.

6. Plugging into the app: an expense report without when (type)

Now let’s move to a more practical part for ExpenseTracker. Under this heading it’s important to “build a bridge”: we already have an expense model (we made a data class, fields, collections, possibly an enum for categories). Now we’ll add a report view — and we’ll do it so that the printing code does not depend on line kinds.

Let an expense look like this (minimal):

data class Expense(
    val title: String,
    val amount: Int
)

Add an “expense” line and a “total” line

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

A function that builds the list of report lines

Important setup: let the report be built by a separate function. It takes data (a list of expenses) and turns it into “report lines”. At this stage we don’t print — we only build the structure. That makes the code easier to test and extend.

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

For now there is a “stub” ExpenseLine(Expense("Coffee", 250)) — we’ll fix it now; we just kept the example short.

Let’s make a version that adds lines based on the list:

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
}

Here’s the important feeling: lines is a MutableList<ReportLine>. But we put objects of different classes inside. And that is fine, because they all are subclasses of ReportLine.

Printing the report: one loop, one method

fun printReport(lines: List<ReportLine>) {
    for (line in lines) {
        println(line.render())
    }
}

And now 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
}

Notice the architectural win: printReport() does not know at all what “total” or “expense” is. It can print ReportLine. That’s it.

7. What it would look like without polymorphism and why it’s worse

After this heading, it’s important not to bash when “as a concept”. It’s useful. But we want to see the difference: in one approach the “behavior rules” are smeared across the code, in the other they live next to the data.

Imagine we have no polymorphism and we made one structure:

data class RawLine(val kind: String, val text: String, val number: Int?)

And printing:

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

The problem will show up the moment you want to add a new line type, for example a “warning” or “group by category”. You will have to:

  1. agree on a new kind,
  2. find all places where there is when (kind),
  3. add a branch there,
  4. remember formatting and output “style”.

And in the polymorphic variant, you add a new class WarningLine : ReportLine() and that’s it. You don’t touch the old printing code at all.

8. Useful nuances: when, open/override, and super

When is/when by types is still appropriate

After this heading, it’s important not to fall into the extreme of “branching by type is evil”. Sometimes it’s the only reasonable option, especially when you get an object of a too-general type (for example, Any) or when the action does not belong to the object semantically.

For example, if you are doing “debug printing” or “statistics collection” for different types, and it is truly external logic, when (x) { is ... } can be clearer.

But a rule that works well in educational projects: if you see that you are branching by types to get the “right behavior”, first ask yourself: “Shouldn’t the object itself be able to do this via an open fun?”

For our report lines, the answer is obvious: a line object should be able to “render” itself into text.

Polymorphism only works with open + override

After this heading, it’s useful to pause and talk through a typical situation: a student writes two methods with the same name in the base class and the subclass, but forgets open/override, and then is surprised that “the wrong one gets called”.

Example where “polymorphism didn’t turn on”:

class BaseLine {
    fun render(): String = "base"
}

class ChildLine : BaseLine() {
    fun render(): String = "child" // this is NOT an override
}

Such code will not give you polymorphism, because render() in the base class is not open, and render() in the subclass is not override. In Kotlin this is not a “hidden override”, it is just a different function with the same name inside a different class (and the compiler, by the way, may complain in some cases).

The correct version:

open class BaseLine {
    open fun render(): String = "base"
}

class ChildLine : BaseLine() {
    override fun render(): String = "child"
}

super and polymorphism: “add on top, don’t replace”

The setup here is: in real projects you often don’t want to fully replace behavior; you want to “take the base and add to it”. It’s like taking a regular sandwich and adding cheese. It doesn’t stop being a sandwich (though it may become dangerously tasty).

For example, a base line can provide a common prefix:

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

open class LabeledLine(private val label: String) : ReportLine() {
    override fun render(): String = "[$label] " + super.render()
}

This example is more illustrative: in real code you would design the base class more carefully so that super.render() does not return emptiness. But the idea is important: super helps extend behavior without copy-paste.

And a reminder from the previous lecture: in base constructors and init, you should not poke open members, because you can accidentally hit a subclass override too early.

9. Typical mistakes when working with polymorphism

Mistake No. 1: making a “collection of everything” like List<Any> and then branching by type.
A beginner may think Any is “universal”. In reality, it’s giving up typing and IDE autocompletion. If the elements conceptually belong to one group (like report lines), it’s better to give them a base type and a shared contract.

Mistake No. 2: expecting polymorphism without open/override.
In Kotlin, polymorphism does not “turn on by default”, because methods are final, and that is intentional. If you forget open in the base class or override in the subclass, you will get either a compilation error or behavior that is not what you expected.

Mistake No. 3: trying to cram all logic into one giant when (type).
At first it seems simpler because “everything is in one place”. A week later it grows to ten branches, another week later to twenty, and then you start being afraid to touch that file (the classic “fragile Jenga tower” effect). Polymorphism helps distribute responsibility: each class is responsible for its own behavior.

Mistake No. 4: overloading the base class with too many open methods “just in case”.
Sometimes a student makes a base class where everything is open, because “what if we need it”. That reduces predictability and makes maintenance harder. Make open only what is truly part of the contract and will be overridden.

Mistake No. 5: trying to access subclass fields through a base-type variable.
If the variable has type ReportLine, you cannot access expense inside ExpenseLine. And that is fine: the base type promises only what is declared in it. If you need behavior, express it as an open fun in the base type, not as “reach into internals”.

Mistake No. 6: calling open methods in the base class init and getting “mysterious” bugs.
This is not the most common bug in small examples, but in real projects it’s unpleasant. A base class may call an overridden subclass method before the subclass has had a chance to initialize its fields. The rule is simple: base initialization must be self-sufficient.

1
Task
Kotlin SELF,  35 level,  3 lecture
Locked
Changing the greeting
Changing the greeting
1
Task
Kotlin SELF,  35 level,  3 lecture
Locked
Console interface
Console interface
1
Task
Kotlin SELF,  35 level,  3 lecture
Locked
Expense report
Expense report
1
Task
Kotlin SELF,  35 level,  3 lecture
Locked
Discounted price tag
Discounted price tag
Комментарии
  • популярные
  • новые
  • старые
Для того, чтобы оставить комментарий Вы должны sign in
This page has no comments yet