JavaRush /Courses /Kotlin SELF /Readable chains of operations — results without mutating ...

Readable chains of operations — results without mutating the original collection

Kotlin SELF
Level 22, Lesson 4
Available

1. Why write chains instead of loops

If you have ever written code like “create an empty list → iterate with a loop → add inside an if → then iterate again”, you already know how the feeling gradually appears that a program is like a pot: we constantly stir something and are afraid to spill it. Call chains in Kotlin let you describe data processing as a sequence of steps, where each step returns a result, and you can see the “pipeline” (pipeline) right in the code.

The key idea of this lecture: we try not to mutate the original collection, but to build new results on top of it. That is, instead of “took the list of expenses and removed everything unnecessary from it”, we do “took the list of expenses and got a new list—only what we need”. This makes the code more predictable: you worry less that somewhere “over there” the list has already changed, and debugging turns into ghost hunting.

A small mini-table to keep the focus:

Operation What it returns Mutates the original collection? Typical role
filter { ... }
List<T>
no “keep what matches”
map { ... }
List<R>
no “turn into something else”
find { ... }
T?
no “find the first match”
any/all
Boolean
no “quick check”
count { ... }
Int
no “how many elements match”
forEach { ... }
Unit
no “do an action at the end”

2. The skeleton of a readable chain of operations

When you first start writing chains, it is tempting to make them “as short as possible”, then proudly look at 12 calls in a row and think, “I am a functional genius.” Usually at this moment, future-you from the future pulls out a sign saying “for what” and silently shows it to you. So we need a simple, repeatable skeleton that reads like a story: first prepare the data, then select, then transform, then get the result and (if needed) output it.

Let’s lock this in as a pattern. Not as a dogma, but as a convenient reading rhythm for code:

flowchart TD
    A[Data source: List⟨T⟩] --> B[Normalization: map / trim / lowercase]
    B --> C[Selection: filter]
    C --> D[Transformation: map / mapNotNull]
    D --> E[Result: find / any / all / count]
    E --> F[Action: forEach / println]

What matters here is that forEach most often comes at the very end, when you have already obtained what you need to count/prepare. forEach is “print to the screen”, “log”, “call a side-effecting function”, but not “compute and return” (that is what count, any, find, and friends are for).

A mini example “one line of meaning”: clean strings, keep non-empty ones, turn them into lengths, count how many lengths are >= 3:

fun main() {
    val raw = listOf("  a  ", "   ", "kotlin", " go ")

    val count = raw
        .map { it.trim() }
        .filter { it.isNotEmpty() }
        .map { it.length }
        .count { it >= 3 }

    println(count) // 2
}

3. Learning example: expenses and commands without mutation

So chains do not hang in a vacuum, let’s continue the practical console project from the days about collections and commands. We are still “without OOP” (classes will come later), so we will represent an expense as a Triple:

  • first — amount,
  • second — category ("food", "taxi", "books"),
  • third — comment.

Yes, Triple is not the peak of readability, but it is an honest “bridge” to classes. And by the way, it is a great reason to write small helper functions so the code does not look like a magic ritual with expense.first all over the program.

Expense data and helper functions

Let’s make a couple of functions that improve readability (and will be useful for callable references ::):

fun normalizeCategory(raw: String): String =
    raw.trim().lowercase()

fun formatExpense(e: Triple<Int, String, String>): String =
    "${e.first} | ${e.second} | ${e.third}"

Notice the style: each function does one thing. As soon as a function starts “normalizing, validating, and printing” all at once, that is usually a signal that you are trying to pack three different steps into one.

A long chain vs intermediate vals

Almost any chain has a “breaking point”: up to it, it reads easily; after it, your brain starts pretending it urgently needs lunch. In Kotlin, it is normal and even useful to break long chains into intermediate vals with descriptive names. This is not “extra code”, but hints to the reader: “here we already got cleaned-up expenses”, “here we kept only the food category”.

Let’s compare approaches with an example: print all expenses in the category the user entered, and the category must be normalized.

Option “everything in one chain” (short, but it can be heavy if there are more steps):

fun printByCategory(
    expenses: List<Triple<Int, String, String>>,
    rawCategory: String
) {
    expenses
        .filter { it.second == rawCategory.trim().lowercase() }
        .map(::formatExpense)
        .forEach(::println)
}

Option with intermediate vals (usually reads more calmly, especially for beginners):

fun printByCategory(
    expenses: List<Triple<Int, String, String>>,
    rawCategory: String
) {
    val category = normalizeCategory(rawCategory)
    val filtered = expenses.filter { it.second == category }
    val lines = filtered.map(::formatExpense)

    lines.forEach(::println)
}

Here you can literally see: “entered category → normalized → filtered → formatted → printed.” And that is exactly the “step” structure we talked about.

Commands: input → normalization → result

In console applications, there is almost always a layer of “raw input”: the user enters a command as a string; the string may contain spaces, weird casing, and generally complete freedom of personality. Chains help turn “raw input” into normal data without the code looking like noodles made of if and temporary variables.

Let’s make a small function: the user enters a string with numbers separated by spaces, and we want to get a list of Int, ignoring garbage. This is a classic case for mapNotNull, because toIntOrNull() returns Int?, and we want to drop null results.

fun parseInts(raw: String): List<Int> =
    raw.split(" ")
        .map { it.trim() }
        .filter { it.isNotEmpty() }
        .mapNotNull { it.toIntOrNull() }

Here it is especially important not to confuse things: filterNotNull() removes exactly null, not empty strings. Empty strings are removed with a separate filter { it.isNotEmpty() }.

Now let’s apply the idea to our project: suppose we have a "list food" command that prints expenses by category. We want to:

  • not change expenses,
  • get a new list of strings for printing,
  • print via forEach at the end.
fun listCommand(
    expenses: List<Triple<Int, String, String>>,
    rawCategory: String
) {
    val category = normalizeCategory(rawCategory)

    expenses
        .filter { it.second == category }
        .map(::formatExpense)
        .forEach(::println)
}

Notice: not a single add, not a single remove, no mutation. The list command should be “pure” in the good sense: it only displays.

4. Terminal operations: find, any, all, count

A very common beginner mistake: they do filter, get a list, and then iterate again with a loop and manually count or search. That is normal at the beginning (you are honestly practicing loops), but now we have operations that express the intent directly: “find”, “check”, “count”. When code speaks human language, it rarely breaks by accident.

find: find the first matching expense

In a real application you often want: “Show the first purchase over 5000” or “Find the first record in category "taxi".” find returns T?, and that is not a whim but honesty: the element might not exist.

fun printFirstOver(
    expenses: List<Triple<Int, String, String>>,
    threshold: Int
) {
    val found = expenses.find { it.first > threshold }

    val message = found?.let(::formatExpense) ?: "NOT_FOUND"
    println(message)
}

The style matters here: we do not do found!!. We explicitly describe the “not found” behavior via the Elvis operator ?:.

any and all: checks

Checks are where chains are especially nice: instead of a manual flag var ok = false, we write what we actually mean.

For example, let’s check whether there is at least one suspiciously large expense (say, more than 100_000):

fun hasHugeExpense(expenses: List<Triple<Int, String, String>>): Boolean =
    expenses.any { it.first > 100_000 }

And now an all example: are all expenses non-negative? Yes, it sounds like a “sanity check”, but in user input, sanity is an infrequent guest.

fun allNonNegative(expenses: List<Triple<Int, String, String>>): Boolean =
    expenses.all { it.first >= 0 }

Separately, let me remind you of an important behavior: all { ... } on an empty collection returns true (because there is not a single counterexample). That is fine; you just need to remember it when interpreting the result.

count: how many expenses match

If you need a number, count is usually more expressive than filter {...}.size, because it directly says: “count by condition.”

fun countInCategory(
    expenses: List<Triple<Int, String, String>>,
    rawCategory: String
): Int {
    val category = normalizeCategory(rawCategory)
    return expenses.count { it.second == category }
}

5. Compute separately, print separately

When code is small, it is very tempting to do println right inside map or filter: “so what, it works.” It works—until you want to reuse the computation without printing, test it, or simply understand why output is duplicated. A useful habit: first build the result as a value (List<String>, Int, Boolean), and only then perform an action (println, forEach).

Let’s compare with a simple example.

Bad (mixed transformation and printing):

fun printBad(expenses: List<Triple<Int, String, String>>) {
    expenses.map {
        println(it) // side effect inside map — the reader cries
        it.first
    }
}

Better (first produce strings, then print):

fun printGood(expenses: List<Triple<Int, String, String>>) {
    val lines = expenses.map(::formatExpense)
    lines.forEach(::println)
}

forEach at the end of the chain is like putting a period at the end of a sentence. You have already computed everything, and now you simply “use” the result.

6. A mini scenario in main

To see the style end-to-end, let’s assemble a small main fragment. It does not pretend to be a full command parser (that was earlier), but it shows how chains are used in a real flow: got data → made a check → printed a list → counted.

fun main() {
    val expenses = listOf(
        Triple(1200, "food", "coffee"),
        Triple(8000, "taxi", "airport"),
        Triple(300, "food", "bread")
    )

    println(hasHugeExpense(expenses)) // false
    println(allNonNegative(expenses)) // true

    listCommand(expenses, " FOOD ")
    // 1200 | food | coffee
    // 300 | food | bread

    println(countInCategory(expenses, "food")) // 2
}

This shows the main point clearly: the expenses list does not change; we only “take slices from it” and build results.

7. Common mistakes when building chains

Mistake #1: turning forEach into a “universal shovel” for everything.
Beginners often do this: inside forEach they filter, format, count, and update external variables. It works, but the code becomes hard to verify and hard to change: any edit adds yet another side effect. A good rule: everything that returns a value (map/filter/find/any/all/count) should happen before forEach, and forEach should remain the final step: “use the result.”

Mistake #2: printing inside map or filter and getting “magic” output.
map and filter read like data transformations. When a println suddenly appears inside them, the reader’s expectations break: they think they are building a new list, but in fact they are also triggering side effects. This is especially dangerous when the chain gets reused elsewhere: printing will “suddenly” start there, even though you did not plan it.

Mistake #3: writing a chain of 12 calls and being proud it is “in one line.”
Compactness is not the same as readability. If a chain is long, split it with intermediate vals with normal names. You will not lose code quality—on the contrary, you will make it friendly to future-you, who will open the project in a week and will not have to remember what exactly it was doing at step six.

Mistake #4: forgetting where null appears and trying to use the result as non-null.
find returns T?, toIntOrNull() returns Int?, and this is not an “obstacle”, but a signal: the “not found / could not parse” scenario is possible. If you ignore this and reach for !!, you are not “simplifying code”, you are postponing the crash to the moment when the user enters something unexpected. Most often, it is better to use ?: or clean the data up front with mapNotNull.

Mistake #5: confusing filterNotNull() with filtering out empty strings.
filterNotNull() drops only null elements and also improves the result type to non-null, which is very convenient. But an empty string "" is not null, and it will not go anywhere. So for strings you usually need a duo: first map { it.trim() }, then filter { it.isNotEmpty() }, and filterNotNull() applies only if you really have a List<String?>.

1
Task
Kotlin SELF, level 22, lesson 4
Locked
Clean words
Clean words
1
Task
Kotlin SELF, level 22, lesson 4
Locked
Even Numbers from Junk
Even Numbers from Junk
1
Task
Kotlin SELF, level 22, lesson 4
Locked
Purchase within Budget
Purchase within Budget
1
Task
Kotlin SELF, level 22, lesson 4
Locked
Email validation
Email validation
1
Survey/quiz
Collection operations, 22 level, 4 lesson
Unavailable
Collection operations
Collection operations
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION