Skip to content

6. Closures & higher-order functions

What & why

A closure is a self-contained block of code you can pass around and call later. It can capture variables from the place it was written. Closures power map/filter/sorted, callbacks, SwiftUI, and concurrency (Task { }). A function that takes or returns a function is called higher-order.

Closure syntax

Named functions are just closures with a name. The full closure expression:

let add = { (a: Int, b: Int) -> Int in
    return a + b
}
print(add(2, 3))     // 5

in separates the signature from the body. The signature can be shortened step by step when passing a closure as an argument:

let numbers = [5, 2, 8, 1]

// Full
let a = numbers.sorted(by: { (x: Int, y: Int) -> Bool in return x < y })
// Types inferred
let b = numbers.sorted(by: { x, y in return x < y })
// Single-expression: implicit return
let c = numbers.sorted(by: { x, y in x < y })
// Shorthand argument names
let d = numbers.sorted(by: { $0 < $1 })
// Trailing closure
let e = numbers.sorted { $0 < $1 }
// Operator method
let f = numbers.sorted(by: <)

All of these are identical; choose the clearest. Use $0 for short, obvious closures, and named parameters when the body is more than a line or two.

Trailing closures

If the last argument is a closure, write it after the parentheses:

let doubled = numbers.map { $0 * 2 }

numbers.forEach { n in
    print(n)
}

let evenSquares = numbers
    .filter { $0.isMultiple(of: 2) }
    .map { $0 * $0 }

Functions with several closure parameters allow multiple trailing closures:

func load(onSuccess: (String) -> Void, onFailure: (Error) -> Void) { }
load { text in print(text) } onFailure: { error in print(error) }

Capturing values

Closures capture surrounding variables by reference and keep them alive:

func makeCounter() -> () -> Int {
    var count = 0
    return {
        count += 1
        return count
    }
}

let counterA = makeCounter()
let counterB = makeCounter()
print(counterA(), counterA(), counterA())   // 1 2 3
print(counterB())                           // 1 — independent state

Each call to makeCounter creates a fresh count, and the returned closure owns it. Closures are reference types: copying one shares the same captured state.

Use a capture list to capture a snapshot instead:

var x = 10
let show = { [x] in print("x was \(x)") }
x = 20
show()                  // x was 10

Escaping vs non-escaping

A closure parameter is non-escaping by default: it must run (or be dropped) before the function returns, which allows optimisations.

If the function stores the closure or calls it later, mark it @escaping:

var handlers: [() -> Void] = []

func register(_ handler: @escaping () -> Void) {
    handlers.append(handler)         // survives past the call
}

register { print("fired") }
handlers.forEach { $0() }

Closures in classes: avoid retain cycles

A class that stores a closure which captures self creates a reference cycle — neither is ever freed. Use [weak self] (or [unowned self]):

class Downloader {
    var onFinish: (() -> Void)?
    var name = "dl"

    func start() {
        onFinish = { [weak self] in
            guard let self else { return }      // unwrap weak reference
            print("\(self.name) finished")
        }
    }
    deinit { print("Downloader freed") }
}

var d: Downloader? = Downloader()
d?.start()
d = nil          // "Downloader freed" — no leak

Without [weak self], deinit would never run. Details in the ARC lesson. Structs have no such issue (they're copied into closures).

Higher-order functions: the standard toolkit

let words = ["swift", "kotlin", "rust", "go"]

words.map { $0.count }                       // [5, 6, 4, 2]
words.filter { $0.count > 3 }                // ["swift", "kotlin", "rust"]
words.reduce(0) { $0 + $1.count }            // 17
words.sorted { $0.count < $1.count }         // by length
words.first { $0.hasPrefix("r") }            // Optional("rust")
words.contains { $0.count == 2 }             // true
words.allSatisfy { $0.count >= 2 }           // true
words.compactMap { Int($0) }                 // [] — keeps only non-nil results
[[1, 2], [3]].flatMap { $0 }                 // [1, 2, 3]
words.min { $0.count < $1.count }            // Optional("go")
Dictionary(grouping: words, by: \.count)     // [5: ["swift"], 6: ["kotlin"], 4: ["rust"], 2: ["go"]]

Key paths work as functions: words.map(\.count), users.sorted(using: KeyPathComparator(\.age)).

Lazy evaluation

Chain without creating intermediate arrays:

let firstBigSquare = (1...).lazy
    .map { $0 * $0 }
    .first { $0 > 1_000 }
print(firstBigSquare as Any)       // Optional(1024)

(1...) is infinite; lazy makes this safe because only as many elements as needed are computed.

Returning and composing functions

import Foundation

func compose<A, B, C>(_ f: @escaping (A) -> B, _ g: @escaping (B) -> C) -> (A) -> C {
    { g(f($0)) }
}

let trimThenCount = compose({ (s: String) in s.trimmingCharacters(in: .whitespaces) }, { $0.count })
print(trimThenCount("  hi  "))      // 2

Currying-style factories (makeAdder) from the Functions lesson are the same idea.

@autoclosure

Wraps an expression in a closure automatically so it's evaluated only if needed:

func log(_ message: @autoclosure () -> String, enabled: Bool) {
    if enabled { print(message()) }       // the expensive string is built only when enabled
}
log("result: \(expensive())", enabled: false)    // expensive() never runs

func expensive() -> Int { 42 }

&&, ||, ??, and assert use this under the hood.

Void and function types

Type Meaning
() -> Void no params, no result
(Int) -> Int one Int in, Int out
(String, Int) -> Bool two params
((Int) -> Int) -> Int takes a function
(Int) throws -> Int may throw; rethrows for propagating
@Sendable () -> Void safe to call from concurrent code

Common mistakes

  • Retain cycles from self captured strongly in a stored closure. Use [weak self].
  • Forgetting @escaping when storing a closure — compiler error "escaping closure captures non-escaping parameter".
  • Surprising captures: closures capture variables, not values, unless you use a capture list.
  • Long closures in a single chain — extract a named function or use named parameters.
  • Over-clever $0/$1 in nested closures; $0 in an inner closure shadows the outer.
  • Using forEach where a for loop is clearer — break/continue/return behave differently in forEach.

Exercises

  1. Sort ["banana", "kiwi", "apple"] by length (longest first), then alphabetically for ties.
  2. Write func makeMultiplier(_ n: Int) -> (Int) -> Int and use it to build double and triple.
  3. Implement your own myMap, myFilter, and myReduce as extensions on Array.
  4. Write a Debouncer-style helper: func once(_ f: @escaping () -> Void) -> () -> Void that runs f at most one time.
  5. Demonstrate a retain cycle with a class holding a closure, then fix it with [weak self] — prove it by watching deinit print.
  6. Use reduce(into:) to count character frequencies in a string.
  7. Write compose for three functions, or a pipe operator |>.

Recap

  • Closures: { params in body }, shortened with inference, $0, and trailing-closure syntax.
  • They capture variables by reference; capture lists ([x], [weak self]) change that.
  • Non-escaping by default; mark stored/deferred closures @escaping.
  • map/filter/reduce/sorted/compactMap/flatMap are the everyday higher-order tools.
  • Beware retain cycles with classes.

Next: Memory management & ARC →