JavaRush/Courses/Kotlin SELF/Secondary constructors and this(...) delegation in Kotlin...

Secondary constructors and this(...) delegation in Kotlin

Open

1. Why secondary constructors are needed

When you are just starting to write classes, it feels like one constructor is quite enough: “well, we always create Expense(title, amount, category)”. And then reality hits: data comes in different formats. A user types a number as a string, the console gives you a single line title;amount;category, sometimes you want to make some fields default, and sometimes you want a “short path” like Expense("Coffee", 250), and set the category to a default value.

And this is where two common pain scenarios usually show up.

First: you drag parsing and normalization outside, and main() turns into a place where input, parsing, validation, error messages, and the logic of adding to a list all live together. You get a “combine harvester” that is hard to read and even harder to maintain.

Second: you try to “fit everything” into the primary constructor, add a bunch of parameters (String?, flags, defaults), and the constructor turns from a “clean entry point” into a “buffet”: you can pick anything, but it is unclear what you will get out.

Secondary constructors are a third path: keep the primary constructor strict and clear, and format additional creation methods as “adapters” that bring input into the correct shape.

Kotlin explicitly describes secondary constructors as additional initialization ways, useful when you need multiple options for creating an object.

2. Fundamentals: syntax and delegation

How a secondary constructor is declared

A secondary constructor is declared inside the class body and starts with the keyword constructor. It has parameters and (usually) a body in { ... }.

The most important part is this(...). This is constructor delegation: we say “create the object through another constructor of this same class” (usually via the primary). If a primary constructor exists, secondary constructors must delegate to it (directly or via other secondary ones), so that the overall initialization path is unified.

Minimal example of “format adaptation”:

class User(val name: String, val age: Int) {

    constructor(name: String, ageText: String) : this(
        name = name,
        age = ageText.toIntOrNull() ?: 0
    )
}

fun main() {
    val u1 = User("Ann", 20)
    val u2 = User("Bob", "18")
    val u3 = User("Eve", "oops")

    println("${u1.name} ${u1.age}") // Ann 20
    println("${u2.name} ${u2.age}") // Bob 18
    println("${u3.name} ${u3.age}") // Eve 0
}

Notice: the secondary constructor here does not “duplicate” object creation. It converts ageText: String into age: Int and delegates further.

A secondary constructor as an adapter, not a second “factory”

It is very tempting to start writing “real initialization” in a secondary constructor: validation, normalization, complex conditions, assignments. But if you do that, you effectively build two different roads to object creation — and they will start to diverge in rules.

Today you call trim() in one constructor, tomorrow you forget it in another, the day after tomorrow you add a new check only in one, and now the same class behaves differently depending on which constructor created it. That is unpleasant even without explicit bugs — it is simply hard to read such code.

A practical mental model looks like this:

Primary constructor + init is a “single factory” for producing valid objects.

Secondary constructors are “intake points” that accept input in different forms and bring it to the factory standard.

If you want to see it as a diagram:

flowchart TD
    A["Raw input (strings, CSV, partial data)"] --> B["Secondary constructor (adapter)"]
    B --> C["Primary constructor + init (unified rules)"]
    C --> D["Ready valid object"]

The point is not only “architectural beauty”. Delegation to the primary guarantees that the shared initialization code (including init blocks and property initializers) runs consistently.

Direct and indirect delegation

In real life, a class may have several secondary constructors. Sometimes it is convenient for one secondary to delegate to another, and that one then delegates to the primary. This is indirect delegation, and Kotlin considers it normal: the main thing is that in the end we reach the primary (if it exists).

Let’s compare two types of delegation with a simple example:

class Point(val x: Int, val y: Int) {

    // direct delegation to primary
    constructor(x: Int) : this(x, 0)

    // indirect delegation: first to constructor(x: Int), then to primary
    constructor() : this(0)
}

fun main() {
    println("A: ${Point(5, 7).x}, ${Point(5, 7).y}") // A: 5, 7
    println("B: ${Point(5).x}, ${Point(5).y}")       // B: 5, 0
    println("C: ${Point().x}, ${Point().y}")         // C: 0, 0
}

Why is this convenient? Because sometimes you want to express the rule “if y is not specified — it is 0”, and “if nothing is specified at all — both x and y are 0”. A delegation chain lets you avoid repeating these rules.

3. Case study: Expense in an expense tracker

Now we will implement the application that evolves throughout the course: a simple console expense tracker (a hypothetical BudgetBuddy).

The problem we face: in a CLI the user enters data as strings. That means somewhere we need to do trim(), toIntOrNull(), checks like “empty/non-empty”, “amount > 0”. If we keep this outside, main() will bloat. If we shove many input variants into the primary constructor, it becomes muddy.

So we will do this:

  • The primary constructor Expense(...) accepts already-normalized values.
  • Secondary constructors accept raw input and convert it into a normal form.

Primary constructor and init: unified validity rules

Let’s start with the primary constructor and init. init is a convenient place for validation via require(...).

class Expense(
    val title: String,
    val amount: Int,
    val category: String
) {
    init {
        require(title.isNotBlank()) { "title must not be blank" }
        require(amount > 0) { "amount must be > 0, got $amount" }
        require(category.isNotBlank()) { "category must not be blank" }
    }

    override fun toString(): String = "$title: $amount [$category]"
}

The checks here are simple: empty strings are forbidden; the amount must be positive. This is our invariant: an expense object should not exist in a meaningless state like “empty title, amount -500”.

Secondary constructor: accept strings and normalize

Now let’s make a convenient way to create Expense from strings, as if they came from readln(). Here the secondary constructor will do three things: trim(), parse amount, and delegate to the primary.

Important: we do not turn the secondary constructor into a “monster”; it stays an adapter. The “main rules” still live in init.

class Expense(
    val title: String,
    val amount: Int,
    val category: String
) {
    constructor(titleInput: String, amountText: String, categoryInput: String) : this(
        title = titleInput.trim(),
        amount = amountText.trim().toIntOrNull() ?: 0,
        category = categoryInput.trim()
    )

    init {
        require(title.isNotBlank()) { "title must not be blank" }
        require(amount > 0) { "amount must be > 0, got $amount" }
        require(category.isNotBlank()) { "category must not be blank" }
    }

    override fun toString(): String = "$title: $amount [$category]"
}

If a person enters amountText = "oops", then toIntOrNull() returns null, we substitute 0, and then init “fails” expense creation via require(amount > 0). This is fail-fast behavior: the object is not created because the input is invalid.

One more format: a single line title;amount;category

In console utilities you often want to shorten input. For example, a command might look like: add Coffee;250;food.

That is, the user enters one line and we parse it. Let’s do the bare minimum: split(';'), check the number of parts, and delegate again.

Since a constructor cannot “return null”, you need to pick a strategy: either set defaults (which is risky), or fail via require. For a learning project and correctness discipline, failing is better.

class Expense(
    val title: String,
    val amount: Int,
    val category: String
) {
    constructor(line: String) : this(
        titleInput = line.split(';').getOrNull(0) ?: "",
        amountText = line.split(';').getOrNull(1) ?: "0",
        categoryInput = line.split(';').getOrNull(2) ?: ""
    )

    constructor(titleInput: String, amountText: String, categoryInput: String) : this(
        title = titleInput.trim(),
        amount = amountText.trim().toIntOrNull() ?: 0,
        category = categoryInput.trim()
    )

    init {
        require(title.isNotBlank()) { "title must not be blank" }
        require(amount > 0) { "amount must be > 0, got $amount" }
        require(category.isNotBlank()) { "category must not be blank" }
    }

    override fun toString(): String = "$title: $amount [$category]"
}

There is a “suspicious spot” here: line.split(';') is called three times. We could optimize it, but for now we choose simplicity and readability. The goal right now is for the code not to look like an incantation in an ancient language.

Notice the chain: constructor(line) delegates to constructor(titleInput, amountText, categoryInput), and that delegates to the primary. This is exactly indirect delegation.

What this looks like in main()

When secondary constructors are done correctly, main() starts to look simpler: it chooses “which way to create an object”, but does not constantly do trim() and toIntOrNull().

Mini demo:

fun main() {
    val expenses = mutableListOf<Expense>()

    val e1 = Expense("Coffee", 250, "food")
    val e2 = Expense("  Taxi  ", "510", " transport ")
    val e3 = Expense("Pizza;900;food")

    expenses.add(e1)
    expenses.add(e2)
    expenses.add(e3)

    expenses.forEach { println(it) }
    // Coffee: 250 [food]
    // Taxi: 510 [transport]
    // Pizza: 900 [food]
}

main() almost does not know how the data is normalized. It says, “create an expense”, and the class itself guarantees that the expense will be valid — or it will not be created at all.

4. What to avoid in secondary constructors

Secondary constructors are easy to turn into a place where “everything happens”. But if you want the class to remain predictable, it helps to remember a few language constraints and a few common-sense constraints.

From the language perspective: if a class has a primary constructor, a secondary must delegate to it directly or indirectly. This rule helps avoid a situation where one initialization branch bypasses shared code.

From the design perspective: if you discover that in your secondary constructor you have 30 lines of logic, a lot of if/else, and also “just a bit of console printing for debugging”, you are probably building a second factory for object creation. This is not “forbidden”, but it almost certainly leads to rule desynchronization.

And one more practical point: when you create an object via a secondary constructor, delegation runs first (primary + init), and only then the body of the secondary executes. So do not assume the secondary body is “the very beginning of the object’s life”. It is more like “an additional step after base initialization”.

5. Typical mistakes

Mistake No. 1: duplicate checks in every secondary constructor.
Students often do this: they validate one thing in the primary, validate something else in the second constructor, and in the third they forget to validate at all. A week later you add a new rule (“category cannot be empty”) and start remembering where else you need to add require. The fix is simple: shared validity rules live in one place — in init (or next to the primary), and secondary constructors only convert input into the needed form.

Mistake No. 2: try to “bypass” the primary constructor and assemble the object in pieces.
Sometimes you want to write a secondary constructor that does not delegate via this(...), but simply assigns fields. In Kotlin, if a primary constructor exists, you cannot do that: a secondary constructor must delegate to the primary (directly or indirectly).
In practice, this is even good: the language forces you to keep a single initialization path and not create “secret entrances” into the class.

Mistake No. 3: make a secondary constructor too “smart” and turn it into a scenario.
A secondary constructor is a bad place for complex branching like “if the command is this — do that, otherwise do this”. Such code usually belongs not to object creation, but to application logic (for example, CLI command parsing). If you cram it into a constructor, the object will start to “know” too much about the outside world, and the class becomes hard to test and read.

Mistake No. 4: use “magic defaults” that create an invalid object.
In the examples above we did toIntOrNull() ?: 0, and that is OK only because afterwards we have require(amount > 0). But if you leave defaults like 0, "", "UNKNOWN" without validation, you end up with an object that formally exists but is semantically invalid. Later such objects “leak” into collections, reports, sorting — and you catch bugs far away from the creation site.

Mistake No. 5: confuse the role of a secondary constructor with default values.
Sometimes people write a secondary constructor where a default parameter in the primary would be enough. For example, if the category is almost always "food", it may be better to make category: String = "food" in the primary rather than multiplying constructor(title, amount). Secondary constructors are needed when the input form changes or you want to express a delegation chain as separate “convenient entry points”, not just substitute one value.

1
Task
Kotlin SELF,  31 level,  2 lecture
Locked
Character coordinates
Character coordinates
1
Task
Kotlin SELF,  31 level,  2 lecture
Locked
Player profile
Player profile
1
Task
Kotlin SELF,  31 level,  2 lecture
Locked
Station Clock
Station Clock
1
Task
Kotlin SELF,  31 level,  2 lecture
Locked
Expense from a string
Expense from a string
Комментарии
  • популярные
  • новые
  • старые
Для того, чтобы оставить комментарий Вы должны sign in
This page has no comments yet