09 · Collections Deep Dive¶
Level 1 covered List, Set, and Map
basics — creating them, indexing, and looping. This module covers Dart's
functional collection methods: map, where, fold, reduce, expand,
and sort, the tools that let you transform and summarize data in a few
expressive lines instead of hand-written loops with mutable accumulators.
The core five¶
class Employee {
final String name;
final String department;
final double salary;
Employee(this.name, this.department, this.salary);
@override
String toString() => '$name ($department, \$$salary)';
}
void main() {
final employees = [
Employee('Ada', 'Engineering', 95000),
Employee('Grace', 'Engineering', 105000),
Employee('Alan', 'Research', 88000),
Employee('Linus', 'Engineering', 120000),
Employee('Margaret', 'Research', 91000),
];
// where: filter -- keep only matching elements
final engineers = employees.where((e) => e.department == 'Engineering');
print(engineers.length); // 3
// map: transform each element -- lazy, doesn't run until consumed
final names = employees.map((e) => e.name).toList();
print(names); // [Ada, Grace, Alan, Linus, Margaret]
// fold: reduce to a single value, with an explicit starting point
final totalSalary = employees.fold<double>(0, (sum, e) => sum + e.salary);
print(totalSalary); // 499000.0
// reduce: like fold, but starts from the first element (fails on empty!)
final highestPaid = employees.reduce((a, b) => a.salary > b.salary ? a : b);
print(highestPaid); // Linus (Engineering, $120000.0)
// expand: flatten -- map each element to zero or more elements
final initials = employees.expand((e) => e.name.split('')).take(3).toList();
print(initials); // [A, d, a]
// sort: in-place, via a comparator (spread into a fresh list first
// if you don't want to mutate the original)
final bySalaryDesc = [...employees]
..sort((a, b) => b.salary.compareTo(a.salary));
print(bySalaryDesc.map((e) => e.name).toList());
// [Linus, Grace, Ada, Margaret, Alan]
}
| Method | Input → Output | Fails on empty? |
|---|---|---|
where |
Iterable<T> → Iterable<T> (subset) |
No — returns empty |
map |
Iterable<T> → Iterable<R> (transformed) |
No — returns empty |
fold(initial, combine) |
Iterable<T> → single R, starting from initial |
No — returns initial |
reduce(combine) |
Iterable<T> → single T, starting from first element |
Yes — throws StateError |
expand |
Iterable<T> → Iterable<R> (flattened) |
No — returns empty |
fold vs reduce is the one worth memorizing carefully: reach for fold
whenever the collection might be empty, or when your accumulator's type
differs from the element type (like folding List<Employee> down to a
double total above) — reduce can't do that, since it has no separate
initial value to fall back to.
Grouping — a pattern, not a built-in¶
Dart doesn't ship a groupBy in the core library. The standard pattern uses
Map.putIfAbsent to build up the groups as you iterate once:
Map<String, List<Employee>> groupByDepartment(List<Employee> employees) {
final result = <String, List<Employee>>{};
for (final e in employees) {
// putIfAbsent -- create the list only the first time we see this key.
result.putIfAbsent(e.department, () => []).add(e);
}
return result;
}
void main() {
final employees = [
Employee('Ada', 'Engineering'),
Employee('Grace', 'Engineering'),
Employee('Alan', 'Research'),
];
final grouped = groupByDepartment(employees);
for (final entry in grouped.entries) {
print('${entry.key}: ${entry.value.map((e) => e.name).toList()}');
}
// Engineering: [Ada, Grace]
// Research: [Alan]
}
The trap: map/where are lazy¶
map and where don't run their callback immediately — they build a
description of the transformation and only execute it when something
actually consumes the result (toList(), a for loop, .length in some
cases, etc.). This surprises people coming from languages where these
operations eagerly build a new list on the spot.
void main() {
int callCount = 0;
final lazy = [1, 2, 3].map((n) {
callCount++;
return n * 2;
});
print('right after map(): $callCount'); // 0 -- nothing ran yet!
final materialized = lazy.toList();
print('after toList(): $callCount'); // 3
print(materialized); // [2, 4, 6]
// Re-iterating a lazy Iterable's VALUES re-runs the transform from
// scratch -- results are never cached.
for (final v in lazy) {
// (just draining it)
}
print('after iterating lazy again: $callCount'); // 6, not 3
}
Two consequences worth knowing: (1) a map/where chain with a
callback that has side effects (logging, incrementing a counter, mutating
something) will re-run those side effects every time the result is
consumed — call .toList() once and reuse the list if you need the
callback to run exactly once. (2) chaining several lazy operations
(.where(...).map(...).take(3)) is efficient precisely because nothing
computes until the final consumption, and .take(3) can stop the whole
pipeline early instead of processing every element first.
Cheat sheet¶
| Need | Reach for |
|---|---|
| Keep only matching elements | .where((e) => condition) |
| Transform each element | .map((e) => newValue) |
| Reduce to one value, safe on empty | .fold(initial, (acc, e) => ...) |
| Reduce to one value from the elements themselves | .reduce((a, b) => ...) |
| Flatten a list of lists | .expand((e) => e.subList) |
| Sort in place | list.sort((a, b) => a.compareTo(b)) |
| Group by a key | Map.putIfAbsent inside a loop |
Force a lazy Iterable to compute now |
.toList() |
How It Actually Works¶
map/where and most of Iterable's transformation methods are lazy
because Iterable in Dart is defined as a protocol (an iterator getter
returning something with moveNext()/current), and .map()/.where()
return wrapper objects implementing that same protocol without eagerly
computing anything — each wrapper's moveNext() pulls the next element from
its source iterable and applies the transformation/predicate on demand, one
element at a time. This is why calling .map(expensiveFn) on a million-item
list is instantaneous — no work happens until something actually iterates
the result (a for-in loop, .toList(), .first, etc.), and if you never
iterate it, expensiveFn never runs at all. It's also why side effects
inside a .map() callback can run more times than you expect if you
iterate the same lazy Iterable more than once — each iteration re-walks
the whole lazy chain from the source.
The "core five" collection types share the underlying Iterable interface,
but their concrete storage strategies differ in ways that affect Big-O
behavior in practice: List gives O(1) index access because it's backed by
a contiguous (over-allocated, doubling) array; Set/Map give average O(1)
lookup because they're hash tables bucketing by hashCode, but degrade
toward O(n) per bucket if many elements collide on the same hash — a real
risk if you override hashCode poorly (e.g., always returning a constant).
"Grouping" isn't a separate data structure — it's just building a
Map<K, List<V>> by hand (or via groupBy from package:collection),
which internally is one lazy pass over the source Iterable, inserting into
list buckets keyed by hash — there's no special-cased "group" runtime
construct in Dart at all.
Exercise¶
Given a List<Employee> like the one above, write one expression (chaining
where, map, and fold or reduce) that computes the average salary of
just the Engineering department, and a second function Map<String,
double> averageSalaryByDepartment(List<Employee> employees) that returns
the average salary for every department, built using the
groupByDepartment pattern above plus a .map() over the resulting
Map's entries.