JavaRush/Courses/Kotlin SELF/Pipeline decomposition: step functions and intermediate v...

Pipeline decomposition: step functions and intermediate val

Open

1. Why long chains are not always readable

When you first discover collection operations, there is a temptation to write everything “in one line”: normalization, filtering, grouping, sorting, and formatting. The code turns out compact, like vacuum packaging… but sometimes just as hard to unpack as that very vacuum packaging.

The problem is not that “chains are bad”. The problem is that a chain, like a toy snake, has a critical length: up to it everything is cute, past it you are no longer sure where the head is and where the tail is. And if a week later someone asks you to “change the filtering rule just a little” — you will stare at the code the way people stare at a microwave manual in Japanese.

Let’s start with a simple example. Imagine that our training console project (a hypothetical “expense tracking”) stores operations the same way we agreed in the day plans: List<Pair<String, Int>>, where first is the category, and second is the amount.

fun main() {
    val expenses = listOf(
        " Food " to 120,
        "FOOD" to 80,
        " taxi" to 250,
        "Books" to 90
    )

    val report = expenses
        .map { it.first.trim().lowercase() to it.second }
        .groupBy { it.first }
        .mapValues { (_, items) -> items.fold(0) { acc, item -> acc + item.second } }
        .entries
        .map { it.key to it.value }
        .sortedByDescending { it.second }
        .take(3)
        .joinToString(separator = "\n") { (cat, sum) -> "$cat: $sum" }

    println(report)
}

Does it work? Yes. Is it readable? Well… “in its own way”. You can immediately see the key pain here: in one place we mixed different levels of logic. map and groupBy are about data, sortedByDescending and take are about how to present it, joinToString is already about text. And inside mapValues there is a hidden fold, which is itself an important aggregation stage.

Our goal is to make the pipeline read like a “story”: what we did first, what we did next, and what each step means.

2. Tools for decomposing a pipeline

When a chain starts to “grow”, there are two simplest and most powerful tools that save readability. And they save it without magic, without new libraries, and without the “functional zen” philosophy. Everything is as down-to-earth as possible: you just add clear names and stopping points.

The first tool is intermediate val. These are “stops” in the pipeline: you take the result of an important step, put it into a variable with a meaningful name, and move on. You get code where each val is like a chapter title in a book.

The second tool is step functions. If an operation repeats across multiple reports (for example, category normalization), or if the step is complex by itself, you extract it into a separate function with a clear “input → output” contract. This is especially good when you want main to stay short and read like a script.

It is important that both tools solve the same problem, but from different sides. val is convenient when the step is needed once and the context matters right here. A function is convenient when you want to name and reuse the step, and also when you want to hide technical details. In Kotlin this fits naturally because collection operations are ordinary functions, often taking lambdas.

Intermediate val as stopping points

Intermediate val is the fastest way to make code clearer today without changing the project architecture. Mentally it is similar to how you solve a math problem: instead of writing the final half-page formula right away, you first write out “let A = …”, “let B = …”, and then everything becomes more transparent.

Let’s rework our report example into several val. Note: the logic is almost the same, but reading is now linear.

fun main() {
    val expenses = listOf(
        " Food " to 120,
        "FOOD" to 80,
        " taxi" to 250,
        "Books" to 90
    )

    val normalized = expenses.map { (cat, amount) ->
        cat.trim().lowercase() to amount
    }

    val totalsByCategory = normalized
        .groupBy { (cat, _) -> cat }
        .mapValues { (_, items) -> items.fold(0) { acc, item -> acc + item.second } }

    val top3 = totalsByCategory
        .entries
        .map { it.key to it.value }
        .sortedByDescending { (_, sum) -> sum }
        .take(3)

    val report = top3.joinToString("\n") { (cat, sum) -> "$cat: $sum" }

    println(report)
}

There is an important effect here: each val is “type control”. With your eyes you can see that after normalization you still have List<Pair<String, Int>>, after grouping and mapValues you have Map<String, Int>, and after sorting you got a list of pairs for output.

And one more detail: we applied sortedByDescending to a list of pairs, rather than trying to “sort a Map”. This is typical reality: Map is not about order, so before sorting you often move to entries and turn them into a list. And if you feel tempted to say “let me just do sort()” — remember the difference: sort() mutates a mutable collection in place, sorted()/sortedBy... return a new result.

A small pipeline diagram

To see the structure better, it is sometimes useful to draw it as a chain of types:

List<Pair<String, Int>>
  → (normalize)
List<Pair<String, Int>>
  → (groupBy + sum)
Map<String, Int>
  → (entries → sort → take)
List<Pair<String, Int>>
  → (render)
String

When you keep this “type ladder” in mind, you get less confused about what you can sort, what you can group, and what is already “ready-made numbers”.

Step functions

Intermediate val works great while you are writing one report. But as soon as there are several reports, you will start copying chunks: normalization repeats, “sum by categories” repeats, rendering repeats. And that is where the classic story begins: “I fixed it a little in one place, and forgot in another.” The program is kind of hinting that it is time to extract repeated steps into functions.

A step function is a small function with a single responsibility and a clear signature. You can read it like a phrase: normalizeExpenses, totalsByCategory, topCategories, renderLines. A good step function usually does not print to the console and does not read input — it just takes data and returns new data.

Let’s start with the most repeatable one: category normalization. It is a simple step, but it is often needed in all reports.

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

Now let’s apply it to normalization of the list of operations.

fun normalizeExpenses(expenses: List<Pair<String, Int>>): List<Pair<String, Int>> {
    return expenses.map { (cat, amount) ->
        normalizeCategory(cat) to amount
    }
}

The map operation is the basic collection transformation operation: it builds a new collection from the results of transforming elements.

Now let’s extract “totals by category” into a separate function. We will continue using groupBy, which gives Map<String, List<Pair<String, Int>>>, and then reduce each group to a sum. fold fits well here because it has a start value, and it is safe on empty lists.

fun totalsByCategory(expenses: List<Pair<String, Int>>): Map<String, Int> {
    val grouped = expenses.groupBy { (cat, _) -> cat }
    return grouped.mapValues { (_, items) ->
        items.fold(0) { acc, item -> acc + item.second }
    }
}

And finally, the step “prepare top-N”. It should work with already aggregated data (Map<String, Int>), because top by “raw operations” has a completely different meaning.

fun topCategories(totals: Map<String, Int>, n: Int): List<Pair<String, Int>> {
    return totals.entries
        .map { it.key to it.value }
        .sortedByDescending { (_, sum) -> sum }
        .take(n)
}

take(n) is that simple top-N trick: sort first, then take the first n.

Now rendering. Since it is important for us to keep “calculation” separate from “text”, rendering is a separate function that returns a String. To assemble lines, it is convenient to use StringBuilder.

import kotlin.text.StringBuilder

fun renderCategoryTotals(rows: List<Pair<String, Int>>): String {
    val sb = StringBuilder()
    for ((cat, sum) in rows) {
        sb.append(cat).append(": ").append(sum).append('\n')
    }
    return sb.toString()
}

3. A readable script in main

When the steps are extracted, main starts to look like a script rather than a lab notebook of chaotic formulas. And this is an important psychological effect: you read the code top to bottom and understand the intent without having to squint at every lambda.

Let’s put everything together on small data:

fun main() {
    val rawExpenses = listOf(
        " Food " to 120,
        "FOOD" to 80,
        " taxi" to 250,
        "Books" to 90
    )

    val normalized = normalizeExpenses(rawExpenses)
    val totals = totalsByCategory(normalized)
    val top3 = topCategories(totals, n = 3)
    val report = renderCategoryTotals(top3)

    println(report)
}

One thing is especially nice here: if you want to add another report (for example, “all categories in alphabetical order”), you will not rewrite half the pipeline. You will just plug in a different “presentation” function after totals. And if the business rule for normalization changes (for example, “replace spaces with _” or “remove dots”), you change one function, normalizeCategory.

4. Contracts and step names

There is a useful habit that quickly makes code mature: look not only at the function body, but also at its “external contract” — the name, parameters, and return type. A good step function can be read almost without its body: you see what goes in, what comes out, and you understand where it lives in the pipeline.

In Kotlin this is especially clear with fold: it takes a start value and a combiner function and returns the accumulated result. That is, from the signature alone it is clear: this is aggregation that turns many elements into a single value.

For our steps, the same idea:

  • normalizeExpenses(expenses: List<Pair<String, Int>>): List<Pair<String, Int>> — the type does not change, the data quality does.
  • totalsByCategory(...): Map<String, Int> — a “turning point”: from a list of operations we move to aggregates.
  • topCategories(...): List<Pair<String, Int>> — prepare the representation (sorting and limiting).
  • renderCategoryTotals(...): String — turn data into text.

If at some point you catch yourself with a function like fun doReportStuff(x: Any): Any, that is not “versatility”, it is a signal: “we lost the structure”.

With variables and functions in pipelines, a simple rule applies: the name should explain the meaning of the step, not the internal mechanics. If you named a variable tmp2, you are telling your future self: “I don’t know what this is, good luck.” If you named it totalsByCategory, you literally labeled what is inside.

Below is a small table that is useful to keep in mind when your hand reaches for data2:

Bad (hides meaning) Good (explains the step)
tmp, tmp2, res
normalized, filtered, totalsByCategory
process(), handle()
normalizeCategory(), topCategories()
map1, map2
totals, counts, categoryToSum

And note: we are not required to extract everything into functions. Sometimes one intermediate val is enough — and the chain becomes readable. Decomposition is not the religion of “everything into functions”, but common sense: “everything should have a name”.

5. Common mistakes

Mistake #1: extracting a “step” into a function that also prints.
At first you want to do it like this: fun totalsByCategory(...) { ... println(...) }. But then you lose flexibility: you will not be able to use the result in another report without extra output. It is much more robust when steps return data and output is done separately, at the end.

Mistake #2: intermediate val that clarify nothing.
Sometimes a student honestly adds val a = ...; val b = ...; val c = ..., but the names are so abstract that it becomes even worse. Decomposition works only together with meaningful names: normalized, totals, top3, reportText.

Mistake #3: a “do-everything” function that does normalization, filtering, sorting, and rendering at once.
Such a combine-harvester seems convenient while it is the only one. Then you add a second report — and realize there is nothing to reuse. A pipeline lives well when each step has one result type and one responsibility: transform a list, get a map, get a top list, get a string.

Mistake #4: losing type control due to missing “stopping points”.
In a long chain it is easy to stop understanding what exactly you have right now: List? Map? Sequence? String? Intermediate val are often needed not “for beauty”, but so that at any moment you can say: “at this step I have a Map<String, Int>”. groupBy is exactly the step where the type changes sharply to Map<K, List<V>>, and it is convenient to capture this explicitly.

1
Task
Kotlin SELF,  26 level,  1 lecture
Locked
Profile tags
Profile tags
1
Task
Kotlin SELF,  26 level,  1 lecture
Locked
Task priorities
Task priorities
1
Task
Kotlin SELF,  26 level,  1 lecture
Locked
Top categories
Top categories
1
Task
Kotlin SELF,  26 level,  1 lecture
Locked
Valid scores
Valid scores
Комментарии
  • популярные
  • новые
  • старые
Для того, чтобы оставить комментарий Вы должны sign in
This page has no comments yet