06 · Collections¶
Swift has three primary collection types, all value types: Array
(ordered), Set (unordered, unique elements), and Dictionary (key-value
pairs). Assigning one to a new variable or passing it to a function copies
it (conceptually — Swift optimizes this with copy-on-write under the hood).
Arrays¶
var fruits = ["apple", "banana", "cherry"]
print(fruits.count) // 3
print(fruits[0]) // apple
fruits.append("date")
fruits += ["elderberry"]
print(fruits) // ["apple", "banana", "cherry", "date", "elderberry"]
fruits.remove(at: 1) // removes "banana"
print(fruits) // ["apple", "cherry", "date", "elderberry"]
let empty: [Int] = []
var explicit: Array<Int> = [1, 2, 3] // "[Int]" is shorthand for "Array<Int>"
for fruit in fruits {
print(fruit, terminator: " ")
}
// apple cherry date elderberry
print()
Common transformations use map, filter, and reduce rather than manual
loops:
let numbers = [1, 2, 3, 4, 5, 6]
let doubled = numbers.map { $0 * 2 }
print(doubled) // [2, 4, 6, 8, 10, 12]
let evens = numbers.filter { $0 % 2 == 0 }
print(evens) // [2, 4, 6]
let total = numbers.reduce(0) { $0 + $1 }
print(total) // 21
let sorted = numbers.sorted(by: >)
print(sorted) // [6, 5, 4, 3, 2, 1]
Dictionaries¶
var ages: [String: Int] = ["Ada": 36, "Alan": 41]
ages["Grace"] = 85 // insert
ages["Ada"] = 37 // update
print(ages["Ada"]!) // 37 -- subscripting a Dictionary returns an Optional
print(ages["Unknown"]) // nil
if let age = ages["Alan"] {
print("Alan is \(age)") // Alan is 41
}
ages.removeValue(forKey: "Grace")
for (name, age) in ages {
print("\(name) is \(age)") // order is not guaranteed
}
let defaultedAge = ages["Nobody", default: 0]
print(defaultedAge) // 0
Dictionary subscripting always returns an Optional (Value?), because the
key might not exist — this is the same optional mechanism from
Module 5, applied consistently across the language.
Sets¶
var primes: Set<Int> = [2, 3, 5, 7, 11]
primes.insert(13)
primes.insert(2) // no-op -- 2 is already present
print(primes.contains(7)) // true
print(primes.count) // 6
let a: Set = [1, 2, 3, 4]
let b: Set = [3, 4, 5, 6]
print(a.union(b).sorted()) // [1, 2, 3, 4, 5, 6]
print(a.intersection(b).sorted()) // [3, 4]
print(a.subtracting(b).sorted()) // [1, 2]
Sets guarantee uniqueness and offer fast contains checks (O(1) on average),
at the cost of not preserving insertion order.
Choosing between them¶
| Type | Ordered? | Duplicates? | Lookup by | Typical use |
|---|---|---|---|---|
Array |
Yes | Yes | Index | Sequential data, order matters |
Set |
No | No | Value (hash) | Uniqueness, fast membership checks |
Dictionary |
No | Keys unique | Key | Fast lookup by identifier |
Nested collections¶
let matrix: [[Int]] = [
[1, 2, 3],
[4, 5, 6],
[7, 8, 9],
]
for row in matrix {
print(row.map(String.init).joined(separator: " "))
}
// 1 2 3
// 4 5 6
// 7 8 9
let studentsBySubject: [String: [String]] = [
"Math": ["Ada", "Alan"],
"Physics": ["Grace"],
]
print(studentsBySubject["Math"] ?? []) // ["Ada", "Alan"]
How It Actually Works¶
Copy-on-write (COW) is not a language feature — it's a data structure
technique the standard library implements itself. Array, Set, and
Dictionary are all thin Swift structs that wrap a single property: a
pointer to a heap-allocated buffer holding the actual elements, plus a
reference count on that buffer (via Swift's normal class reference
counting, since the buffer is a private class instance under the hood).
var a = [1, 2, 3]; var b = a copies the struct — 8 bytes, one pointer —
not the buffer. Both a and b now point at the same buffer, and its
retain count is 2.
The copy only actually happens the moment you mutate one of them. Every
mutating method (append, remove, subscript assignment, ...) starts with
isKnownUniquelyReferenced(&buffer) — a runtime check on the buffer's
retain count. If it's 1 (nobody else points at it), the buffer is mutated
in place: no allocation, no copy. If it's >1 (as with a/b above), the
buffer is copied first, b's pointer is repointed at the new copy, and
then the mutation happens on the new buffer. This is why b.append(4)
leaves a at [1, 2, 3] while b becomes [1, 2, 3, 4] — value
semantics are preserved, but the cost of the copy is deferred until it's
actually needed, and skipped entirely if it never is.
Array growth works the same way as ArrayList/std::vector:
appending past the buffer's capacity allocates a new buffer at roughly 2x
the old capacity and moves every element over — this is why reserveCapacity
matters for large known-size arrays (it avoids the O(log n) reallocations),
and why append is amortized O(1) rather than worst-case O(1).
Dictionary and Set are open-addressed hash tables. Every key must
be Hashable; Swift computes hashValue via hasher.combine, mixes it
with a per-process random seed (to make hash-flooding attacks
non-deterministic across runs), and uses the result to pick a bucket. A
collision probes forward to the next open bucket rather than chaining, which
is why contains/subscript lookup is O(1) on average but degrades if the
hash function distributes poorly. This is also the mechanical reason
iteration order is unspecified: it reflects bucket layout, not insertion
order, and can change across insertions/removals or even across process
runs due to the random seed.
🔀 See this in another language¶
Exercise¶
Given let words = ["swift", "is", "expressive", "and", "safe"], use map
to produce an array of their lengths, filter to keep only words with more
than 3 characters, and reduce to compute the total character count across
all words. Then build a [String: Int] dictionary mapping each word to its
length, and a Set<Int> of the distinct lengths.