JavaRush/Courses/Kotlin SELF/Pure computations in reports: minimal effects and no inpu...

Pure computations in reports: minimal effects and no input mutation

Open

1. Why reports need pure computations

Reports are a place in a program where a developer especially easily starts “to compute, and print, and also tweak the data a little along the way.” It feels faster: a println here, an in-place sort there, some counter in a var that is “obvious anyway.” The problem is that a report is usually called many times and in different scenarios, and any hidden effects later turn into mysteries like “why is the total different today even though the expenses are the same.”

In our console projects, a report is almost always a derived view of data. It should not change the data itself; otherwise you start living in parallel realities: first “the data as-is,” then “the data that the report accidentally tweaked.” And guess which reality wins? Usually the one with bugs.

What a pure function is and which effects get in the way

A pure function is a very down-to-earth idea: if you give it the same input, it should return the same result. And at the same time it must not “do something else” invisibly: print to the console, change global variables, rewrite the input list, depend on randomness, or on the current time.

With Kotlin, this is also convenient because the language nudges you toward careful work with collections: there are read-only interfaces (List, Map) and there are mutable variants (MutableList, MutableMap). A read-only collection does not let you “accidentally” change its contents, which reduces the chance of breaking data out of nowhere.

Printing and input inside computation

Printing (println) is the most insidious side effect because it seems harmless: “I’ll just look at the intermediate result.” But once println ends up inside a report function, the function stops being “compute and return” and turns into “compute and make noise along the way.” Then you call the report from somewhere else—and suddenly the console is flooded with messages you already forgot where they came from.

A sensible boundary is this: the computation function returns data (a number, a list of pairs, a map of totals), and printing happens outside, in main or in the CLI layer. Then you can test the report “in your head”: input → output, without distractions.

Mutating external state and why var is suspicious here

The second class of effects is changing something “outside”: an external var, a global variable, a shared application state collection. In reports, this is especially dangerous because a report is usually called to “see the current state,” not to “change the state.” If a report changes state, it becomes a command, even though semantically it is a query.

var is not evil by itself. But if you see an external counter in a report like var total = 0 that lives somewhere “outside” and accumulates values between calls, that is almost guaranteed to become a future puzzle. “Why does the second report print show twice the sum?”—because var remembers its past life.

2. The effects boundary: compute, render, print

To make reports readable and predictable, we introduce a simple separation: first compute the data, then turn it into nice text. This is not “architecture for architecture’s sake,” but practical stress savings: if the numbers are wrong, you fix the computation; if the text looks bad, you fix the renderer. And these tasks do not interfere with each other.

It is convenient to think about the boundary like this: “everything that returns a String is presentation; everything that returns an Int, List<...>, Map<...> is computation.” Yes, there are exceptions, but for our level and for a console app this rule almost always makes code better.

A small diagram (in our expense tracker):

flowchart LR
    A["Expense list
List of Pair(String, Int)"] --> B["Report computation
(pure functions)"]
    B --> C["Final data
Int / Map / List of Pair"]
    C --> D["Render to string
(StringBuilder locally)"]
    D --> E["println(...) in CLI"]

The point of the diagram is simple: printing is only at the very end, at the program boundary. Everything before it we try to do without side effects.

Allowed local mutation: StringBuilder and local MutableMap

An important clarification: “minimal side effects” does not mean “no var ever.” It means: do not mutate external state and input data; and if mutation is needed, do it locally, under control, and return the result outward.

The classic example is StringBuilder. It is mutated (pieces of text are appended), but this happens inside the render function, and the function returns a ready string. This is a good compromise: efficient and readable.

Another example is a local MutableMap inside an aggregation function. If you create a MutableMap inside the function and fill it without touching external variables and without returning the mutable reference itself “for further adventures,” it is usually acceptable. The idea is the same: local mutation for the sake of a result you will return as a Map.

3. fold as a “pure counter” instead of external var

When a beginner wants to compute a sum, they almost always write a counter and a loop. That is fine, and we used to do that. But in reports it is more convenient to express accumulation via fold: then accumulation happens “inside the expression,” not through external mutation. Technically, there is an accumulator inside fold too, but it is local and does not leak outward. As a result, your code is easier to read and harder to accidentally break.

Let’s compare two approaches.

The version with an external var:

fun calcTotalBad(expenses: List<Pair<String, Int>>): Int {
    var total = 0
    for (e in expenses) {
        total += e.second
    }
    return total
}

It works. But if you start printing inside the loop “for convenience,” or move total somewhere outside, you will quickly end up in a world of unexpected effects.

The version via fold:

fun calcTotal(expenses: List<Pair<String, Int>>): Int =
    expenses.fold(0) { acc, item -> acc + item.second }

Here it reads literally: “fold the list into a single number, starting from zero.” And, importantly, this function cannot “accidentally” keep state between calls—there is no place where it could live.

Mini-check:

fun main() {
    val expenses = listOf("food" to 10, "taxi" to 20)

    println(calcTotal(expenses)) // 30
    println(calcTotal(expenses)) // 30
}

Called twice—got the same result twice. And the report did not “get tired” between calls.

4. Do not mutate input data

The “input mutation” problem in reports often looks like this: you have a list of expenses in application state (usually a MutableList), you pass it into a report, and the report decides “for convenience” to change something in it. For example, delete invalid records, sort, normalize categories right in the original list. And then the user returns to the list and sees that the data changed—although they did nothing; they only asked for a report.

To prevent this, we keep two rules.

Rule one: accept read-only types in computation functions: List, Map, not MutableList, MutableMap. Even if inside the app you store a MutableList, you pass it outward (to the report) as a List. This does not make the collection “magically immutable,” but it greatly reduces the chance you will accidentally start mutating it, because the compiler simply will not allow it.

Rule two: if you really need to “change data for computation,” you do a transformation that returns a new result (map, filter, sorted, etc.), rather than rewriting the input list. Collection transformations in Kotlin build a new collection by default and do not mutate the original, which is exactly what we need for reports.

A simple example of normalization without mutating the input:

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

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

We do not change anything “inside” expenses; we create a new list. The old one stays as it was.

5. Pure reports in a mini expense tracker

Now let’s put this into practice: imagine we already have a console expense tracker that stores operations as pairs (category, amount). We want to write reports so that they do not print inside themselves, do not modify input data, and do not depend on external variables. At the same time, reports should be easy to compose: one computes numbers, another produces text, a third prints.

We will develop the same style: “pure computation → pure render → effect (println) in main.” This will help us later assemble a set of reports without turning main into a logic dump.

Data shape and normalization

Let’s start by fixing the basics: an expense is a Pair<String, Int>, where first is the category and second is the amount. A category can come in “dirty” (whitespace, different casing), so we normalize it in advance. It is convenient to make normalization a separate function, because it is repeated in every report.

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

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

Let’s test on a mini set:

fun main() {
    val raw = listOf(" Food " to 120, "FOOD" to 80, "taxi" to 250)
    val normalized = normalizeExpenses(raw)

    println(normalized) // [(food, 120), (food, 80), (taxi, 250)]
}

Important point: raw did not change; we created a new list. This is “no input mutation.”

Pure computation functions: total and totalsByCategory

Now let’s write pure computation functions. We will start with the total sum. We already discussed fold: it is ideal because it expresses “accumulation of the result” without external counters.

fun calcTotal(expenses: List<Pair<String, Int>>): Int =
    expenses.fold(0) { acc, item -> acc + item.second }

Check:

fun main() {
    val expenses = listOf("food" to 10, "taxi" to 20, "books" to 5)
    println(calcTotal(expenses)) // 35
}

Now totals by category. We have two readable options, both without mutating the input.

The first path: groupBy + mapValues. groupBy turns a list into a Map<K, List<V>>, where the key is the category and the value is the list of entries for that category. This is the “turning point” of the pipeline where the data shape changes radically.

fun totalsByCategory(expenses: List<Pair<String, Int>>): Map<String, Int> {
    val grouped = expenses.groupBy { it.first } // Map<String, List<Pair<String, Int>>>

    return grouped.mapValues { (_, items) ->
        items.fold(0) { acc, item -> acc + item.second }
    }
}

Check:

fun main() {
    val expenses = listOf("food" to 10, "food" to 5, "taxi" to 7)
    println(totalsByCategory(expenses)) // {food=15, taxi=7}
}

mapValues is a standard map transformation that builds a new map without changing the original. We obtained the final structure Map<String, Int>, and then a report can work with numbers rather than with the original records.

The second path (sometimes more convenient when you want a single pass): carefully fold the list into a map via fold, using a local MutableMap. This is local mutation, but it is completely hidden inside the function and does not touch the input list. In the end we return a Map as the “final data.”

fun totalsByCategoryFold(expenses: List<Pair<String, Int>>): Map<String, Int> =
    expenses.fold(mutableMapOf()) { acc, (cat, amount) ->
        acc[cat] = (acc[cat] ?: 0) + amount
        acc
    }

Check:

fun main() {
    val expenses = listOf("food" to 10, "food" to 5, "taxi" to 7)
    println(totalsByCategoryFold(expenses)) // {food=15, taxi=7}
}

Both variants are correct. In teaching examples, the first one (via groupBy) is often easier to read because it literally shows the steps: “grouped → computed the sum in each group.”

Pure rendering: turn final data into a string

Now let’s make a function that does not compute, but formats nicely. Its job is to take already computed strings/numbers and return a String. Local mutation of StringBuilder is allowed here because it is limited to one function and produces a predictable result.

fun renderTotals(rows: List<Pair<String, Int>>): String {
    val sb = StringBuilder()

    for ((category, sum) in rows) {
        sb.append(category).append(": ").append(sum).append('\n')
    }

    return sb.toString()
}

Let’s test:

fun main() {
    val rows = listOf("taxi" to 250, "food" to 200)
    print(renderTotals(rows))
    // taxi: 250
    // food: 200
}

Note: renderTotals does not print anything by itself. It returns a string, and printing is the caller’s decision. This is the clean effects boundary.

6. Common mistakes with “pure” reports

Mistake #1: the report “computes” via println.
A common style is “computed and immediately printed,” and sometimes it also prints intermediate values “for debugging” along the way. While writing, it seems convenient, but later the report becomes impossible to reuse: you will want to show it in another format or combine it with another report, and it is already making noise in the console. The remedy is simple: computation returns data, rendering returns a string, printing lives outside.

Mistake #2: the report function accepts MutableList and changes it “for convenience.”
This is especially insidious because semantically a report is “to look,” not “to modify.” If inside the report you delete elements, fix categories, or change ordering, then after calling the report the application state is already different. Usually this surfaces as a “flaky” bug: called the report once—everything is OK; called it a second time—everything is weird. Accepting List and returning a new result, rather than editing the input, helps.

Mistake #3: an external counter in var that lives longer than one call.
Sometimes “for speed” or “for simplicity” the sum is put into a variable outside, and the report adds to it. After a couple of calls you get accumulation “for all time,” even though you wanted “for the current data.” fold solves this almost always: it expresses accumulation locally and does not allow accidentally preserving the past between calls.

Mistake #4: mixing normalization and formatting in one place.
When inside the renderer you suddenly start doing trim().lowercase() and also replacing spaces, reports become inconsistent: somewhere the category is normalized, somewhere it is not. As a result, one report will show Food, another food, a third will show food . It is better to agree: normalization is an early step, shared across all reports, and it returns “clean data,” while formatting is the final step and works with already normalized data.

Mistake #5: returning “almost finished text” too early and losing the ability for further processing.
If you convert data into strings too early, it becomes inconvenient to sort, filter by threshold, take top-N, and so on: you have to parse it back or build extra structures. A good habit is to keep data as numbers and pairs until the very end, and only as the last step produce a String. Then the report stays flexible and is easy to extend with the next pipeline steps.

1
Task
Kotlin SELF,  26 level,  2 lecture
Locked
Robot Energy
Robot Energy
1
Task
Kotlin SELF,  26 level,  2 lecture
Locked
Clean tags
Clean tags
1
Task
Kotlin SELF,  26 level,  2 lecture
Locked
Console commands
Console commands
1
Task
Kotlin SELF,  26 level,  2 lecture
Locked
Pretty report
Pretty report
Комментарии
  • популярные
  • новые
  • старые
Для того, чтобы оставить комментарий Вы должны sign in
This page has no comments yet