Skip to main content

[From C# to Swift] 07. Closures

Learning Swift from a C# Perspective

Swift: Closures

Closure Expressions
#

1. Core Concepts
#

  • Explanation: Closures are self-contained blocks of functionality that can be passed around and used in your code. Simply put, they are “logic that can be stored in variables.” Swift closures can capture constants and variables from the context in which they are defined. Swift closure expressions have a clean, clear syntax style. The compiler can perform strong type inference, allowing you to omit parameter types, parentheses, and even use shorthand argument names ($0).
  • Key Syntax: { (parameters) -> ReturnType in statements }, in keyword, $0, Trailing Closures
  • Official Tip:

Global functions and Nested functions are actually special cases of closures. Global functions are closures that have a name and do not capture any values. Nested functions are closures that have a name and can capture values from their enclosing function.

2. Example Analysis
#

Original Documentation Code:

// Original complete form
let names = ["Chris", "Alex", "Ewa", "Barry", "Daniella"]
var reversedNames = names.sorted(by: { (s1: String, s2: String) -> Bool in
    return s1 > s2
})

// Optimization 1: Type Inference
reversedNames = names.sorted(by: { s1, s2 in return s1 > s2 } )

// Optimization 2: Implicit Returns from Single-Expression
reversedNames = names.sorted(by: { s1, s2 in s1 > s2 } )

// Optimization 3: Shorthand Argument Names
reversedNames = names.sorted(by: { $0 > $1 } )

// Optimization 4: Operator Methods
reversedNames = names.sorted(by: >)

Logic Explanation: This code demonstrates the evolution of Swift closure syntax. From the most complete function type declaration, known information is omitted step by step.

  1. Since sorted(by:) expects (String, String) -> Bool, the parameter type String can be omitted.
  2. Since the closure contains only a single line of code, the return keyword can be omitted.
  3. If you don’t want to name parameters s1, s2, you can directly use $0, $1 to represent the first and second arguments.
  4. Since String defines the > operator and its signature fits the requirement exactly, you can simply pass the operator.

3. C# Developer Perspective
#

Concept Correspondence: Swift Closures directly correspond to C# Lambda Expressions (=>) and Anonymous Methods (delegate { }).

C# Comparison Code:

var names = new List<string> { "Chris", "Alex", "Ewa", "Barry", "Daniella" };

// C# Lambda Expression
// Corresponds to Swift's { s1, s2 in s1 > s2 }
var reversedNames = names.OrderByDescending(s => s).ToList();

// To fully simulate comparator logic:
names.Sort((s1, s2) => s2.CompareTo(s1)); 

Key Differences Analysis:

  • Syntax:
    • Swift uses the in keyword to separate parameters from the body ({ params in body }).
    • C# uses the Lambda operator => (params => body).
    • Swift provides shorthand arguments like $0, $1. C# requires explicit parameter naming (e.g., x => x + 1) and cannot omit parameter names.
  • Behavior:
    • Both are very powerful regarding type inference; usually, explicit declaration of parameter types is not needed.

Trailing Closures
#

1. Core Concepts
#

  • Explanation: If the last parameter of a function is a closure, Swift allows you to write the closure expression outside the function call’s parentheses (). This makes the code look more like native control structures (such as if or while blocks), significantly improving readability. If the closure is the function’s only argument, you can even omit the parentheses () entirely.
  • Key Syntax: funcName() { ... }

2. Example Analysis
#

Original Documentation Code:

func someFunctionThatTakesAClosure(closure: () -> Void) {
    // function body
}

// Without using trailing closure
someFunctionThatTakesAClosure(closure: {
    // closure's body
})

// Using trailing closure (outside parentheses)
someFunctionThatTakesAClosure() {
    // trailing closure's body
}

// Practical application: Array map
let strings = numbers.map { (number) -> String in
    var number = number
    var output = ""
    repeat {
        output = digitNames[number % 10]! + output
        number /= 10
    } while number > 0
    return output
}

Logic Explanation: The map function accepts a closure as an argument. Since it is the only argument, the parentheses map(...) are omitted when calling it, followed directly by { ... }. This makes the transformation logic look like an independent code block rather than a function argument, which is a key feature for Swift’s DSL (Domain Specific Language) feel.

3. C# Developer Perspective
#

Concept Correspondence: C# does not have syntactic sugar directly corresponding to Trailing Closures. In C#, even if the Lambda is the last parameter, it must be written inside the parentheses.

C# Comparison Code:

// C# must wrap the Lambda inside parentheses
var strings = numbers.Select(number => {
    var tempNumber = number;
    var output = "";
    // ... logic ...
    return output;
});

Key Differences Analysis:

  • Syntax: This is one of the main reasons why Swift code looks “cleaner.” When reading Swift UI (SwiftUI) or configuration code, C# developers will encounter a large number of Trailing Closures and need to get used to this “function call looking like a block definition” style.

Capturing Values and Memory Management
#

1. Core Concepts
#

  • Explanation: Closures can “capture” constants or variables from their defining scope. Even if the scope defining these variables has ended, the closure can still reference and modify these values. Swift closures are Reference Types. When you assign a closure to a variable, you are assigning a reference.
  • Key Syntax: Reference Type, Capture List, makeIncrementer
  • Official Tip:

If you assign a closure to a property of a class instance, and the closure captures that instance (via self), it creates a Strong Reference Cycle. Swift uses a Capture List ([weak self]) to break this cycle.

2. Example Analysis
#

Original Documentation Code:

func makeIncrementer(forIncrement amount: Int) -> () -> Int {
    var runningTotal = 0
    func incrementer() -> Int {
        runningTotal += amount
        return runningTotal
    }
    return incrementer
}

let incrementByTen = makeIncrementer(forIncrement: 10)
incrementByTen() // returns 10
incrementByTen() // returns 20

Logic Explanation: The incrementer function is nested inside makeIncrementer. It captures runningTotal and amount from the outer scope. Even after makeIncrementer finishes execution and returns, the memory space for runningTotal still exists because the incrementByTen closure has captured the storage state of runningTotal, and that state persists along with the closure’s lifecycle.

3. C# Developer Perspective
#

Concept Correspondence: This is almost identical to the Closure Variable Capturing mechanism in C#. The C# compiler automatically generates a hidden class (Display Class) to hold the captured variables.

C# Comparison Code:

Func<int> MakeIncrementer(int amount) {
    int runningTotal = 0;
    return () => {
        runningTotal += amount;
        return runningTotal;
    };
}

var incrementByTen = MakeIncrementer(10);
Console.WriteLine(incrementByTen()); // 10

Key Differences Analysis:

  • Behavior (Memory Management): This is the biggest trap.
    • C# uses Garbage Collection (GC). Even if a Lambda captures this, circular references are usually collected by the GC as long as there are no external references (unless involving specific scenarios like Event Handlers).
    • Swift uses ARC (Automatic Reference Counting). Closures create a Strong Reference to captured objects by default. If a closure captures self, and self owns the closure (e.g., stored as a property), it leads to a Memory Leak. C# developers writing Swift must constantly check if they need to use [weak self] or [unowned self].

Escaping Closures
#

1. Core Concepts
#

  • Explanation: When a closure is passed as an argument to a function but is called after the function returns, the closure is said to “Escape”. The most common scenario is Completion Handlers for asynchronous operations. In Swift, closures are Non-Escaping by default as a performance optimization. If a closure needs to escape, it must be explicitly marked with the @escaping attribute.
  • Key Syntax: @escaping, completionHandler

2. Example Analysis
#

Original Documentation Code:

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

func someFunctionWithEscapingClosure(completionHandler: @escaping () -> Void) {
    // Closure is stored in an external array and executed later -> Escaping
    completionHandlers.append(completionHandler)
}

func someFunctionWithNonescapingClosure(closure: () -> Void) {
    // Closure is executed directly within the function -> Non-escaping
    closure()
}

Logic Explanation: someFunctionWithEscapingClosure adds the closure to the externally defined completionHandlers array. This means that after the function finishes execution, the closure still exists in memory and can be called at any time. The compiler enforces the @escaping marker to remind the developer of potential memory management risks (especially when capturing self).

3. C# Developer Perspective
#

Concept Correspondence: C# does not have an explicit distinction like @escaping. In C#, all Delegates/Lambdas are objects (Heap Allocated) and inherently possess the ability to “escape”.

Key Differences Analysis:

  • Syntax: @escaping is a mandatory syntax requirement in Swift.
  • Behavior: Swift defaults to Non-Escaping for optimization (can be allocated on the Stack, no need for Reference Counting). C# developers need to get used to this: if your Callback runs asynchronously or is stored for later use, the Swift compiler will error out and require you to add @escaping.
  • Handling Self: For @escaping closures, Swift enforces explicit use of self. (e.g., self.x) or adding [self] to the Capture List within the closure. This forces developers to be aware that capturing is occurring, preventing circular references.

Autoclosures
#

1. Core Concepts
#

  • Explanation: @autoclosure is syntactic sugar that automatically wraps an expression you pass into a closure. It can only wrap a “single expression,” not an arbitrary code block, but it eliminates the need for curly braces {}. It is typically used for Delayed Evaluation.
  • Key Syntax: @autoclosure
  • Official Tip:

Overusing @autoclosure can reduce code readability. The user might not know that the passed argument will not be evaluated immediately.

2. Example Analysis
#

Original Documentation Code:

// Define a function that accepts an autoclosure
func serve(customer customerProvider: @autoclosure () -> String) {
    print("Now serving \(customerProvider())!")
}

var customersInLine = ["Ewa", "Barry", "Daniella"]

// Looks like passing a String when called, but actually passes a closure
// customersInLine.remove(at: 0) executes only when customerProvider() is called inside serve
serve(customer: customersInLine.remove(at: 0))

Logic Explanation: The serve function’s parameter type is marked with @autoclosure. When we call serve(customer: customersInLine.remove(at: 0)), Swift does not execute remove(at: 0) first and pass the result. Instead, it automatically generates a closure { customersInLine.remove(at: 0) } and passes it in. This means if the serve function internally decides not to call this closure, the remove action will never happen.

3. C# Developer Perspective
#

Concept Correspondence: This is similar to Func<T> or Lazy<T> in C#, but C# lacks the syntactic sugar to automatically convert an expression into a Func<T>. The closest experience for C# developers might be delayed execution in IQueryable or Expression Trees, or conditional checks in Debug.Assert.

C# Comparison Code:

// C# has no Autoclosure, must explicitly pass Func
void Serve(Func<string> customerProvider) {
    Console.WriteLine($"Now serving {customerProvider()}!");
}

// Must be written as a Lambda when called
Serve(() => {
    var name = customersInLine[0];
    customersInLine.RemoveAt(0);
    return name;
});

Key Differences Analysis:

  • Syntax: Swift’s @autoclosure makes the function call look very natural (like passing a value), but the behavior is passing a reference (logic).
  • Behavior: Commonly found in assert functions. For example, assert(condition, message), where the message part only needs to be evaluated (potentially involving expensive operations like string concatenation) if condition fails. Using @autoclosure avoids unnecessary computational overhead.