Dart — Collections, Iterables & Allocation Patterns
Lazy Iterable Chains — Evaluation Semantics
// map/where/expand return LAZY Iterables — no computation until iterated.
// Each `.toList()` materializes the chain (one allocation, one pass).
// ❌ Anti-pattern: calling .toList() after every step (multiple allocations).
var result = numbers
.map((n) => n * 2) // lazy Iterable
.toList() // ← allocates a List here (premature)
.where((n) => n > 5) // lazy Iterable (on a List, re-iterates)
.toList() // ← another allocation
.map((n) => n.toString()) // lazy
.toList(); // ← yet another
// ✓ Correct: chain lazy operations, materialize ONCE at the end.
var resultFixed = numbers
.map((n) => n * 2) // lazy
.where((n) => n > 5) // lazy
.map((n) => n.toString()) // lazy
.toList(); // single allocation, single pass
// Lazy iterables re-evaluate on each iteration — no caching:
var numbers = [1, 2, 3];
var doubled = numbers.map((n) { print('mapping $n'); return n * 2; });
doubled.toList(); // prints: mapping 1, mapping 2, mapping 3
doubled.toList(); // prints again: mapping 1, mapping 2, mapping 3 (re-evaluated!)
// If the mapping is expensive, cache with .toList() and reuse the list.
sort — In-Place, Returns void
// ❌ Anti-pattern: assigning the result of sort() — it returns void.
var sorted = [3, 1, 2].sort(); // sorted is void! sort() is in-place.
print(sorted); // null (or compile error in strict mode)
// ✓ Correct: sort a copy (don't mutate the original).
var numbers = [3, 1, 2];
var sorted = [...numbers]..sort(); // spread creates a copy, cascade sorts it
print(sorted); // [1, 2, 3]
print(numbers); // [3, 1, 2] — original unchanged
// Custom comparator — for descending or multi-field sort:
var users = [
(name: 'Alice', age: 30),
(name: 'Bob', age: 25),
(name: 'Alice', age: 25),
];
// Sort by name (asc), then by age (desc):
users.sort((a, b) {
final nameCmp = a.name.compareTo(b.name);
if (nameCmp != 0) return nameCmp;
return b.age.compareTo(a.age); // descending age
});
// [(name: Alice, age: 30), (name: Alice, age: 25), (name: Bob, age: 25)]
// Comparable<T> — for types with a natural ordering:
class Priority implements Comparable<Priority> {
final int level;
const Priority(this.level);
@override
int compareTo(Priority other) => level.compareTo(other.level);
}
var tasks = [Priority(3), Priority(1), Priority(2)];
tasks.sort(); // uses compareTo — [1, 2, 3]
Spread & Collection-If — Conditional Construction
// Spread (...) flattens another iterable into a literal.
// Null-aware spread (...?) skips if the iterable is null.
var base = [1, 2, 3];
var extended = [0, ...base, 4]; // [0, 1, 2, 3, 4]
List<int>? maybeNull;
var safe = [0, ...?maybeNull, 4]; // [0, 4] — null spread is a no-op
// Collection-if — conditional elements in a literal (Flutter widget trees):
var widgets = <Widget>[
Text('Header'),
if (showAd) BannerAd(), // included only if showAd is true
if (user != null) ...[
ProfilePic(user!),
UserName(user!),
], // spread a conditional group
if (items.isEmpty)
EmptyState()
else
...items.map((i) => ItemWidget(i)), // if-else in a collection literal
];
// Collection-for — programmatic elements:
var indices = [for (var i = 0; i < 5; i++) 'item-$i'];
// ['item-0', 'item-1', 'item-2', 'item-3', 'item-4']
// Nested — build complex structures declaratively:
var matrix = [
for (var r = 0; r < 3; r++)
[for (var c = 0; c < 3; c++) r * 3 + c]
];
// [[0,1,2], [3,4,5], [6,7,8]]
Immutable Collections — Views vs Copies
// ── const: compile-time, deeply immutable, canonicalized ──
const colors = ['red', 'green']; // same instance everywhere, zero allocation
// colors.add('blue'); // ✗ UnsupportedError
// ── List.unmodifiable: runtime, throws on mutation, but is a VIEW ──
var source = [1, 2, 3];
var view = List.unmodifiable(source);
// view.add(4); // ✗ UnsupportedError
source.add(4);
print(view); // [1, 2, 3, 4] — view reflects source mutation!
// ✓ For a true immutable copy: wrap a copy, not the source.
var immutable = List.unmodifiable([...source]); // copy then wrap
source.add(5);
print(immutable); // [1, 2, 3, 4] — unaffected by source mutation after copy
// ── Set.unmodifiable / Map.unmodifiable: same semantics ──
var setView = Set.unmodifiable({1, 2, 3});
var mapView = Map.unmodifiable({'a': 1, 'b': 2});
// For persistent (structural sharing) immutable collections, use packages:
// - package:built_collection — persistent immutable List/Map/Set
// - package:fast_immutable_collections — high-performance persistent collections
reduce vs fold — Empty Collection Behavior
// reduce: combines elements, NO initial value — throws on empty.
// ❌ Anti-pattern: reduce on a possibly-empty list.
int sum(List<int> nums) => nums.reduce((a, b) => a + b); // throws StateError on []
sum([]); // Uncaught Error: Bad state: no element
// ✓ Correct: fold with an initial value — safe on empty.
int sumSafe(List<int> nums) => nums.fold(0, (a, b) => a + b); // 0 on empty
sumSafe([]); // 0
// fold with a different initial type (e.g., building a string):
String csv = [1, 2, 3].fold('', (acc, n) => acc.isEmpty ? '$n' : '$acc,$n');
// '1,2,3'
// reduce's return type matches the element type:
var max = [3, 1, 4, 1, 5].reduce((a, b) => a > b ? a : b); // 5 (int)
// For first/last/single on possibly-empty: use firstWhere with orElse:
var first = [1, 2, 3].firstWhere((n) => n > 5, orElse: () => -1); // -1
Equality — Collections Are Identity
// Built-in collections use IDENTITY for ==, not value equality.
print([1, 2] == [1, 2]); // false — different instances
print({1, 2} == {1, 2}); // false
print({'a': 1} == {'a': 1}); // false
// ❌ Anti-pattern: using == to compare collection contents.
bool sameContent(List<int> a, List<int> b) => a == b; // always false for distinct lists
// ✓ Correct: use listEquals / setEquals / mapEquals (Flutter) or DeepCollectionEquality.
import 'package:flutter/foundation.dart';
print(listEquals([1, 2], [1, 2])); // true
import 'package:collection/collection.dart';
const deepEq = DeepCollectionEquality();
print(deepEq.equals([1, [2, 3]], [1, [2, 3]])); // true — nested deep equality
print(deepEq.equals({'a': [1, 2]}, {'a': [1, 2]})); // true
// Records have structural equality built in:
print((1, 2) == (1, 2)); // true — records compare by value, no helper needed
print((x: 1, y: 2) == (x: 1, y: 2)); // true
💡 Tips & Tricks
- Performance: chain lazy
Iterableoperations (map/where/expand) and call.toList()once at the end — single allocation, single pass. Avoid.toList()after every step (multiple intermediate lists). - Idiom:
[...list]..sort()for a sorted copy — spread creates a new list, cascade sorts it in-place, returns the list. Doesn't mutate the original. Prefer overlist.toList()..sort()(clearer intent). - Idiom: use
fold(notreduce) for possibly-empty collections —fold(0, (a, b) => a + b)returns0on empty;reducethrowsStateError.foldalso supports a different return type than the element type. - Idiom: use collection-if/for in widget trees —
[Text('header'), if (showAd) Ad(), for (var item in items) ItemWidget(item)]builds lists declaratively. EliminatesaddAllandif-then-addboilerplate. - Idiom:
...?(null-aware spread) for optional nested collections —[...?optionalList]skips ifoptionalListis null. Cleaner thanif (list != null) [...list].
⚠️ Edge Cases & Gotchas
{}is an emptyMap, not aSet:var x = {}infersMap<dynamic, dynamic>. Usevar x = <int>{}orSet<int>()for an empty Set.map/wherereturn lazyIterable: not aList. No[]indexing (well,elementAtworks but iterates from the start). Call.toList()to cache and getListmethods.sort()is in-place, returnsvoid:var sorted = list.sort()assignsvoid. Use[...list]..sort()for a sorted copy.reducethrows on empty:[].reduce(...)throwsStateError. Usefoldwith an initial value for possibly-empty collections.List.filled(n, x)is fixed-length by default: can'tadd/remove, but can modify elements (list[0] = ...). UseList.filled(n, x, growable: true)or<int>[]for a growable list.List.unmodifiableis a view: mutations to the source are visible through the view. UseList.unmodifiable([...source])for an immutable copy.constcollections are canonicalized:const [1,2,3]is the same instance everywhere (identicalis true). Mutating throws. Zero allocation at runtime.==for collections is identity:[1,2] == [1,2]isfalse. UselistEquals,DeepCollectionEquality, or records (structural equality).- Lazy
Iterablere-evaluates on each iteration:var gen = fib(); gen.take(10).toList(); gen.take(10).toList();runs the generator twice. Cache with.toList()for reuse. Setdefault isLinkedHashSet: preserves insertion order, but don't rely on it semantically. If order matters, use aList.HashSet(hash-based) is faster but unordered.
🧠 Spot the Bug
A developer filters and maps a list, but the side effect runs more times than expected:
var numbers = [1, 2, 3, 4, 5];
var result = numbers
.map((n) { print('mapping $n'); return n * 2; })
.where((n) => n > 4);
print(result.first); // prints: mapping 1, mapping 2, mapping 3 → 6
print(result.last); // prints: mapping 1, mapping 2, mapping 3, mapping 4, mapping 5 → 10
Why does mapping print for 1 and 2 even though they're filtered out?
Answer
The map and where operations return lazy Iterables — no computation happens until the iterable is actually traversed. When .first is called, the iterable is traversed from the beginning:
.firstneeds the first element matchingwhere((n) => n > 4).- The chain is:
map→where. To get the firstwherematch, it pulls frommap. map(1)→print('mapping 1')→2.where(2 > 4)→ false. Continue.map(2)→print('mapping 2')→4.where(4 > 4)→ false. Continue.map(3)→print('mapping 3')→6.where(6 > 4)→ true. Return6.
The map function runs for ALL elements up to the first match — even those filtered out by where. The where predicate receives the mapped result, not the original.
When .last is called, the iterable is traversed again from the beginning (lazy, no caching) — all 5 elements are mapped and filtered to find the last match.
The fix — materialize once if you need multiple accesses, and be aware that lazy chains process all upstream elements up to the point of the match:
// Materialize once, reuse:
var materialized = numbers
.map((n) { print('mapping $n'); return n * 2; })
.where((n) => n > 4)
.toList(); // runs once: mapping 1, 2, 3, 4, 5
print(materialized.first); // 6 (no re-evaluation)
print(materialized.last); // 10 (no re-evaluation)