Skip to content

03 · Collections Deep Dive

Level 1 introduced List, map, and filter as everyday tools. This module goes deeper into the fold/reduce family (and where each one can bite you), lazy views for processing large sequences without building intermediate collections, and how to pick the right collection type — List, Vector, Set, or Map — for the job instead of defaulting to List everywhere.

map, filter, flatMap — the basics, extended

val nums = List(1, 2, 3, 4, 5)

println(nums.map(_ * 2))                 // List(2, 4, 6, 8, 10)
println(nums.filter(_ % 2 == 0))         // List(2, 4)
println(nums.flatMap(n => List(n, -n)))  // List(1, -1, 2, -2, 3, -3, 4, -4, 5, -5)

flatMap is map followed by flattening one level — each input element can expand into zero, one, or many output elements, which is exactly what you need when a transformation itself returns a collection (or, as you'll see in Module 4, an Option).

fold vs reduce: the difference that matters

Both combine a collection down to a single value with a binary operation, but they differ in one important way: fold takes an explicit starting value and an operation whose accumulator and element types don't even have to be the same; reduce uses the collection's first element as the starting point and requires the operation to combine same-typed values.

val nums = List(1, 2, 3, 4, 5)

val sum = nums.fold(0)(_ + _)
println(sum)   // 15

// fold can change type entirely -- the accumulator starts as a String
val trace = nums.foldLeft("start")((acc, n) => s"$acc-$n")
println(trace)   // start-1-2-3-4-5

val product = nums.reduce(_ * _)
println(product)   // 120

The trap: reduce throws on an empty collection, because there's no first element to seed it with. fold never has this problem, because you supplied the starting value yourself:

try
  List.empty[Int].reduce(_ + _)
catch
  case e: UnsupportedOperationException => println(s"caught: ${e.getMessage}")
  // caught: empty.reduceLeft

println(List.empty[Int].fold(0)(_ + _))   // 0 -- no exception, just the seed value

Prefer fold (with an explicit, meaningful starting value) over reduce any time the input might be empty — which, in real code reading from files, APIs, or user input, is more often than it looks.

foldLeft/foldRight are the direction-explicit versions of fold — use them when the accumulator type differs from the element type (as trace does above) or when evaluation order actually matters, such as building a string or a reversed structure.

groupBy and partition

val nums = List(1, 2, 3, 4, 5)

val grouped = nums.groupBy(n => if n % 2 == 0 then "even" else "odd")
println(grouped)   // HashMap(odd -> List(1, 3, 5), even -> List(2, 4))

val (evens, odds) = nums.partition(_ % 2 == 0)
println(evens)   // List(2, 4)
println(odds)    // List(1, 3, 5)

groupBy builds a Map keyed by whatever your function returns — useful for bucketing (note the result is a HashMap, so don't rely on key order). partition is the two-bucket special case (matches vs. non-matches) and returns a plain tuple, so you can destructure it directly as shown.

Combining a fold with a tuple accumulator is a common way to compute several running totals in a single pass, without groupBy's overhead of materializing sublists:

val (evenSum, oddSum) = nums.foldLeft((0, 0)) { case ((e, o), n) =>
  if n % 2 == 0 then (e + n, o) else (e, o + n)
}
println(s"evenSum=$evenSum oddSum=$oddSum")   // evenSum=6 oddSum=9

Lazy views: process without materializing

map and filter on a normal collection are eager — each call builds a brand-new collection right away, even if you only need the first few results. Calling .view switches to a lazy wrapper: transformations are recorded but not run until you force the result (with .toList, .force, etc.), and only as many elements as needed are ever computed.

var mapCalls = 0
var filterCalls = 0

val result = (1 to 1_000_000).view
  .map { x => mapCalls += 1; x * 2 }
  .filter { x => filterCalls += 1; x % 3 == 0 }
  .take(3)
  .toList

println(result)       // List(6, 12, 18)
println(mapCalls)     // 9  -- not 1,000,000
println(filterCalls)  // 9

Without .view, that pipeline would allocate two full million-element intermediate collections before take(3) ever ran. With .view, Scala pulls elements through the whole chain one at a time and stops the instant take(3) is satisfied. Reach for .view on large sequences with multiple chained transformations where you don't need every element — for small collections it's not worth the extra layer of indirection.

Choosing the right collection

Type Ordered? Duplicates? Typical use Lookup by key/index
List yes yes sequential processing, prepend-heavy O(n) random access
Vector yes yes general-purpose sequence, frequent random access/updates O(log n), effectively fast
Set no (in general) no membership tests, deduplication O(1)-ish contains
Map no (in general) no (unique keys) key-value lookups O(1)-ish get/apply
val s: Set[Int] = Set(1, 2, 2, 3)
println(s)               // Set(1, 2, 3) -- duplicate 2 silently collapsed
println(s.contains(2))   // true -- much faster than nums.contains(2) on a large List

val m: Map[String, Int] = Map("a" -> 1, "b" -> 2)
println(m("a"))                 // 1 -- throws NoSuchElementException if missing
println(m.getOrElse("z", -1))   // -1 -- safe default instead of an exception
println(m + ("c" -> 3))         // Map(a -> 1, b -> 2, c -> 3) -- immutable, returns a new Map

The trap: reaching for List by habit and then calling .contains or index lookups on it in a hot path. List.contains and List(i) are both O(n) — walking the whole linked list — while the equivalent operations on Set and Vector are effectively constant time. If you're checking membership repeatedly, convert to a Set once; if you're indexing repeatedly, use a Vector.

How It Actually Works

map, filter, and flatMap on a strict collection like List are each a full, eager pass: list.map(f).filter(g) allocates an entire intermediate list from map before filter even starts, then allocates a second list for the filtered result — two full traversals and two heap allocations of list nodes, even though logically you only care about the final elements. fold/reduce avoid that by threading an accumulator through a single traversal, applying the combining function to each element as it goes — which is also why foldLeft is stack-safe for large collections (it compiles down to an iterative loop over the list's cons cells) while a naive hand-written recursive combine over the same list could stack overflow.

A lazy view (list.view.map(f).filter(g)) restructures this completely: instead of eagerly building intermediate collections, .view wraps the underlying collection in a chain of small transformer objects that record what to do without doing it. Nothing actually runs element-by element until you call a "strict" operation like .toList or .force — at that point, each element is pulled through the whole chain of transformations one at a time, so map and filter fuse into a single pass with zero intermediate collections. This is exactly the same lazy-evaluation idea behind Iterator, and it's why views pay off most on large collections or chains, and can cost more than they save on tiny ones (the wrapping overhead isn't free).

groupBy builds its result using the same hash-trie Map machinery described in Level 1's collections module — it does one pass computing each element's key via your function, accumulating elements into growable buffers per key, then converts those buffers to the final Lists in a second pass. partition is a specialization of the same idea with exactly two buckets (true/false) computed in a single traversal, which is why partition(p) is faster than the equivalent (list.filter(p), list.filterNot(p)) — that alternative walks the source list twice.

Cheat sheet

Method Input → Output Empty-safe?
map(f) List[A] → List[B] yes
flatMap(f) List[A] → List[B] (flattened) yes
filter(p) List[A] → List[A] yes
fold(seed)(op) List[A] → B yes (returns seed)
reduce(op) List[A] → A no — throws on empty
groupBy(f) List[A] → Map[K, List[A]] yes
partition(p) List[A] → (List[A], List[A]) yes
.view...toList lazy pipeline → eager result yes

Exercise

Given val words = List("scala", "is", "a", "great", "language", "for", "jvm", "developers"), use groupBy to bucket the words by length, then use fold (not reduce) to compute the total character count across all words starting from 0. Separately, build a lazy .view pipeline over (1 to 10_000_000) that maps each number to its square, filters to keep only squares ending in 5, and takes the first 5 — confirm (with a counter, like the example above) that far fewer than 10 million squarings actually happen.