Skip to content
academia.sh

Lesson 07 / 24

Functions

Introduces argument labels, default values, multiple returns through a tuple and the `inout` parameter; measures that a default argument expression is evaluated on every call.

Published Updated

In this lesson

  • Use argument labels, default values, tuple returns and `inout`
  • Show that a default argument expression is evaluated on every call
  • Explain that the default expression is never evaluated when an argument is given
  • State that an argument is evaluated before the call even if the body does not read it
Contents

In control flow, conditions were evaluated only when needed. In functions the question goes one step further: when is the argument of a call evaluated, and when is the default value of a parameter? A function in Swift is designed to look like a sentence the caller can read; the measurement in this lesson shows the evaluation order behind that sentence.

Labels, Defaults and Return

In Swift every parameter has an argument label, and it is written at the call. In the definition func move(from start: Int, to end: Int), from and to are the labels the caller sees, while start and end are the names the body uses. If you write an underscore instead of a label, the call has no label: func pad(_ text: String). Adding = value to a parameter defines a default argument; if the caller does not give that parameter, the default expression is used.

A function can return a tuple to return several values: -> (low: Int, high: Int). A labeled tuple is read on the caller’s side as result.low. An inout parameter lets the function change the caller’s variable; the variable is prefixed with & at the call and has to be a var.

// Probe.swift — records when an expression is evaluated
final class Probe {
    let label: String
    private(set) var evaluations = 0
    private(set) var uses = 0
    private var evaluatedBeforeUse = false

    init(_ label: String) {
        self.label = label
    }

    func eval<T>(_ value: T) -> T {
        evaluations += 1
        if uses == 0 { evaluatedBeforeUse = true }
        return value
    }

    func use<T>(_ access: () -> T) -> T {
        uses += 1
        return access()
    }

    var moment: String {
        if evaluations == 0 { return "never" }
        if evaluations > 1 { return "on-every-use" }
        return evaluatedBeforeUse ? "immediately" : "on-first-use"
    }

    func report() {
        print("\(label): \(evaluations) evaluations, \(uses) uses -> \(moment)")
    }
}

func tally(_ probes: [Probe]) {
    for moment in ["immediately", "on-first-use", "on-every-use", "never"] {
        let count = probes.filter { $0.moment == moment }.count
        print("\(moment): \(count)")
    }
}

The measurement block first runs these four features. Then it builds two functions with the same body: pad takes its default width from a Probe when the caller does not give one, while padGiven has the same default but we give the width ourselves on every call. The functions are marked @MainActor, because the default expression reads the top-level Probe, and under the Swift 6 rules a Probe can be read only from the main actor.

// main.swift — label, default, tuple and inout
func move(from start: Int, to end: Int) -> Int { end - start }
func bounds(of values: [Int]) -> (low: Int, high: Int) {
    (values.min() ?? 0, values.max() ?? 0)
}
func increment(_ value: inout Int) { value += 1 }

print(move(from: 2, to: 5))
let found = bounds(of: [3, 1, 2])
print(found.low, found.high)
var total = 0
increment(&total)
increment(&total)
print(total)

let defaultProbe = Probe("default-expression")
let givenProbe = Probe("default-when-given")

@MainActor func pad(_ text: String, width: Int = defaultProbe.eval(6)) -> String {
    String(repeating: "*", count: width) + text
}

@MainActor func padGiven(_ text: String, width: Int = givenProbe.eval(6)) -> String {
    String(repeating: "*", count: width) + text
}

print(defaultProbe.use { pad("a") })
print(defaultProbe.use { pad("b") })
print(givenProbe.use { padGiven("c", width: 2) })
print(givenProbe.use { padGiven("d", width: 2) })

let allProbes = [defaultProbe, givenProbe]
for probe in allProbes { probe.report() }
tally(allProbes)
3
1 3
2
******a
******b
**c
**d
default-expression: 2 evaluations, 2 uses -> on-every-use
default-when-given: 0 evaluations, 2 uses -> never
immediately: 0
on-first-use: 0
on-every-use: 1
never: 1

The move call gave 3, the bounds tuple was read as 1 3, and the two increment calls made total 2. The key lines of the measurement are at the end. default-expression was evaluated twice in two calls: a default expression is not computed once at the function definition and stored, it runs again on every call, and its result is on-every-use. default-when-given showed zero evaluations in two calls, because the caller gave the width itself and the default expression was never reached: never.

LB46. The default argument expression was measured in a @MainActor function and only for a constant integer; the same result for other types or in a nonisolated context is not a claim of this lesson.

LB47. Whether the default expression runs in the caller’s context or the callee’s context is not distinguished in this lesson; what was measured is how many times and when it was evaluated.

Variadic Parameters

If you write three dots after the type of a parameter, the function takes as many arguments as you like at that point: func sumAll(of numbers: Int...) -> Int. Inside the function body numbers is an ordinary [Int] array. At the call you write the arguments separated by commas; giving no argument at all is allowed too, and then the array arrives empty. A function can have more than one variadic parameter; the parameter that comes after the first one has to carry an argument label.

Arguments are evaluated before the call; three arguments mean three expressions. The block below makes two calls that pass each argument through a Probe, and adds one call without arguments.

// Variadic.swift — variadic parameters
func sumAll(of numbers: Int...) -> Int {
    numbers.reduce(0, +)
}
let variadicProbe = Probe("variadic-count")
print(variadicProbe.use {
    sumAll(of: variadicProbe.eval(1), variadicProbe.eval(2), variadicProbe.eval(3))
})
print(variadicProbe.use {
    sumAll(of: variadicProbe.eval(4), variadicProbe.eval(5))
})
print(sumAll())
variadicProbe.report()
6
9
0
variadic-count: 5 evaluations, 2 uses -> on-every-use

The sum of three numbers gave 6, the sum of two numbers 9, and the call without arguments 0. The Probe showed five evaluations: three in the first call and two in the second. Each argument is its own expression and is computed before the call; the function body receives it ready as an array. So the moment reads on-every-use.

A variadic parameter simplifies the caller’s side, because no array has to be built. The number of arguments changes with the call; the measurement shows that this change also changes the number of evaluations.

LB48. The variadic parameter was measured with Int; Swift has no way to spread an array into a variadic parameter; if an array is needed, the parameter is declared as [Int].

A Nested Function Captures Its Surroundings

You can define another function inside a function. The inner function sees the local variables and parameters of the outer function and captures them. If the outer function returns the inner function, the captured variables go on living after the outer function has finished. This is the way to build a counter that carries its own state on every call.

The block below writes a function named makeCounter. The function takes a start value; the inner function advance increases the number by one on every call, passes it through a Probe, and the function returns the inner function. Two independent counters are built: the first is called twice, the second once.

// Counter.swift — a nested function carries state
func makeCounter(start: Int, probe: Probe) -> () -> Int {
    var current = start
    func advance() -> Int {
        current += 1
        return probe.eval(current)
    }
    return advance
}
let nestedProbe = Probe("nested-function")
let first = makeCounter(start: 10, probe: nestedProbe)
let second = makeCounter(start: 100, probe: nestedProbe)
print(nestedProbe.use { first() })
print(nestedProbe.use { first() })
print(nestedProbe.use { second() })
nestedProbe.report()
11
12
101
nested-function: 3 evaluations, 3 uses -> on-every-use

The first counter gave 11 and 12, the second counter 101. Although the two counters came out of the same function, each captured a separate current; while one advanced, the other was not affected. Three calls brought three evaluations, and the moment is on-every-use. Even after makeCounter has finished, current is alive, because the inner function holds it.

This prepares the topic of the next lesson: a nested function is a closure that has been given a name. The capture rules are the same.

LB49. The nested function captured an Int state; cases where the captured value is a class instance are outside this lesson.

inout Writes Back

An inout parameter lets the function change the caller’s variable. The mechanism is “copy, change, write back”: the function takes a copy of the variable, changes it as much as it wants in the body, and when the function ends it writes the result back to the variable in one go. If the variable has an observer (didSet), the observer runs once at the write-back, not at each change in the body. A direct assignment, on the other hand, runs the observer on every line.

The block below does the same job in two ways: one increments twice with a function that has an inout parameter, the other writes the two increments directly. Both variables have a didSet observer, and each observer writes to its own Probe. Both ways are run for two rounds.

// Inout.swift — inout write-back
func addTwice(_ value: inout Int) {
    value += 1
    value += 1
}
let viaCallProbe = Probe("inout-observer")
let directProbe = Probe("direct-observer")
var viaCall = 0 { didSet { _ = viaCallProbe.eval(viaCall) } }
var direct = 0 { didSet { _ = directProbe.eval(direct) } }
_ = viaCallProbe.use { addTwice(&viaCall) }
_ = viaCallProbe.use { addTwice(&viaCall) }
_ = directProbe.use { direct += 1; direct += 1 }
_ = directProbe.use { direct += 1; direct += 1 }
print(viaCall, direct)
viaCallProbe.report()
directProbe.report()
4 4
inout-observer: 2 evaluations, 2 uses -> on-every-use
direct-observer: 4 evaluations, 2 uses -> on-every-use

Both variables became 4, but the observer counts diverged: the inout way showed two evaluations, the direct way four evaluations. addTwice incremented twice in the body; the observer ran at the write-back, once per call. In the direct way each += triggered its own observer. Both lines read on-every-use, but their counts differ.

This difference needs care when inout and an observer come together: there may be intermediate values the observer does not see. If you want to see the intermediate states, write directly; if you want only the result, inout is enough.

LB50. The observer count was measured with a top-level variable; that inout write-back gives the same count on class properties and struct properties is outside this lesson.

Function Type and Function as a Value

Every function has a type: the combination of the parameter types and the return type. The type of the function func triple(_ n: Int) -> Int is written (Int) -> Int. This type is used like an ordinary type: you can assign it to a constant, write it as a parameter type, or return it from another function. Assigning a function to a constant does not call it; it only carries it as a value. A call is made only with parentheses.

The block below assigns the triple function to a constant, prints its type, and passes it as an argument to another function named apply. apply applies the given function to a value. The value to apply comes from a Probe, and apply is called twice.

// FunctionType.swift — function type
func triple(_ n: Int) -> Int { n * 3 }
func apply(_ transform: (Int) -> Int, to value: Int) -> Int {
    transform(value)
}
let typeProbe = Probe("function-type")
let transform = triple
print(type(of: transform))
print(typeProbe.use { apply(transform, to: typeProbe.eval(4)) })
print(typeProbe.use { apply(transform, to: typeProbe.eval(5)) })
typeProbe.report()
@Sendable (Int) -> Int
12
15
function-type: 2 evaluations, 2 uses -> on-every-use

type(of: transform) printed @Sendable (Int) -> Int: besides the parameter and return types, the type carries a @Sendable attribute, because top-level functions can be shared safely in concurrent environments. In reading, the (Int) -> Int part is the decisive one. apply gave 12 and 15 in two calls: 4 * 3 and 5 * 3. The Probe showed two evaluations; in each call the argument was computed once, before apply ran. The transform constant itself was not counted as an evaluation, because assigning a function to a constant does not run it.

When you carry a function as a value, the moment of calling stays with you: when you call it, how many times and with which argument is clear at the call site.

Once you have learned to read a function type, the signatures in the standard library become readable too. The map method of an array expects an (Element) -> T function; you can give it a named function or a closure. The closure you will see in the next lesson is a function value without a name and obeys the same type rules.

The @Sendable attribute at the start of the type can be ignored for now; you will see it with concurrency in later topics. What matters is knowing that a function is carried as a value and does not run until the call parentheses arrive. Assigning the same function to two different variables does not produce two separate copies; both point to the same function.

What is computed before the call is the argument, not the function value; the two evaluations of the measurement confirm this.

LB51. The function type was measured with a function that has one parameter; functions with several parameters and functions that return functions are outside this lesson.

The Limiting Measurement

A default value comes into play when the caller does not give one. When the caller gives an argument, the opposite holds: that expression is evaluated before the call, whether the function body reads it or not. This is an ordinary rule for the language, but before the measurement shows it, an intuition lingers: the argument of a parameter that is not read seems never to be evaluated. The body below never reads its parameter, and the helper function marked shows with print when the argument ran.

// main.swift — the argument is evaluated before the body
func marked(_ n: Int) -> Int {
    print("argument \(n)")
    return n
}

func ignore(_ value: Int) -> String {
    print("body ran")
    return "did not read"
}

let argumentProbe = Probe("argument-expression")
_ = argumentProbe.use { ignore(argumentProbe.eval(marked(7))) }
_ = argumentProbe.use { ignore(argumentProbe.eval(marked(8))) }

let allProbes = [argumentProbe]
for probe in allProbes { probe.report() }
tally(allProbes)
argument 7
body ran
argument 8
body ran
argument-expression: 2 evaluations, 2 uses -> on-every-use
immediately: 0
on-first-use: 0
on-every-use: 1
never: 0

The line argument 7 was printed before the line body ran, even though the body never used the value parameter. argument-expression was evaluated twice in two calls: the argument is computed every time, before the function is called. The difference from the default expression is this: a default expression runs only when no argument is given, while a given argument always runs.

LB52. The fact that the ignore body does not read the parameter does not measure whether the compiler eliminates the argument expression; the argument 7 line in the output is the only evidence of this measurement and holds only for this construct.

Summary

  • The argument label makes the call readable; the _ label removes it, and = value defines a default argument.
  • A tuple returns several values; an inout parameter changes the caller’s var variable through &.
  • The default argument expression is evaluated on every call in which no argument is given.
  • If an argument is given, the default expression is never evaluated; a given argument is evaluated before the call even if the body does not read it.
  • The moment of this lesson: on every use (the default argument expression).

Next Step

A function can take another function as a parameter, and you can write it as an expression without a name. When does the body of this expression run, and at which moment does it see the variables around it? The next lesson looks at closures.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close