Deep Dive into Functions and Closures
To understand functions and closures in Swift, you really need to understand the following topics:
- Functions as First-Class Citizens
- Functions Capturing Variables (Closures)
- Closure Expressions in Swift
- Autoclosures in Swift
- Escaping Closures in Swift
- Delegation and Callbacks
- Understanding In-Out Parameters in Swift
- Subscripts in Swift
- 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:
- 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. - 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. - Trailing Closure Syntax: If the closure is the last argument in a function, you can move it outside the parentheses for cleaner syntax.
- Shorthand names: Swift automatically provides shorthand names for the arguments to the function — $0 for the first, $1 for the second, etc.
- 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
implementsAlertViewDelegate
and sets itself as the delegate for anAlertView
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, thelogger
struct is copied. - Mutating the copied struct inside the delegate method (
buttonTapped
) does not affect the originallogger
.
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 anInt
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 originallogger
.
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 capturingself
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 like2 + 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.