1.5 — Collections, and the higher-order functions that came with them

Opening scenario

You open a code review and find this one-liner:

let names = users
    .filter { $0.isActive }
    .sorted { $0.createdAt > $1.createdAt }
    .prefix(10)
    .map(\.displayName)

Four operations, zero for loops, reads like a sentence. The first time you see it, it’s intimidating. The tenth time, it’s the only way you want to write Swift. This chapter gets you to the tenth time.

The three Swift collections you’ll use 99% of the time

TypeWhat it isWhen to reach for it
Array<T> ([T])Ordered, indexable, allows duplicatesDefault. Lists, sequences, anything ordered.
Dictionary<K, V> ([K: V])Unordered key→value, keys must be HashableLookups by id, configuration maps, counts.
Set<T>Unordered, unique, Hashable elementsMembership tests, deduplication.

You’ll also occasionally touch Range (0..<10), ContiguousArray, OrderedDictionary (from swift-collections), but the three above carry most of daily life.

Concept → Why → How → Code

Concept: Swift collections are value types with copy-on-write

When you write let b = a for an array, Swift conceptually copies. But internally it shares the buffer until you mutate. The mutation triggers the actual copy. This is copy-on-write (COW). The upshot:

  • You get value-type semantics (b doesn’t change when a does).
  • You don’t pay the copy cost unless you mutate.
  • Passing an array to a function is cheap.

Why this matters

Other languages force you to choose: value semantics with copies (slow, safe) or reference semantics (fast, full of bugs). Swift gives you value semantics that are usually as cheap as references. You write naturally; the runtime optimizes.

How: the literal syntax

let xs: [Int] = [1, 2, 3]
let scores: [String: Int] = ["Ada": 95, "Linus": 88]
let tags: Set<String> = ["swift", "ios", "macos"]
let range = 0..<10           // half-open
let inclusive = 0...10       // closed

let empty1: [Int] = []
let empty2: [String: Int] = [:]
let empty3 = Set<String>()

Empty collection literals need a type annotation (Swift can’t infer []). Or use the explicit init.

Code: array essentials

var nums = [3, 1, 4, 1, 5, 9]
nums.append(2)
nums.insert(0, at: 0)
nums.remove(at: 2)
nums[1] = 99               // mutate by index
nums.count                 // 7
nums.isEmpty               // false
nums.first                 // Int? — empty arrays return nil
nums.last                  // Int?
nums.contains(4)           // Bool — O(n) for arrays
nums.indices               // 0..<7 — for index-aware loops

Index out of bounds crashes. There is no automatic nil-return. Use nums.first, nums.last, or guard your indices.

Code: dictionary essentials

var ages = ["Ada": 36, "Linus": 54]

// Reading — subscript returns Int?
let adaAge = ages["Ada"]            // Int? — nil if absent

// Reading with default
let unknown = ages["Bob", default: 0]  // 0 (does not insert)

// Writing
ages["Grace"] = 87                  // insert or overwrite
ages["Ada"] = nil                   // delete the key
ages.removeValue(forKey: "Linus")   // alternative

// Iterating (order is not stable across runs)
for (name, age) in ages { print("\(name): \(age)") }

// Common: count occurrences
let words = "the the quick brown fox the lazy fox".split(separator: " ")
var counts: [Substring: Int] = [:]
for w in words { counts[w, default: 0] += 1 }
// counts == ["the": 3, "quick": 1, "brown": 1, "fox": 2, "lazy": 1]

The dict[key, default: …] subscript with += 1 is the canonical Swift counter pattern. Memorize it.

Code: set essentials

let a: Set = [1, 2, 3, 4]
let b: Set = [3, 4, 5, 6]

a.union(b)        // {1, 2, 3, 4, 5, 6}
a.intersection(b) // {3, 4}
a.subtracting(b)  // {1, 2}
a.isDisjoint(with: b)  // false
a.contains(2)     // O(1) — vs O(n) for array

When you find yourself checking array.contains(x) inside a loop, convert the array to a Set first. O(n²) → O(n).

Higher-order functions: the meat of the chapter

Every Swift collection type conforms to Sequence and Collection, which provide a rich set of methods that take closures. Master these:

let nums = [1, 2, 3, 4, 5]

// MAP — transform each element
let squared = nums.map { $0 * $0 }
// [1, 4, 9, 16, 25]

// FILTER — keep only matching elements
let even = nums.filter { $0.isMultiple(of: 2) }
// [2, 4]

// REDUCE — collapse to a single value
let total = nums.reduce(0, +)               // 15
let product = nums.reduce(1, *)             // 120
let csv = nums.reduce("") { $0 + "\($1)," } // "1,2,3,4,5,"

// COMPACTMAP — map + drop nils
let strings = ["1", "two", "3"]
let parsed = strings.compactMap { Int($0) }
// [1, 3]

// FLATMAP — map then flatten one level
let nested = [[1, 2], [3, 4]]
let flat = nested.flatMap { $0 }
// [1, 2, 3, 4]

// SORTED — returns a new sorted array
let mixed = [3, 1, 4, 1, 5, 9, 2, 6]
let asc = mixed.sorted()                    // ascending by default
let desc = mixed.sorted(by: >)              // descending
let byCount = ["bb", "a", "ccc"].sorted { $0.count < $1.count }

// PREFIX / SUFFIX / DROPFIRST / DROPLAST — slicing
nums.prefix(3)           // [1, 2, 3]
nums.suffix(2)           // [4, 5]
nums.dropFirst()         // [2, 3, 4, 5]
nums.dropLast(2)         // [1, 2, 3]

// ALLSATISFY / CONTAINS / FIRST(WHERE:) — querying
nums.allSatisfy { $0 > 0 }            // true
nums.contains { $0 > 4 }              // true
nums.first { $0.isMultiple(of: 2) }   // 2 (Int?)

// ENUMERATED — index + element pairs
for (i, n) in nums.enumerated() { print("\(i): \(n)") }

// ZIP — pairwise iteration over two sequences
for (name, age) in zip(["Ada", "Linus"], [36, 54]) {
    print("\(name) is \(age)")
}

The KeyPath shorthand (Swift 5.2+) lets you replace { $0.title } with \.title:

let titles = articles.map(\.title)              // instead of { $0.title }
let activeNames = users.filter(\.isActive).map(\.name)

This works wherever a (T) -> U is expected and U is a property of T.

In the wild

  • JSON parsing pipelines: every URLSession.dataTask returning JSON funnels through a decode → filter → map → sort chain.
  • SwiftUI’s ForEach(items) iterates collections; idiomatic SwiftUI is full of items.filter { … }.sorted { … } to drive the view.
  • Core Data fetched results are converted to [Entity] and then transformed with higher-order functions before display.
  • Networking layers convert [APIPost][DomainPost] with .map(Post.init). This is called the mapper pattern and is everywhere in production iOS code.

Common misconceptions

  1. map and for loops are interchangeable; use whichever feels right.” Subtly wrong. map returns a new array of the same length. If you’re using map for side effects (array.map { print($0) }), you’re misusing it. Use forEach or a for loop for side effects. The compiler will eventually warn you about the unused return value.

  2. “Higher-order functions are slow.” In Swift, the compiler aggressively inlines map/filter/reduce closures. The difference vs a hand-written loop is usually unmeasurable. Premature manual loops for “performance” is a 2014 attitude.

  3. Array.contains is fast.” O(n). For repeated lookups, convert to a Set once and check membership in O(1).

  4. Dictionary preserves insertion order.” Swift’s Dictionary does not guarantee order. If you need ordered key-value pairs, use OrderedDictionary from swift-collections.

  5. reduce is too clever for production.” Disagree, but the first parameter is the initial value, and the closure is (accumulator, element) -> accumulator. Once that clicks, it’s the most general tool in your kit.

Seasoned engineer’s take

A pipeline of higher-order functions is the declarative shape of a transformation. A for loop is the imperative shape. Both produce the same output; they have very different review and refactor costs.

  • A .filter { $0.isActive }.map(\.id) pipeline is self-documenting — a reader sees the intent (keep active users, take ids).
  • The equivalent for loop with mutable accumulators requires the reader to execute the loop mentally to discover the same intent.

Use the pipeline form by default. Drop to a for loop when:

  • You need early-exit (break / return).
  • You’re producing multiple outputs from one pass (which would otherwise require iterating twice).
  • The transformation involves more than ~3 steps; at that point break it into named functions with descriptive names — composition still beats a single megastatement, but legibility wins over chain length.

Also: be wary of flatMap on optional sequences. Modern Swift renamed the optional version to compactMap to avoid confusion. If you mean “map and drop nils”, use compactMap. If you mean “map and flatten nested arrays”, use flatMap.

TIP: The lazy modifier (array.lazy.filter { … }.map { … }.first { … }) defers evaluation. Useful when you’re searching a huge collection and want to stop at the first match without materializing the intermediates.

WARNING: Set and Dictionary iteration order is not guaranteed to be stable across runs (or even within a run, in theory). Never rely on the order. If your tests pass on macOS and fail in CI on Linux, this is often the cause.

Interview corner

Question: “Given an array of [User], return the top 5 active users by signup date, as [String] (their display names).”

Junior answer:

var actives: [User] = []
for u in users {
    if u.isActive { actives.append(u) }
}
// then sort, then take 5, then map names…

Correct, verbose. They’ll ask: “can you do that in one line?”

Mid-level answer:

let result = users
    .filter { $0.isActive }
    .sorted { $0.createdAt > $1.createdAt }
    .prefix(5)
    .map { $0.displayName }

Strong. Pipeline is idiomatic.

Senior answer: All of the above, plus: “I’d reach for \.displayName keypath shorthand on the last map. I’d also point out this is O(n log n) because of the sort — fine for thousands, suboptimal for millions. For a very large input I’d use a min-heap of size 5 to do it in O(n log k). And if createdAt were nullable I’d handle the optional explicitly with compactMap rather than crash on a force-unwrap.”

let result = users
    .filter(\.isActive)
    .sorted { $0.createdAt > $1.createdAt }
    .prefix(5)
    .map(\.displayName)

Senior signal. Knows the language idioms, the complexity, and the production edge cases.

Red-flag answer: “I’d write a custom sort algorithm because the built-in one isn’t tuned for my data.” → Unless you’ve benchmarked, this is a make-work answer. Swift’s sort is Timsort-style; it’s excellent.

Lab preview

Lab 1.A (Playground exploration) includes a section where you’ll process a sample dataset (the words of Hamlet) using only higher-order functions: word counts, longest words by length, top-N alphabetized. No for loops allowed.


Next: how Swift models kinds of things — structs, classes, enums, protocols, and the religious war between them. → Structs, classes, enums, protocols