Closures in Swift — Capture Lists, Escaping, and Retain Cycles
Closures are first-class reference types that capture the world around them. Those capture semantics are the source of both Swift's expressive power and its trickiest memory bugs.
Functions and closures are first-class
In Swift, a function is not a special, untouchable thing. It is a value. We can store it in a variable, pass it as an argument, and return it from another function. Anything we can do with an Int or a String, we can do with a function.
And here is the key idea that runs through this whole episode: a function is just a named closure. A closure is a self-contained block of code that can be passed around and called later. When we write func greet(), we are really giving a name to a closure. The anonymous block { print("hi") } is the same kind of thing, only without a name.
func greet() {
print("Hello!")
}
let f = greet // assign the function to a variable
f() // call it — prints "Hello!"
let blocks = [greet, greet, greet]
for block in blocks {
block() // prints "Hello!" three times
}
Because functions are values, they have types. A function type is written as the parameter types in parentheses, an arrow, then the return type. (Int) -> Bool is the type of any function that takes an Int and returns a Bool. () -> Void takes nothing and returns nothing. (String, String) -> Bool takes two strings and returns a bool.
let isEven: (Int) -> Bool = { n in n % 2 == 0 }
let printer: (String) -> Void = { s in print(s) }
func apply(_ value: Int, _ test: (Int) -> Bool) -> Bool {
return test(value)
}
apply(10, isEven) // true
A little history for context. When Swift was unveiled at WWDC in 2014, Chris Lattner and the team made closures central to the language from day one. The { } syntax with the in keyword was deliberately designed to read well at the call site, because Cocoa was already drowning in Objective-C blocks (which used the famously hard-to-remember ^ caret syntax). Swift's closures are the spiritual successor to those blocks, but with cleaner syntax and full type inference.
Closure expression syntax — from full to shorthand
Swift lets us write the same closure at several levels of brevity. The compiler fills in everything it can infer, so we are free to omit the obvious. Let's take sorting an array of names and peel the ceremony away one layer at a time.
We start with the full form: explicit parameter types, an explicit return type, the in keyword separating the signature from the body, and an explicit return.
let names = ["Chris", "Ada", "Brian"]
// 1. Full form
names.sorted(by: { (a: String, b: String) -> Bool in
return a < b
})
Because sorted(by:) already knows it wants a (String, String) -> Bool, the types are redundant. And a single-expression closure returns its value implicitly, so return goes too.
// 2. Inferred types, implicit return
names.sorted(by: { a, b in a < b })
Swift gives every closure automatic shorthand argument names: $0, $1, $2, and so on. Once we use them, we can drop the parameter list and the in entirely.
// 3. Shorthand argument names
names.sorted(by: { $0 < $1 })
Finally, when a closure is the last argument to a function, we can lift it outside the parentheses as a trailing closure. If it is the only argument, the parentheses vanish completely.
// 4. Trailing closure
names.sorted { $0 < $1 }
This is why so much Swift reads cleanly.
names.sorted { $0 < $1 } says exactly what it does with no noise. Swift 5.3 went one step further with multiple trailing closures (proposal SE-0279). When a function takes several closure arguments, the first is unlabelled and the rest are labelled, which is why SwiftUI can write Button { save() } label: { Text("Save") }.
Capturing the environment
Here is what makes a closure more than a bare block of code. A closure captures the variables it references from the surrounding scope. By default it captures them by reference, which means it can both read and write them — and it keeps doing so even after the enclosing function has returned.
The classic demonstration is a counter maker. We write a function that returns a closure, and that closure keeps mutating a variable that, on paper, should have stopped existing the moment the outer function returned.
func makeCounter() -> () -> Int {
var count = 0
return {
count += 1
return count
}
}
let counter = makeCounter()
print(counter()) // 1
print(counter()) // 2
print(counter()) // 3
The inner closure captured count. Even though makeCounter() has returned, the closure still holds a live reference to that variable and goes on incrementing it. Each fresh call to makeCounter() creates a new, independent count, so two counters never tread on each other.
Capturing by reference is what makes this work, but it is also where surprises hide. Because the closure shares the same variable rather than a snapshot, anything that mutates that variable later is visible inside the closure. That is the difference between capturing a reference and capturing a value, and it is exactly what capture lists let us control.
Capture lists — snapshotting and controlling references
What if we do not want the live, shared reference? What if we want the closure to remember a value as it was at the moment of creation? That is what a capture list is for. We write the names we care about in square brackets right at the start of the closure.
var x = 10
let snapshot = { [x] in print(x) } // captures a copy of x: 10
let live = { print(x) } // captures x by reference
x = 99
snapshot() // 10 — frozen at creation time
live() // 99 — sees the current value
The [x] capture list says "take the current value of x and stash a copy inside the closure." The closure that captures x implicitly, with no capture list, shares the real variable and so reports its later value.
Capture lists matter most for reference types — classes. By default a closure captures a class instance strongly, which keeps it alive. We can change that with two keywords inside the capture list:
•
[weak self] captures self as an optional weak reference. It does not keep self alive, and becomes nil if self has been deallocated.•
[unowned self] captures self without keeping it alive either, but assumes self will still exist when the closure runs. If it does not, the program crashes — so use it only when you are certain of the lifetime.final class Downloader {
var onFinish: (() -> Void)?
func start() {
// weak: self may be gone by the time this runs
onFinish = { [weak self] in
self?.cleanUp()
}
}
func cleanUp() { print("done") }
}
So we have two distinct tools wearing the same bracket syntax. For value types, a capture list snapshots the value. For reference types, it lets us choose how the reference is held — strongly, weakly, or unowned. Getting this choice right is the whole game when it comes to memory, which is exactly where we are heading.
Escaping vs non-escaping closures
Swift draws a sharp line between two kinds of closure parameters, and the distinction is about lifetime: does the closure outlive the function call it was passed into, or not?
A non-escaping closure is called before the function returns and is never stored anywhere. Think of map or sorted(by:) — they run the closure right there and then throw it away. Since Swift 3 (proposal SE-0103), closures are non-escaping by default. We write nothing special.
An escaping closure may be stored for later or run after the function has already returned. Completion handlers, stored callbacks, anything pushed onto a queue — these all escape. We mark such a parameter with @escaping, and the compiler insists on it.
// Non-escaping: runs now, then gone
func transform(_ x: Int, with f: (Int) -> Int) -> Int {
return f(x)
}
// Escaping: stored to run later
var handlers: [() -> Void] = []
func defer(_ work: @escaping () -> Void) {
handlers.append(work) // escapes — outlives this call
}
Why make non-escaping the default? Two reasons. First, the optimiser. If the compiler knows a closure cannot escape, it can keep its captures on the stack and avoid heap allocation and reference-counting overhead entirely. Second, safety. A non-escaping closure that captures
self cannot create a long-lived reference cycle, because it simply does not live long enough — which is why we are never asked to write [weak self] inside a plain map. The danger only appears once a closure can escape, and Swift makes that danger visible by forcing the @escaping annotation.
Retain cycles — the most common real-world Swift memory bug
Here it is, the bug that catches every Swift developer eventually. An escaping closure is stored on an object, and that closure captures self strongly. Now self holds the closure, and the closure holds self. Each keeps the other's reference count above zero, ARC sees neither as garbage, and the pair leaks forever.
final class ViewModel {
var onUpdate: (() -> Void)?
func configure() {
// self → onUpdate → self : a strong cycle
onUpdate = {
self.refresh()
}
}
func refresh() { /* ... */ }
deinit { print("freed") } // never prints!
}
The deinit never runs. The object is unreachable from the outside, yet ARC cannot collect it because the closure is still holding a strong reference back to it. This is a textbook retain cycle.
We break the cycle with a capture list.
[weak self] turns self into a weak optional inside the closure, so the closure no longer keeps the object alive. Because it is now optional, we unwrap it — usually with the guard let self else { return } dance at the top of the closure, which gives us a strong local self for the rest of the body if the object is still around.
onUpdate = { [weak self] in
guard let self else { return }
self.refresh()
}
The alternative is [unowned self], which avoids the optional and reads more cleanly — but it is a promise to the compiler that self will still be alive when the closure fires. Break that promise and you get a crash, not a leak. Reach for weak when the lifetime is uncertain (network callbacks, timers), and unowned only when self provably outlives the closure. As a rule, if you store an escaping closure on an object and reference self inside it, assume you have a cycle until you have proven otherwise.
The functional toolkit, and a little trivia
Closures are the fuel for Swift's higher-order functions — functions that take other functions as arguments. The standard library leans on them heavily, and once the shorthand syntax is second nature, these read almost like English.
let nums = [1, 2, 3, 4, 5]
nums.map { $0 * 2 } // [2, 4, 6, 8, 10]
nums.filter { $0 % 2 == 0 } // [2, 4]
nums.reduce(0) { $0 + $1 } // 15
let raw = ["1", "x", "3"]
raw.compactMap { Int($0) } // [1, 3] — drops nils
let nested = [[1, 2], [3], [4, 5]]
nested.flatMap { $0 } // [1, 2, 3, 4, 5] — one level flatter
Each of these takes a closure and applies it across the collection. map transforms, filter keeps what passes a test, reduce folds everything into a single value, compactMap maps then quietly discards any nil results, and flatMap maps then concatenates one level of nesting away.
Two facts worth keeping in your pocket. First, closures are reference types. Assign a closure to two variables and they point at the same underlying object, captured state and all — which is precisely why ARC manages closures and why retain cycles are even possible. Second,
@autoclosure. It wraps a plain expression in a () -> T closure automatically, so an argument is not evaluated until it is actually needed. This is the trick behind lazy evaluation in the standard library:
// The nil-coalescing operator only evaluates the default if needed
func ?? <T>(value: T?, defaultValue: @autoclosure () -> T) -> T {
if let value { return value } else { return defaultValue() }
}
let name = optionalName ?? expensiveDefault() // expensiveDefault() runs only if nil
The same mechanism powers assert, whose condition is skipped entirely in release builds. To close, a quick contrast with Dart, the subject of our sibling series. Both languages give us first-class closures that capture by reference, and both lean on map and friends. But Dart relies on a tracing garbage collector, so it has no retain cycles and no need for capture lists or weak references at all. Swift trades that convenience for deterministic, immediate deallocation through ARC — and the price of that determinism is the very capture-list discipline this episode has been about.
Test your understanding
7 questions
Seven questions covering first-class closures, capture lists, escaping, retain cycles, and the functional toolkit.