Collections and Transformations
List, Vector, Map and Set — plus map, flatMap, groupBy and foldLeft, and why picking the wrong collection makes an O(1) operation O(n).
Scala’s collections are immutable by default: every transformation returns a new collection and leaves the original alone. That makes chains of operations safe to reason about, and it is why almost all Scala data code looks like a pipeline.
The four you need
@main def run(): Unit =
val list = List(1, 2, 3, 4, 5) // linked list: fast prepend, slow index
val vector = Vector(1, 2, 3, 4, 5) // fast index, append, update
val set = Set("GB", "US", "GB", "NL") // unique, unordered
val map = Map("GB" -> "United Kingdom", "US" -> "United States")
println(list)
println(vector)
println(set)
println(map)
println(map("GB"))
println(map.get("FR"))
List(1, 2, 3, 4, 5)
Vector(1, 2, 3, 4, 5)
Set(GB, US, NL)
Map(GB -> United Kingdom, US -> United States)
United Kingdom
None
Two details worth catching now. The Set dropped the duplicate GB silently — that is the
point of a set, and a source of surprise when a count comes out lower than expected. And
map.get returned None rather than throwing, while map("FR") would throw
NoSuchElementException. Prefer get; lesson 4 is about what to do with the Option.
Which one, and why it matters
@main def run(): Unit =
val n = 100_000
val list = List.range(0, n)
val vector = Vector.range(0, n)
def time(label: String)(body: => Unit): Unit =
val t0 = System.nanoTime()
body
println(f"$label%-24s ${(System.nanoTime() - t0) / 1e6}%.1f ms")
time("list(50000)") { (0 until 1000).foreach(_ => list(50000)) }
time("vector(50000)") { (0 until 1000).foreach(_ => vector(50000)) }
time("list prepend") { (0 until 1000).foldLeft(list)((acc, i) => i :: acc) }
time("vector append") { (0 until 1000).foldLeft(vector)((acc, i) => acc :+ i) }
list(50000) 412.8 ms
vector(50000) 0.4 ms
list prepend 0.1 ms
vector append 1.2 ms
A thousand indexed reads: 412ms on List, 0.4ms on Vector. List(i) walks i links every
time. If you find yourself indexing a List in a loop, that is the bug.
The time method also shows a by-name parameter — body: => Unit is evaluated where it is
used, not at the call site, which is how you write your own control structures.
Transforming
case class Order(id: Int, customerId: Int, country: String, status: String, amount: Double)
val orders = List(
Order(1001, 1, "GB", "completed", 25.50),
Order(1002, 2, "US", "completed", 12.00),
Order(1003, 1, "GB", "returned", 40.00),
Order(1004, 3, "GB", "completed", 8.75),
Order(1005, 2, "US", "pending", 63.20),
)
@main def run(): Unit =
println(orders.map(_.amount))
println(orders.filter(_.status == "completed").map(_.id))
println(orders.map(_.amount).sum)
println(orders.count(_.country == "GB"))
println(orders.exists(_.amount > 60))
println(orders.forall(_.amount > 0))
println(orders.sortBy(-_.amount).take(2).map(_.id))
List(25.5, 12.0, 40.0, 8.75, 63.2)
List(1001, 1002, 1004)
149.45
3
true
true
List(1005, 1003)
_ is a placeholder for the single argument, so _.amount is o => o.amount. It only works
when the argument is used once — write the lambda out when it is not.
Grouping and aggregating
@main def run(): Unit =
val byCountry = orders.groupBy(_.country)
byCountry.foreach((country, os) => println(s"$country: ${os.map(_.id)}"))
val revenue = orders
.filter(_.status == "completed")
.groupBy(_.country)
.view.mapValues(_.map(_.amount).sum)
.toMap
println(revenue)
val counts = orders.groupMapReduce(_.status)(_ => 1)(_ + _)
println(counts)
GB: List(1001, 1003, 1004)
US: List(1002, 1005)
Map(GB -> 34.25, US -> 12.0)
Map(completed -> 3, returned -> 1, pending -> 1)
groupBy returns a Map[K, List[V]]. groupMapReduce does group, transform and combine in
one pass — the idiomatic way to count occurrences, and it avoids building the intermediate
lists that groupBy(...).mapValues(_.size) would.
view.mapValues(...).toMap rather than plain mapValues, because mapValues returns a lazy
view whose function re-runs on every lookup — a classic performance surprise.
flatMap
case class Basket(orderId: Int, items: List[String])
val baskets = List(
Basket(1001, List("SICP", "The Mythical Man-Month")),
Basket(1002, List("Design Patterns")),
Basket(1003, Nil),
)
@main def run(): Unit =
println(baskets.map(_.items))
println(baskets.flatMap(_.items))
println(baskets.flatMap(b => b.items.map(title => (b.orderId, title))))
List(List(SICP, The Mythical Man-Month), List(Design Patterns), List())
List(SICP, The Mythical Man-Month, Design Patterns)
List((1001,SICP), (1001,The Mythical Man-Month), (1002,Design Patterns))
flatMap is the one-row-to-many operation. Note order 1003 disappeared entirely — an empty
list contributes nothing, which is exactly how flatMap also acts as a filter.
Folding
@main def run(): Unit =
println(orders.foldLeft(0.0)((acc, o) => acc + o.amount))
val summary = orders.foldLeft(Map.empty[String, Double]) { (acc, o) =>
acc.updated(o.country, acc.getOrElse(o.country, 0.0) + o.amount)
}
println(summary)
println(List.empty[Int].sum)
println(List.empty[Int].reduceOption(_ + _))
149.45
Map(GB -> 74.25, US -> 75.2)
0
None
foldLeft takes a starting value and combines left to right — it expresses any aggregation,
including ones building a Map. The last two lines are the reason to prefer it to reduce:
reduce on an empty collection throws, fold returns the seed, and reduceOption gives you
a None instead.
Lazy chains
@main def run(): Unit =
val nums = (1 to 1_000_000).toVector
val strict = nums.map(_ * 2).filter(_ % 3 == 0).take(5)
val lazily = nums.view.map(_ * 2).filter(_ % 3 == 0).take(5).toVector
println(strict)
println(lazily)
Vector(6, 12, 18, 24, 30)
Vector(6, 12, 18, 24, 30)
Identical results, very different work. The strict version builds a million-element vector,
then a filtered one, then takes five. The view computes elements on demand and stops after
five. Add .view when a chain is long and the collection is large; skip it otherwise, since
laziness has its own overhead.
Mutable collections, when you need them
import scala.collection.mutable
@main def run(): Unit =
val builder = mutable.ListBuffer.empty[Int]
for i <- 1 to 5 do builder += i * i
val result = builder.toList
println(result)
val counts = mutable.Map.empty[String, Int].withDefaultValue(0)
for o <- orders do counts(o.country) += 1
println(counts)
List(1, 4, 9, 16, 25)
Map(GB -> 3, US -> 2)
Legitimate inside a method that returns an immutable result — a local ListBuffer nobody
else can see is not a shared-state problem. Returning a mutable collection from a public
method is, because the caller can now change your object.
Practice
1. Total the completed orders per country.
val revenue = orders
.filter(_.status == "completed")
.groupMapReduce(_.country)(_.amount)(_ + _)
println(revenue)
Map(GB -> 34.25, US -> 12.0)
groupMapReduce in one pass instead of groupBy then mapValues — same result, no
intermediate lists.
2. Index into a List in a loop and time it.
list(50000) 412.8 ms
vector(50000) 0.4 ms
A thousandfold difference from one collection choice. List is the right default for
building by prepending and iterating; the moment you index, switch to Vector.
3. Use flatMap to expand orders into order lines.
println(baskets.flatMap(b => b.items.map(t => (b.orderId, t))))
List((1001,SICP), (1001,The Mythical Man-Month), (1002,Design Patterns))
Three baskets became three lines, and the empty basket vanished. When a row count drops after
a flatMap, an empty inner collection is why.
4. Call reduce on an empty list.
println(List.empty[Int].reduce(_ + _))
Exception in thread "main" java.lang.UnsupportedOperationException: empty.reduceLeft
Then try fold(0)(_ + _) — it returns 0. Empty input is the normal case in data work, so
prefer fold, sum, or reduceOption.
Next: case classes and pattern matching — modelling data and taking it apart.