Closures and Async Ops in Swift

Saksham ShreySaksham Shrey
15 min read

Closures !?

Closures are blocks of code you can pass around in Swift. They're like functions but lighter and more flexible. Closures let you run code on demand, pass it as a variable, or use it for callbacks.

Uses
Closures make code compact and powerful. They’re used everywhere: to sort lists, handle user actions, or call back when data loads. In Swift, closures help make code easier to read and maintain.

Sure, here’s a more detailed, beginner-friendly explanation:

Syntax and Example

A simple closure example that sorts a list of numbers:

let numbers = [3, 1, 2]
let sortedNumbers = numbers.sorted { $0 < $1 }
print(sortedNumbers) // Output: [1, 2, 3]

Backdrop:

  • Closure Structure: We’re using a closure inside the sorted(by:) method to tell Swift how we want the array to be sorted. The sorted(by:) method expects a closure as an argument to determine the sorting logic.

  • Shorthand Argument Names ($0, $1): Swift lets us use $0, $1, $2, etc. as shorthand names for closure parameters:

    • $0 refers to the first item in the comparison (the first element in the array).

    • $1 refers to the second item in the comparison (the next element in the array).

  • How It Works:
    The closure { $0 < $1 } means "compare the first element ($0) to the second element ($1). If $0 is less than $1, keep them in that order; otherwise, swap them." Swift goes through each element in the array, applying this comparison repeatedly until the array is fully sorted.

  • Behind the Scenes: Swift handles all the looping and comparisons internally:

    • It compares each pair of numbers in the array.
  • Based on the result of $0 < $1, it arranges them in ascending order.

This shorthand makes closures shorter and more readable by removing the need for named parameters. Instead of writing something like this:

let sortedNumbers = numbers.sorted { (first, second) in
    return first < second
}

we can use $0 and $1 directly for a cleaner, more concise version:

let sortedNumbers = numbers.sorted { $0 < $1 }

Using $0 and $1 is perfect for quick, simple closures where parameter names would add unnecessary complexity.


Non-Escaping Closures in Swift

Non-Escaping Closures
Non-escaping closures are closures that run right inside the function they’re called in. They don’t hang around after the function ends, so they’re “non-escaping.”

Use-cases
Non-escaping closures are great for quick, immediate tasks that happen within the function. These are operations that don’t require the closure to be saved or used outside of the function’s scope. When you use a non-escaping closure, Swift knows it will be used right away and doesn’t need to “escape” or outlive the function, which allows Swift to optimize performance.

When Swift knows a closure won’t escape a function, it doesn’t need to retain extra memory or storage to keep the closure around. This helps improve performance and makes the code safer, as there’s no risk of retain cycles (when an object holds a strong reference to itself through a closure). Non-escaping closures are ideal for “in-the-moment” tasks that finish immediately within the function scope.

Example of a Non-Escaping Closure

Let’s break down an example where we use a non-escaping closure to calculate an average score from a list of grades.

func calculateScore(grades: [Int], completion: (Int) -> Void) {
    let score = grades.reduce(0, +) / grades.count
    completion(score)
}

// Calling the function
calculateScore(grades: [70, 85, 90]) { score in
    print("Average score is \(score)") // Output: Average score is 81
}

Backdrop:

  • Function Structure:

    • The calculateScore function takes two parameters:

      • grades: an array of integers representing scores.

      • completion: a closure of type (Int) -> Void that takes an integer (the calculated score) and returns nothing (Void).

    • The completion closure allows us to do something with the result after the score is calculated, like printing it or updating the UI.

  • Non-Escaping Closure in Action:

    • The completion closure is called immediately after the calculation, within the same function scope.

    • This closure doesn’t need to be stored or retained for later use. It’s only needed in the moment, when completion(score) is called right after calculating score.

  • Contrast from Escaping Closures:

    • Immediate Execution: Since the closure executes immediately, it doesn’t need to “escape” or exist beyond this function. As soon as completion(score) is called, the closure’s job is done, and Swift can release any memory related to it right away.

    • Efficiency: Non-escaping closures allow Swift to optimize performance. Swift doesn’t have to hold on to the closure or worry about its lifecycle beyond this function, making it faster and using less memory.

    • Safety: With non-escaping closures, there’s no risk of retain cycles or memory leaks. Since the closure doesn’t outlive the function, it can’t accidentally hold onto self or other objects.

  • Preferring a Non-Escaping Closure here:

    • Non-escaping closures are ideal here because the closure’s job is done within the function. There’s no async task, no delay, and no need for the closure to “escape” the function’s scope.

    • Swift knows the closure won’t be needed outside of calculateScore, so it treats it as non-escaping by default. This keeps code simpler and makes it clear that the closure will only be used here, immediately.

Non-Escaping for This Task !?

Since completion is called right after calculating the score, there’s no need to save it or delay its execution. The closure’s entire purpose is to handle a quick task immediately, within the same function call. This makes non-escaping closures perfect for in-the-moment calculations or operations that don’t require waiting for something else (like a network call or timer).

In other words, non-escaping closures are great for synchronous, instant tasks, where you don’t need the closure after it’s executed.


Escaping Closures in Swift

Escaping Closure
Escaping closures are closures saved for later, like for an async task. They "escape" because they’re called after the function that takes them has returned.

Use-cases
Escaping closures are useful for tasks with delays, like fetching data from the internet. You need @escaping to make sure the closure is stored and can be called after the function ends.

Example of an Escaping Closure

Let’s dive into an example using an escaping closure to simulate a network request.

func fetchProfile(completion: @escaping (String) -> Void) {
    // Using a timed delay to simulate network delays in real-world situations
    DispatchQueue.global().asyncAfter(deadline: .now() + 2) {
        completion("User profile loaded!")
    }
}

fetchProfile { profile in
    print(profile) // Output after 2 seconds: "User profile loaded!"
}

Backdrop:

  • Function Structure:

    • The function fetchProfile takes a completion parameter, which is a closure of type (String) -> Void. This means the closure accepts a String (the profile data) and returns nothing (Void).

    • We mark completion with @escaping, allowing it to run later, after the fetchProfile function itself has finished. Without @escaping, Swift would expect completion to be used immediately within the function’s scope.

  • Simulating an Asynchronous Task:

    • Real network requests don’t return data instantly. Instead, they involve delays as they connect to servers, retrieve information, and send it back. To simulate this delay, we use DispatchQueue.global().asyncAfter, which waits 2 seconds before running the completion closure, as if it were responding after receiving results.

    • After the delay, the closure completion("User profile loaded!") is called with the profile data as a String. This simulates the arrival of data from a network call.

  • The Closure “Escapes” the Function Scope:

    • The closure doesn’t execute immediately when fetchProfile is called. Instead, it “escapes” the function, meaning it lives on and executes after the 2-second delay.

    • Since the closure will execute later, marking it as @escaping lets Swift know it won’t complete within the function’s lifecycle, so it keeps the closure in memory until it’s needed.

  • Example Usage:

    • When fetchProfile is called, we provide a closure that prints the profile data once it’s received.

    • The print statement runs after 2 seconds: "User profile loaded!" appears in the console, showing that the completion closure successfully executed after the delay.

Escaping Closures for Asynchronous Tasks !?

Escaping closures are essential for delayed or asynchronous tasks where results aren’t immediately available. In real-world apps, escaping closures are frequently used in network requests, animations, and background operations. They allow us to handle data only when it’s ready, enabling smooth, responsive code that waits for data without blocking the app’s main thread.

This example highlights how escaping closures help manage complex tasks that require time, making them crucial in asynchronous programming.


Completion Handlers with Escaping Closures

Completion Handlers
Completion handlers are escaping closures used to handle async tasks. They let you act on data only after it’s available. For example, updating the UI after a file download completes.

Common Use Cases for Completion Handlers
Use them for network requests, file downloads, or database queries. When the task completes, it “calls back” with data or an error.

Example Using a Completion Handler

In this example, we’ll simulate downloading a file using a completion handler that returns either success or failure. We use Swift’s Result type to handle both outcomes in a single, clean structure.

enum NetworkError: Error { case fileNotFound }

func downloadFile(completion: @escaping (Result<String, NetworkError>) -> Void) {
    DispatchQueue.global().asyncAfter(deadline: .now() + 2) {
        // Simulate a successful download after a delay
        completion(.success("File downloaded successfully"))
    }
}

downloadFile { result in
    switch result {
    case .success(let message):
        print(message) // Output: "File downloaded successfully"
    case .failure(let error):
        print("Error: \(error)")
    }
}

Backdrop:

  • Define Possible Errors with enum:

    • We define an error type called NetworkError using an enum. Here, fileNotFound is a case representing a situation where the file could not be found. This allows us to specify custom errors for various scenarios.
  • Function Structure with Result and @escaping:

    • The downloadFile function takes a completion handler that’s an escaping closure.

    • The type (Result<String, NetworkError>) -> Void means the closure will return a Result type:

      • .success(String): A successful result containing a String (the success message).

      • .failure(NetworkError): A failure result containing a NetworkError.

  • Simulating Asynchronous Download:

    • We use DispatchQueue.global().asyncAfter with a 2-second delay to mimic a network request. In a real-world app, this could represent the time taken to download a file from a remote server.

    • After 2 seconds, completion(.success("File downloaded successfully")) is called, passing a .success result with a success message.

  • Handling the Result with Completion Handler:

    • When we call downloadFile, we provide a closure to handle the Result:

      • On Success: If the result is .success, it contains a String message, which we print.

      • On Failure: If the result is .failure, it contains a NetworkError, which we also print.

Here, we’ve simulated only a successful download, but you could replace completion(.success("...")) with completion(.failure(.fileNotFound)) to test error handling.

Preferring Result for Asynchronous Tasks

The Result type makes it easy to handle success and failure in one step. Instead of needing multiple completion handlers or checking optional values, we can use Result to enforce handling both outcomes explicitly. This approach is safer, more readable, and ideal for tasks where things might go wrong, like network requests.


Advanced Uses for Closures in Swift

Closures in Functional Programming
Closures make functional programming easy in Swift. Use them with map, filter, and reduce to work with data in a clean, readable way.

Functional programming simplifies complex tasks, like transforming data for a report or dashboard.

let numbers = [1, 2, 3]
// Doubling the original numbers
let doubled = numbers.map { $0 * 2 } // [2, 4, 6]
// Filtering Even Numbers from the array
let evenNumbers = numbers.filter { $0 % 2 == 0 } // [2]

Closures with DispatchQueue for Background Tasks
With closures and DispatchQueue, you can run tasks in the background and then update the UI.

Example: Downloading a large file without freezing the app, then showing the file contents when done.

DispatchQueue.global().async {
    let data = loadHeavyData()
    DispatchQueue.main.async {
        updateUI(with: data)
    }
}

Dependency Injection with Closures
Closures are great for dependency injection. They allow you to pass specific behavior to objects, making code modular and testable.

Example: In tests, you might pass in mock data instead of real data, making it easier to verify behavior without relying on external sources.

class ServiceManager {
    let fetchData: () -> String

    init(fetchData: @escaping () -> String) {
        self.fetchData = fetchData
    }
}

Custom Animations with Closures in SwiftUI
With SwiftUI’s withAnimation closure, you can trigger smooth animations easily.

withAnimation {
    self.isExpanded.toggle()
}

For example, this could expand or collapse a menu, making the app feel interactive and polished.

Dynamic Views in SwiftUI Using Closures
Closures are essential for building dynamic views in SwiftUI. They let you pass custom content, making your UI components reusable.

Example 1: A Button with Different Actions

In this example, we define a custom button view that can perform various actions depending on the closure passed to it. The DynamicButton accepts a title and an action, making it flexible for different behaviors on button press.

struct DynamicButton: View {
    let title: String
    let action: () -> Void

    var body: some View {
        Button(action: action) {
            Text(title)
                .padding()
                .background(Color.blue)
                .foregroundColor(.white)
                .cornerRadius(8)
        }
    }
}

struct ContentView: View {
    var body: some View {
        VStack(spacing: 20) {
            DynamicButton(title: "Print Hello") {
                print("Hello!")
            }

            DynamicButton(title: "Print Goodbye") {
                print("Goodbye!")
            }
        }
    }
}

Explanation:

  • DynamicButton accepts a title and an action closure. The action closure allows us to define different behaviors for each button instance.

  • When you call DynamicButton in ContentView, you can specify a custom action. For example:

    • First Button: Prints "Hello!" when pressed.

    • Second Button: Prints "Goodbye!" when pressed.

This approach keeps DynamicButton reusable for different actions without changing the button’s layout.

Example 2: A Button with Generic Content and Action

In this example, we’ll create a GenericButton that not only accepts a custom action but also lets you define the button’s content (text, icon, etc.) using a generic Content view. This allows even more flexibility, as you can customize both the look and behavior of the button.

struct GenericButton<Content: View>: View {
    let action: () -> Void
    let content: Content

    init(action: @escaping () -> Void, @ViewBuilder content: () -> Content) {
        self.action = action
        self.content = content()
    }

    var body: some View {
        Button(action: action) {
            content
                .padding()
                .background(Color.blue)
                .foregroundColor(.white)
                .cornerRadius(8)
        }
    }
}

struct ContentView: View {
    var body: some View {
        VStack(spacing: 20) {
            GenericButton(action: {
                print("Primary Action")
            }) {
                Text("Primary Action")
            }

            GenericButton(action: {
                print("Secondary Action")
            }) {
                HStack {
                    Image(systemName: "star")
                    Text("Secondary Action")
                }
            }
        }
    }
}

Explanation:

  • GenericButton: This button accepts an action closure and a content view (using a generic type Content). The @ViewBuilder allows content to accept multiple SwiftUI views, such as Text, Image, or an HStack combining them.

  • Usage in ContentView:

    • First Button: Uses a simple Text view as content and prints "Primary Action" when pressed.

    • Second Button: Combines an Image and Text in an HStack, showing an icon next to the button title. When pressed, it prints "Secondary Action".

With this structure, GenericButton becomes highly flexible, allowing you to change both the button’s appearance and its behavior, making it a powerful tool for dynamic, reusable components in SwiftUI.

Memory Management with Closures

When you use closures in Swift, they can sometimes lead to retain cycles if they capture self. A retain cycle happens when two objects hold strong references to each other, preventing either from being released from memory. In the case of closures, this can cause memory leaks, where objects remain in memory longer than needed.

Retain Cycles with Closures

When a closure is defined within an object (like a class instance), it captures a strong reference to self by default if you reference self inside the closure. This is especially common in asynchronous tasks, where the closure might run after a delay, keeping the class instance in memory until the closure completes.

Example of a Retain Cycle

Consider the following example:

func fetchData(completion: @escaping (String) -> Void) {
    DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
        completion("Data fetched")
    }
}

class DataFetcher {
    func startFetching() {
        fetchData { data in
            print("Fetched: \(data)")
        }
    }
}

Backdrop:

  • startFetching() calls fetchData, which takes a completion closure.

  • The closure captures self (the DataFetcher instance) because we reference self implicitly in print("Fetched: \(data)").

  • Since fetchData is asynchronous, the closure keeps self alive until it completes after 1 second, which can create a retain cycle if other parts of the code also hold strong references to self.

Solution: Using [weak self] to Avoid Retain Cycles

To break the retain cycle, we use [weak self] in the closure’s capture list. This tells Swift to create a weak reference to self, meaning the closure won’t prevent self from being released if it’s no longer needed elsewhere.

Here’s the corrected code:

class DataFetcher {
    func startFetching() {
        fetchData { [weak self] data in
            guard let self = self else { return }
            print("Fetched: \(data)")
        }
    }
}

Working of [weak self]

  • Weak Reference: By marking self as [weak self], we make self an optional inside the closure. This means self may become nil if the instance is deallocated before the closure runs.

  • Optional Binding: We use guard let self = self to safely unwrap self. If self is still around, the closure runs as expected. If self has been deallocated, the closure simply exits without executing, avoiding errors or crashes.

Problem is solved by [weak self]

By using [weak self], we prevent the closure from holding a strong reference to self, breaking the retain cycle:

  • No Retain Cycle: self can be deallocated as soon as no other strong references to it remain, even if the closure hasn’t completed.

  • Memory Efficiency: The closure doesn’t “own” self, so self can be released when it’s no longer needed, preventing memory leaks.

TLDR

When working with closures in async tasks, always consider using [weak self] to prevent retain cycles, especially if self might be deallocated before the closure completes. This pattern helps keep your code memory-efficient and avoids unnecessary memory leaks in your app.


I hope this guide has shed light on closures and asynchronous operations in Swift—concepts that have become essential tools in my own two-year journey of iOS development. These insights have helped me write cleaner, more efficient code and build dynamic, reusable components, and I’m excited to share them with others who are also navigating Swift’s capabilities.

As you continue to explore closures and async tasks, remember that these techniques are key for tackling complex problems in iOS development.

If you have questions or run into challenges, feel free to reach out at sakshamshrey@gmail.com. I’d love to hear your feedback and help where I can. Happy coding, and best of luck on your journey to mastering Swift !!

0
Subscribe to my newsletter

Read articles from Saksham Shrey directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Saksham Shrey
Saksham Shrey

Student, learner and a tech enthusiast || likes to discuss all things tech and also abstracts || believes in First-Principles Thinking || ENTJ-A 🍁