JavaRush /Courses /Kotlin SELF /fold and reduce: start value, empty collections, and safe...

fold and reduce: start value, empty collections, and safe variants

Kotlin SELF
Level 25, Lesson 0
Available

1. Aggregations: from a collection to a single result

Once you start writing real programs (even small console ones), you quickly find out that “get a list” is only half the job. The user (or you) usually wants the final outcome: the total sum, the maximum value, the minimum value, a concatenated string, a summary by categories. In other words, not “show me all expenses”, but “how much was spent in total” or “what was the most expensive purchase”.

And this is where a typical “beginner magic trick” happens: someone makes an external var sum = 0, then writes forEach { sum += it } and proudly considers the problem solved. Formally, yes. But in terms of style and reliability it is like carrying water in your hands: you can deliver it, but the chance of spilling is high, and your hands end up sticky.

fold and reduce give us discipline: we explicitly tell Kotlin, “here is a collection, here is the accumulation rule, return the final result”. This is called an aggregation: “many elements → one result”.

The official definition is simple: fold() and reduce() sequentially apply an operation that takes the accumulated value and the next element and returns a new accumulated result.

2. The accumulator: how to read (acc, x) -> newAcc

When you first see fold(0) { acc, x -> acc + x }, it looks like an incantation. Relax: it is not an incantation; it is just a very honest loop packaged into a function.

The main idea is the accumulator (usually called acc, short for accumulator). This is the value that “accumulates” step by step. At any moment it holds the intermediate total.

Imagine a piggy bank. Each element of the collection is a coin. acc is how much is already in the piggy bank. At each step you do the same thing: take the next coin and update the sum.

You can even draw it as a mini diagram:

flowchart LR
    A["initial / start"] --> B["acc after the 1st element"]
    B --> C["acc after the 2nd element"]
    C --> D["acc after the 3rd element"]
    D --> E["result"]

The meaning of the lambda in fold/reduce is always the same: “how to get the next acc from (acc and x)”.

One more important thing: the lambda must return a new accumulator value. In Kotlin that means the last expression in the lambda is the return value (if you do not write return, which is almost never needed inside lambdas).

If you want to see the “insides” of fold, the docs often show an almost literal implementation via a loop: first accumulator = initial, then for each element accumulator = combine(accumulator, element), and at the end accumulator is returned. So it is not magic, just a neatly packaged for.

3. fold: start value, empty collections, and the identity element

Up to this point, many collection operations have been “safe by contract”: filter on an empty list returns an empty list, count returns 0, and nothing crashes. fold continues that philosophy: it is always defined because it has a start value initial.

The key phrasing: fold(initial) assumes at the first step that the current accumulated value equals initial, and only then starts “absorbing” the elements of the collection. This is exactly how it differs from reduce.

The most basic example: sum of numbers

Sum is a genre classic because it has a good identity start: 0.

fun main() {
    val xs = listOf(1, 2, 3)

    val sum = xs.fold(0) { acc, x -> acc + x }
    println(sum) // 6
}

In plain English: “start from 0 and for each x add it to acc”.

Why fold is good on empty lists

This is where fold feels like a relief: an empty list does not cause drama. It simply means “we added nothing to the start”, and the result equals the start value.

fun main() {
    val empty = emptyList<Int>()

    val sum = empty.fold(0) { acc, x -> acc + x }
    println(sum) // 0
}

This is not a bug. This is the contract: fold always returns a value because it always has an initial.

Collection
fold(initial)
Why
empty
initial
there was not a single update step
non-empty accumulated result
initial
was updated on each element

The identity element: why the start value must make sense

When a beginner learns fold(0), a dangerous thought appears: “aha, so the start is always 0”. And that is how programs are born that run consistently but incorrectly. This is especially sneaky: the compiler stays silent, there are no errors, but the result is garbage. Computers are generally excellent at calculating nonsense—one of their strong sides.

The meaning of the start value is to be an identity element (or at least a sensible start) for your operation.

If you are computing a product, starting with 0 will turn the result into 0 forever, because 0 * anything = 0. So we use 1.

fun main() {
    val xs = listOf(2, 3, 4)

    val product = xs.fold(1) { acc, x -> acc * x }
    println(product) // 24
}

The start does not have to be a number—and that is fine. fold can accumulate any type because you provide the start. It is important to understand now: initial often determines not only the value, but also the result type.

4. reduce: start = first element, and the consequences

reduce looks like “fold, but shorter”: you do not have to write the start value. And that is exactly why reduce is both convenient and slightly dangerous.

The contract of reduce is this: it does not accept initial, which means that on the first step it has to get the initial acc from somewhere. It takes it from the collection: the first element becomes the initial accumulator, and the operation is first applied to the pair (first element, second element).

From this come two practical consequences: you cannot apply reduce to empty collections, and sometimes reduce gives “the wrong meaning” if you expected every element to be processed the same way starting from the first.

Sum via reduce (when the list is definitely not empty)

fun main() {
    val xs = listOf(1, 2, 3)

    val sum = xs.reduce { acc, x -> acc + x }
    println(sum) // 6
}

This works because sum is associative, and the “first element as start” is sensible.

The trap: the first element was not processed like the others

A classic example: “sum the doubled elements”. With fold, everything is fair: each element is doubled. With reduce, it is not, because the first element suddenly becomes the starting acc without being doubled.

Let’s compare:

fun main() {
    val xs = listOf(5, 2, 10, 4)

    val ok = xs.fold(0) { acc, x -> acc + x * 2 }
    val tricky = xs.reduce { acc, x -> acc + x * 2 }

    println(ok)     // 42
    println(tricky) // 37
}

Why?
reduce starts with acc = 5, and then does 5 + 2*2 + 10*2 + 4*2. The first 5 was not doubled because it never went through the “normal rule”—it just became the start.

This is not Kotlin “setting you up”; it is a direct consequence of the reduce contract.

The most important danger: an empty collection

Since reduce takes the start from the first element, on an empty collection that is impossible. Therefore reduce on an empty collection will throw an exception (that is, the program will crash if you do not handle it).

5. Safe variants and choosing the right approach

When data might be missing, the correct model is not “I hope the list is not empty”, but “if there is no data, there is no result”. Kotlin does not try to comfort you here: a safe contract is expressed via a nullable type.

reduceOrNull: the result may be absent

reduceOrNull { ... } does exactly that: if the collection is empty, it returns null; if it is not empty, it works like reduce.

fun main() {
    val empty = emptyList<Int>()

    val sum = empty.fold(0) { acc, x -> acc + x }
    val reduced = empty.reduceOrNull { acc, x -> acc + x }

    println(sum)     // 0
    println(reduced) // null
}

Meaning in plain language:

  • fold says: “a result always exists, even if there is no data (it is the start value)”.
  • reduceOrNull says: “if there is no data, there is no result”.

Both approaches are fine. The choice depends on the task. For a sum, “0” is often convenient because 0 means “there was nothing”. But for, say, the “minimum purchase”, 0 may already be a lie, because a purchase for 0 USDT looks suspicious even during sales.

A practical rule of thumb

If an operation has a natural identity start (sum → 0, product → 1, string → "", list → emptyList(), etc.), then fold is usually more readable and reliable: it is defined even on empty data.

If “first element as start” is logical, and you guarantee a non-empty collection by the meaning of your program, then you can use reduce.

But as soon as that guarantee becomes questionable (user input, filtering, searching), it is better to switch to reduceOrNull immediately so the contract stays honest and safe.

6. Example from a console app: computing totals

Now we will carefully apply fold/reduceOrNull in our “practical” console app that we built in the days about collections and commands. A reminder of the model: we have a list of operations (for example, expenses) that we are still storing without OOP—just as pairs Pair<String, Int>, where first is the category and second is the amount.

Important: we are not adding new commands or building architecture here—only showing how, in real code, to move “manual math” into readable aggregation functions.

A type for readability (still without classes)

typealias Entry = Pair<String, Int>

Yes, it is still a Pair, but now there is less noise in the code: List<Entry> is easier to read than List<Pair<String, Int>>.

Total sum via fold(0)

typealias Entry = Pair<String, Int>

fun totalAmount(entries: List<Entry>): Int =
    entries.fold(0) { acc, (_, amount) -> acc + amount }

fun main() {
    val entries = listOf("food" to 120, "taxi" to 250)
    println(totalAmount(entries)) // 370
}

Notice a nice thing: if entries is empty, totalAmount still correctly returns 0, and you do not need to write if (entries.isEmpty()) ...

The largest operation via reduceOrNull

Now a more interesting example: find the entry with the maximum amount. Here a “0” start does not fit: we do not want to invent a non-existent entry if the list is empty. So the contract “there may be no result” is the honest one.

typealias Entry = Pair<String, Int>

fun maxEntryOrNull(entries: List<Entry>): Entry? =
    entries.reduceOrNull { acc, x ->
        if (x.second >= acc.second) x else acc
    }

fun main() {
    val entries = listOf("food" to 120, "taxi" to 250, "food" to 80)

    val max = maxEntryOrNull(entries)
    println(max) // (taxi, 250)
}

How to print the result without forgetting about null

typealias Entry = Pair<String, Int>

fun maxEntryOrNull(entries: List<Entry>): Entry? =
    entries.reduceOrNull { acc, x -> if (x.second >= acc.second) x else acc }

fun main() {
    val entries = emptyList<Entry>()

    val max = maxEntryOrNull(entries)
    println(max?.second ?: 0) // 0
}

Here we are not claiming the maximum purchase is “0”. We are just saying: “if there is no maximum, for output I will show 0”.

It is a subtle but important difference: the internal contract of the function is honest, and output formatting is a separate decision.

7. Typical mistakes when working with fold and reduce

Mistake #1: using reduce where the list may be empty.
This usually happens not “because the person is bad”, but because they wrote a filter and then forgot the filter can throw everything away. The result is a crash out of nowhere. The habit is simple: if you do not have a rock-solid guarantee of non-emptiness, use fold (when there is a proper initial) or reduceOrNull (when there may be no result).

Mistake #2: choosing the wrong initial and getting a “consistently wrong” result.
fold(0) for sum is great. fold(0) for product is a guaranteed zero. fold(Int.MAX_VALUE) for minimum sometimes works, but looks like a magic number and breaks if you change the type or the data range. The meaning of the start value should be stated: what it means and why it does not distort the result.

Mistake #3: thinking reduce is “the same thing, just shorter”, and not accounting for the first element.
The trap shows up in tasks like “sum of doubled elements”: the first element in reduce becomes the start and does not go through your processing rule. The same lambda in fold and reduce can produce different results precisely because of the start contract.

Mistake #4: mixing up the parameter roles in the lambda and accidentally writing the rule “backwards”.
In fold/reduce the first parameter is the accumulated value (acc), and the second is the next element (x). If you name them randomly (a, b, c), your brain starts making mistakes by the third line. A simple naming discipline like acc and x (or acc and item) really reduces the number of bugs.

Mistake #5: turning the aggregation lambda into a 30-line mini novel.
Technically you can, but it is hard to read. If complex logic appears inside the aggregation, it is better to create small local val values inside the lambda or extract part of the computation into a separate function, and keep only the “glue” in fold/reduce: (acc, x) -> newAcc. A good guideline is to remember that, essentially, this is a short loop: the logic should be “loop-sized”, not “thesis-sized”.

1
Task
Kotlin SELF, level 25, lesson 0
Locked
Total score
Total score
1
Task
Kotlin SELF, level 25, lesson 0
Locked
Shopping cart
Shopping cart
1
Task
Kotlin SELF, level 25, lesson 0
Locked
Doubling the report
Doubling the report
1
Task
Kotlin SELF, level 25, lesson 0
Locked
Temperature Peak
Temperature Peak
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION