Deep Dive into Functions and Closures

abdul ahad
8 min readAug 21, 2024

--

Photo by Joseph Barrientos on Unsplash

To understand functions and closures in Swift, you really need to understand the following topics:

  1. Functions as First-Class Citizens
  2. Functions Capturing Variables (Closures)
  3. Closure Expressions in Swift
  4. Autoclosures in Swift
  5. Escaping Closures in Swift
  6. Delegation and Callbacks
  7. Understanding In-Out Parameters in Swift
  8. Subscripts in Swift
  9. Result Builders in Swift

1. Functions as First-Class Citizens

In Swift, functions are considered “first-class citizens.” This means they can be assigned to variables, passed as arguments to other functions, and returned from other functions, just like any other data type (such as Int or String).

Example: Assigning Functions to Variables

func printInt(i: Int) {
print("You passed \(i).")
}

let funVar = printInt // Assigning the function to a variable
funVar(2) // Calling the function using the variable, outputs: "You passed 2."

Here, printInt is a function that takes an integer and prints it. By assigning printInt to funVar, we can call the function using funVar. Notice that when assigning the function, we don’t use parentheses () after printInt.

Example: Passing Functions as Arguments

func useFunction(function: (Int) -> ()) {
function(3)
}

useFunction(function: printInt) // Outputs: "You passed 3."
useFunction(function: funVar) // Outputs: "You passed 3."

The useFunction function takes another function as an argument. Here, we pass both printInt and funVar (which points to printInt) to useFunction, demonstrating how functions can be passed around and used in higher-order functions.

Example: Returning Functions from Other Functions

func returnFunc() -> (Int) -> String {
func innerFunc(i: Int) -> String {
return "You passed \(i)."
}
return innerFunc
}

let myFunc = returnFunc()
print(myFunc(3)) // Outputs: "You passed 3."

Here, returnFunc returns another function innerFunc that itself returns a string. When we call returnFunc, it returns the innerFunc function, which we store in myFunc. Calling myFunc(3) then executes innerFunc, printing the message

2. Functions Capturing Variables (Closures)

In Swift, closures can “capture” variables from their surrounding context. This means that even after the function (or closure) is returned and its local scope is gone, it retains access to those captured variables.

Example: Capturing Variables

func counterFunc() -> (Int) -> String {
var counter = 0
func innerFunc(i: Int) -> String {
counter += i // counter is captured by the closure
return "Running total: \(counter)"
}
return innerFunc
}

let f = counterFunc()
print(f(3)) // Outputs: "Running total: 3"
print(f(4)) // Outputs: "Running total: 7"

In this example, the innerFunc function captures the counter variable from its enclosing scope. Even after counterFunc returns, innerFunc retains access to counter, allowing it to update and return the running total each time it is called.

Example: Multiple Instances of Closures

let g = counterFunc()
print(g(2)) // Outputs: "Running total: 2"
print(g(2)) // Outputs: "Running total: 4"

print(f(2)) // Outputs: "Running total: 9"

Here, calling counterFunc again creates a new instance of the innerFunc closure with a fresh counter variable. The closure stored in g has its own counter, separate from the one in f. This shows how each closure maintains its own captured state.

3 Closure Expressions in Swift

In Swift, you can create functions using the func keyword or by using closure expressions, which are unnamed functions written using the { } syntax.

Example: Defining a Function with func

func doubler(i: Int) -> Int {
return i * 2
}

let result = [1, 2, 3, 4].map(doubler) // result: [2, 4, 6, 8]

This is a simple function that doubles an integer. We can pass this function to map, which applies it to each element in the array.

Example: Defining a Function with Closure Expression

let doublerAlt = { (i: Int) -> Int in
return i * 2
}

let resultAlt = [1, 2, 3, 4].map(doublerAlt) // resultAlt: [2, 4, 6, 8]

Here, the same function is defined using a closure expression. The syntax is different, but the function does exactly the same thing as the doubler function defined with func.

Example: Simplifying Closure Expressions

Swift allows closure expressions to be written in a more concise form:

let result = [1, 2, 3].map { $0 * 2 } // result: [2, 4, 6]

This is the same doubling function but written in a very compact form. Let’s break down how it’s simplified:

  1. Omitting Argument Names: If the argument type can be inferred, you can omit the argument list entirely and use the shorthand $0 for the first argument.
  2. Implicit Return: If the closure consists of a single expression, Swift automatically returns the result of that expression, so you can omit the return keyword.
  3. Trailing Closure Syntax: If the closure is the last argument in a function, you can move it outside the parentheses for cleaner syntax.
  4. Shorthand names: Swift automatically provides shorthand names for the arguments to the function — $0 for the first, $1 for the second, etc.
  5. No arguments: Finally, if a function has no arguments other than a closure expression, you can leave off the parentheses after the function name altogether. Using each of these rules, we can boil down the expression below to the form shown above:

Using each of these rules, we can boil down the expression below to the form shown above:

Example

[1, 2, 3].map( { (i: Int) -> Int in return i * 2 } ) 
[1, 2, 3].map( { i in return i * 2 } )
[1, 2, 3].map( { i in i * 2 } )
[1, 2, 3].map( { $0 * 2 } )
[1, 2, 3].map() { $0 * 2 }
[1, 2, 3].map { $0 * 2 }

4 Delegates and Callback functions in Swift

1. Traditional Delegate Pattern

In the traditional Cocoa (Objective-C) style, delegates are used to handle callbacks. This is usually done by defining a protocol (interface) and then implementing that protocol in a class.

protocol AlertViewDelegate: AnyObject {
func buttonTapped(atIndex: Int)
}

class AlertView {
var buttons: [String]
weak var delegate: AlertViewDelegate?

init(buttons: [String] = ["OK", "Cancel"]) {
self.buttons = buttons
}

func fire() {
delegate?.buttonTapped(atIndex: 1)
}
}

class ViewController: AlertViewDelegate {
let alert: AlertView

init() {
alert = AlertView(buttons: ["OK", "Cancel"])
alert.delegate = self
}

func buttonTapped(atIndex index: Int) {
print("Button tapped: \(index)")
}
}

Explanation:

  • AlertViewDelegate is a protocol with a single method.
  • AlertView holds a weak reference to its delegate to avoid strong reference cycles.
  • ViewController implements AlertViewDelegate and sets itself as the delegate for an AlertView instance.

2. Delegates with Structs

In some cases, you might want to use a struct instead of a class as the delegate. However, this introduces challenges because structs are value types and do not support weak references.

Example:

protocol AlertViewDelegate {
mutating func buttonTapped(atIndex: Int)
}

class AlertView {
var buttons: [String]
var delegate: AlertViewDelegate?

init(buttons: [String] = ["OK", "Cancel"]) {
self.buttons = buttons
}

func fire() {
delegate?.buttonTapped(atIndex: 1)
}
}

struct TapLogger: AlertViewDelegate {
var taps: [Int] = []

mutating func buttonTapped(atIndex index: Int) {
taps.append(index)
}
}

let alert = AlertView()
var logger = TapLogger()
alert.delegate = logger
alert.fire()

// The taps in the logger won't be updated because Swift copies the struct.
logger.taps // []

Explanation:

  • When alert.delegate = logger is executed, the logger struct is copied.
  • Mutating the copied struct inside the delegate method (buttonTapped) does not affect the original logger.

3. Replacing Delegates with Functions

If the delegate protocol has only one method, it can be replaced with a function (closure).

class AlertView {
var buttons: [String]
var buttonTapped: ((_ buttonIndex: Int) -> ())?

init(buttons: [String] = ["OK", "Cancel"]) {
self.buttons = buttons
}

func fire() {
buttonTapped?(1)
}
}

struct TapLogger {
var taps: [Int] = []

mutating func logTap(index: Int) {
taps.append(index)
}
}

let alert = AlertView()
var logger = TapLogger()

// To avoid the mutating method issue, we use a closure.
alert.buttonTapped = { logger.logTap(index: $0) }
alert.fire()

logger.taps // [1]

Explanation:

  • Here, buttonTapped is a closure that takes an Int as an argument.
  • Instead of using a delegate, the AlertView directly calls the closure.
  • We wrap logger.logTap in a closure to ensure it captures and mutates the original logger.

4. Avoiding Reference Cycles with Closures

When using closures in classes, be cautious of reference cycles. Capturing self strongly in a closure that is stored in a property can create a reference cycle.

Example:

class ViewController {
let alert: AlertView

init() {
alert = AlertView(buttons: ["OK", "Cancel"])
alert.buttonTapped = { [weak self] index in
self?.buttonTapped(atIndex: index)
}
}

func buttonTapped(atIndex index: Int) {
print("Button tapped: \(index)")
}
}

Explanation:

  • The [weak self] in the closure prevents a strong reference cycle by capturing self weakly.

When to Use Delegates vs. Functions

Delegates:

  • Useful when you have multiple related methods to implement.
  • Helps keep related callbacks grouped together.
  • With class-only protocols, you can avoid reference cycles using weak references.

Functions (Closures):

  • More flexible and lightweight when only one callback is needed.
  • Useful when working with structs or anonymous functions.
  • Requires careful handling of reference cycles when working with classes.

Understanding InOut in Functions

1. Inout Parameters: Pass-by-Value-and-Copy-Back

When you use an inout parameter in Swift, it may seem like the parameter is passed by reference, but it’s actually passed by value and then copied back to the original variable after the function completes.

Example:

func increment(value: inout Int) {
value += 1
}

var i = 0
increment(value: &i)
print(i) // Output: 1

Here, i is passed into the function increment, where it is incremented. The & symbol is used to indicate that i should be passed as an inout parameter, meaning it will be copied back to i after modification.

2. Lvalues and Rvalues

  • Lvalues represent memory locations (e.g., variables, array elements).
  • Rvalues represent values (e.g., literals like 4, expressions like 2 + 2).

Only lvalues can be passed as inout parameters because it wouldn’t make sense to modify a temporary value (an rvalue).

Example:

var array = [0, 1, 2]
increment(value: &array[0])
print(array) // Output: [1, 1, 2]

Here, array[0] is an lvalue, as it represents a specific memory location (the first element of the array). Passing it as an inout parameter works fine.

However, trying to pass an rvalue like 2 + 2 would result in a compile-time error because rvalues cannot be mutated:

increment(value: &(2 + 2)) // Error

3. Properties and inout

Properties can also be passed as inout parameters, as long as they are mutable (i.e., defined with var and not let).

struct Point {
var x: Int
var y: Int
}

var point = Point(x: 0, y: 0)
increment(value: &point.x)
print(point) // Output: Point(x: 1, y: 0)

Here, point.x is a mutable property, so it can be passed as an inout parameter.

However, if a property is read-only (i.e., only has a get method), it cannot be passed as inout.

Example:

extension Point {
var squaredDistance: Int {
return x*x + y*y
}
}

increment(value: &point.squaredDistance) // Error

Here, squaredDistance is read-only, so it cannot be passed as an inout parameter.

4. Operators with inout

Operators can also take inout parameters, but you don’t need to explicitly use the & symbol.

Example:

postfix func ++(x: inout Int) {
x += 1
}

point.x++
print(point) // Output: Point(x: 2, y: 0)

The postfix increment operator (++) directly modifies point.x without needing the & symbol.

5. Nested Functions and inout

You can use inout parameters inside nested functions, but the nested function must be called before the outer function returns, to ensure the inout value is safely copied back.

Example:

func incrementTenTimes(value: inout Int) {
func inc() {
value += 1
}
for _ in 0..<10 {
inc()
}
}

var x = 0
incrementTenTimes(value: &x)
print(x) // Output: 10

Here, the nested inc() function safely modifies the inout parameter value ten times.

However, if you try to return a nested function that captures an inout parameter, it will result in an error, as Swift doesn’t allow inout parameters to escape.

Example:

func escapeIncrement(value: inout Int) -> () -> () {
func inc() {
value += 1
}
return inc // Error: nested function cannot capture inout parameter and escape.
}

This is unsafe because the inout parameter would be copied back before the returned function could modify it.

For the rest of the topics i have dedicated articles for them which you can go to by clicking on the links at the top

--

--

abdul ahad
abdul ahad

Written by abdul ahad

A software developer dreaming to reach the top and also passionate about sports and language learning

No responses yet