Lesson 23 / 24
Result Builders
Writes a result builder of its own and measures that expressions in an unchosen if or else branch are never evaluated, that a for loop evaluates every element, and that the branch is chosen at run time and not at compile time.
Published Updated
In this lesson
- Writes a result builder that builds a list of strings with buildBlock and buildExpression
- Measures that expressions in an unchosen if or else branch are never evaluated
- Shows that a for loop evaluates every element on every call through buildArray
- Tells apart that the choice of branch is made at run time and not at compile time
Contents
The previous lesson moved the value rule of a property into a separate type. A result builder (@resultBuilder) applies the same idea to a list of expressions. Expressions written one under another in the body of a closure normally run one by one and their values are discarded. In a closure passed to a parameter marked with a result builder, each line is instead given to a method of the builder type, and the values those methods return are combined into a single result. Which method each line is bound to, and how, decides at which moment the line runs.
This lesson writes a small builder that builds a list of strings and measures three things: whether an expression in an unchosen if branch is evaluated, how a for loop processes its elements, and when the choice of branch is made. The transformation itself happens at compile time and is not visible at run time; what is measured is the run-time behavior of the transformed code. This split between compile time and run time was covered separately in an earlier curriculum.
// 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)")
}
}
// ListBuilder.swift — a result builder that builds a list of strings
@resultBuilder
struct ListBuilder {
static func buildExpression(_ line: String) -> [String] {
[line]
}
static func buildBlock(_ parts: [String]...) -> [String] {
parts.flatMap { $0 }
}
static func buildOptional(_ part: [String]?) -> [String] {
part ?? []
}
static func buildEither(first part: [String]) -> [String] {
part
}
static func buildEither(second part: [String]) -> [String] {
part
}
static func buildArray(_ parts: [[String]]) -> [String] {
parts.flatMap { $0 }
}
}
func makeList(@ListBuilder _ content: () -> [String]) -> [String] {
content()
}
Writing the Builder
ListBuilder carries six static methods. buildExpression turns each single expression into a list of strings. buildBlock joins the parts of a block one after another. buildOptional handles the result of an if that has no else branch; if the branch is not chosen, it gives an empty list. buildEither(first:) and buildEither(second:) handle whichever of the if and else branches was chosen. buildArray joins the parts that come from each iteration of a for loop. makeList is a helper function that takes its closure as a parameter marked with ListBuilder.
The Unchosen Branch Is Not Evaluated
The first measurement writes two expressions inside if/else. One calls a Probe named chosen, the other a Probe named skipped. The condition is always true, so only the first branch is chosen. The render function is called twice.
// main.swift — the expression in the unchosen branch is not evaluated
let chosen = Probe("chosen-branch")
let skipped = Probe("skipped-branch")
do {
let useFirst = true
func render() -> [String] {
makeList {
"title"
if useFirst {
chosen.eval("first")
} else {
skipped.eval("second")
}
}
}
for _ in 1...2 {
let lines = chosen.use { skipped.use { render() } }
print(lines.joined(separator: ","))
}
}
chosen.report()
skipped.report()
title,first title,first chosen-branch: 2 evaluations, 2 uses -> on-every-use skipped-branch: 0 evaluations, 2 uses -> never
In both calls the list is title,first. chosen-branch records two evaluations, because the body of the builder runs again on every render call. skipped-branch sees two uses but zero evaluations: the expression of the second branch never runs. The rule says never. This lesson’s moment is never: the expression of an unchosen branch is not evaluated at any moment. Merely being written does not call the expression.
The Loop Evaluates Every Element
A for loop is combined with buildArray, and the expression in its body is evaluated once for each element. The measurement processes an array of three elements twice and adds to the same closure a line that contains an if; that line goes through the buildOptional path.
// main.swift — for evaluates every element
let items = Probe("loop-item")
do {
func render(names: [String]) -> [String] {
makeList {
for name in names {
items.eval(name)
}
if names.count > 2 {
"long list"
}
}
}
for _ in 1...2 {
let lines = items.use { render(names: ["a", "b", "c"]) }
print(lines.joined(separator: ","))
}
}
items.report()
a,b,c,long list a,b,c,long list loop-item: 6 evaluations, 2 uses -> on-every-use
On every call the list is a,b,c,long list. loop-item records six evaluations: three elements in each of two calls. The elements were not cached once; the body of the builder runs again from the start on every call, and the loop processes every element again. Its moment is on-every-use. The string of the line if names.count > 2 did not pass through a Probe; it only appeared in the list.
Expression Conversion
buildExpression converts each single expression of the block into the part type of the builder. If more than one buildExpression is written under the same name, the compiler picks the one that matches the type of the expression. The MixedBuilder below accepts both String and Int; it turns numbers into a string of the form number 42. The measurement passes the two expressions through separate Probe objects and looks at how many times each expression is evaluated on every call.
// MixedBuilder.swift — a result builder that accepts text and number expressions
@resultBuilder
struct MixedBuilder {
static func buildExpression(_ line: String) -> [String] {
[line]
}
static func buildExpression(_ number: Int) -> [String] {
["number \(number)"]
}
static func buildBlock(_ parts: [String]...) -> [String] {
parts.flatMap { $0 }
}
}
func makeMixed(@MixedBuilder _ content: () -> [String]) -> [String] {
content()
}
// main.swift — buildExpression converts the expression, it does not re-evaluate it
let texts = Probe("text-expression")
let numbers = Probe("number-expression")
do {
for _ in 1...2 {
let lines = texts.use {
numbers.use {
makeMixed {
texts.eval("title")
numbers.eval(42)
}
}
}
print(lines.joined(separator: ","))
}
}
texts.report()
numbers.report()
title,number 42 title,number 42 text-expression: 2 evaluations, 2 uses -> on-every-use number-expression: 2 evaluations, 2 uses -> on-every-use
The output is title,number 42 in both calls: the string expression passed as it was, and the integer expression was converted into the form number 42. Which path did the conversion is clear from the type of the expression; no separation was written in the builder. The counters show that the conversion did not change the evaluation. text-expression and number-expression record two evaluations each across the two calls and say on-every-use. buildExpression takes the result of the expression; it does not run the expression again. The builder runs the body of the closure from the start once per call, and each expression is evaluated exactly once. So when writing a conversion there is no need to worry that an expression with side effects will be repeated. Writing more than one buildExpression under the same name widens the expression types the builder accepts; an expression whose type is not covered gives a compile error, and it is not silently skipped at run time.
Nested Conditions
An if inside an if opens two nested buildOptional paths in the builder. If the outer condition does not hold, the inner condition itself is not evaluated either. The measurement runs the same closure twice with visible: false, then twice with visible: true. In each case there is a separate Probe for the inner condition and for the expression of the inner line.
// main.swift — if the outer condition does not hold, the inner condition is not evaluated
let closedGate = Probe("closed-inner-condition")
let closedHit = Probe("closed-inner-line")
let openGate = Probe("open-inner-condition")
let openHit = Probe("open-inner-line")
do {
func render(visible: Bool, gate: Probe, hit: Probe) -> [String] {
makeList {
if visible {
if gate.eval(true) {
hit.eval("inner")
}
}
}
}
for _ in 1...2 {
let shut = closedHit.use { closedGate.use { render(visible: false, gate: closedGate, hit: closedHit) } }
let shown = openHit.use { openGate.use { render(visible: true, gate: openGate, hit: openHit) } }
print("closed \(shut.count), open \(shown.count)")
}
}
for probe in [closedGate, closedHit, openGate, openHit] {
probe.report()
}
closed 0, open 1 closed 0, open 1 closed-inner-condition: 0 evaluations, 2 uses -> never closed-inner-line: 0 evaluations, 2 uses -> never open-inner-condition: 2 evaluations, 2 uses -> on-every-use open-inner-line: 2 evaluations, 2 uses -> on-every-use
When the outer condition is false, neither the inner condition nor the inner line is evaluated. closed-inner-condition and closed-inner-line record zero evaluations against two uses each and say never; the closed 0 line in the output also shows that the list stayed empty. When the outer condition is true, both inner expressions are evaluated once on every call: open-inner-condition and open-inner-line record two evaluations each and say on-every-use. The open 1 line confirms that the inner line entered the list. The order of the conditions behaves like short-circuiting here as well: the next expression is evaluated only if the path of the previous one is open. The difference is that this comes not from the && operator but from the buildOptional path of the builder. Each if produces its own buildOptional call; if the part of the outer call is empty, the inner one is never reached, and the inner expression stays never for that reason.
The Empty Loop
buildArray takes the parts that come from each iteration of the loop. If the loop never runs, the list of parts is empty and buildArray produces an empty list. The question is whether the expression in the body of a loop built over an empty array is evaluated. The measurement runs the same closure once with an empty array and once with a filled one.
// main.swift — the loop body over an empty array is not evaluated
let emptyLoop = Probe("empty-loop")
let filledLoop = Probe("filled-loop")
do {
func render(names: [String], probe: Probe) -> [String] {
makeList {
for name in names {
probe.eval(name)
}
}
}
for _ in 1...2 {
let none = emptyLoop.use { render(names: [], probe: emptyLoop) }
let some = filledLoop.use { render(names: ["x", "y"], probe: filledLoop) }
print("empty \(none.count), filled \(some.count)")
}
}
emptyLoop.report()
filledLoop.report()
empty 0, filled 2 empty 0, filled 2 empty-loop: 0 evaluations, 2 uses -> never filled-loop: 4 evaluations, 2 uses -> on-every-use
In the loop over the empty array, the expression of the body never runs: empty-loop records zero evaluations against two uses and says never. The empty 0 line says the result is an empty list as well; buildArray turns an empty list of parts into an empty result and gives no error. In the filled array each call processes two elements, so filled-loop records four evaluations over two calls and says on-every-use. The number of evaluations equals the number of iterations of the loop. For someone who builds a loop in the body of a builder, this is a measure: a loop that processes elements with an expression that has side effects runs as many times as there are elements on every call. The collection is not kept between calls; if the repetition of the side effect is not needed, the loop is moved outside the builder and the result is given to the builder ready-made.
The shared lesson of these measurements is this: the builder turns each line of the body into a call, and the way it is written decides which call is made. Instead of predicting by reading whether an expression will be evaluated, the transformation has to be unfolded in the mind: an expression becomes a buildExpression call, a condition becomes a buildOptional call (buildEither if there is an else), and a loop becomes a buildArray call. Before putting an expression with side effects into the body, the question is asked how many times and under which condition it will run in this chain of calls.
The Bounding Measurement
The transformation is done at compile time, but which branch is chosen is decided at run time. In the previous measurements the condition was fixed and this difference was not visible. The last measurement turns the condition into a parameter and calls render in turn with true, false and true. The expression of each branch is written to a separate Probe.
// main.swift — the branch is chosen at run time
let left = Probe("left-branch")
let right = Probe("right-branch")
do {
func render(first: Bool) -> [String] {
makeList {
if first {
left.eval("left")
} else {
right.eval("right")
}
}
}
for flag in [true, false, true] {
let lines = left.use { right.use { render(first: flag) } }
print(lines.joined(separator: ","))
}
}
left.report()
right.report()
left right left left-branch: 2 evaluations, 3 uses -> on-every-use right-branch: 1 evaluations, 3 uses -> on-first-use
The calls write left, right, left in turn. left-branch records two evaluations: it runs only in the two calls that come with true. right-branch records a single evaluation, and because this evaluation happened in the second call, after the first use, the rule says on-first-use. An expression is evaluated in the call in which its branch is chosen; being written in the code means nothing on its own. For this reason the never moment holds only when the branch is never chosen, and it depends on the condition.
MA5. The measured builder only builds a list of strings, and extra methods such as buildLimitedAvailability were not defined. That the same rule holds for if/else if chains with two or three branches was not measured. Using a single builder type is a design choice.
Summary
- A result builder combines the expressions in a closure body into a result with the methods
buildBlock,buildExpression,buildOptional,buildEitherandbuildArray. - An expression in an unchosen branch of
if/elseis never evaluated; being written does not call it. - The body of the builder runs again from the start on every call, and a
forloop evaluates every element on every call. - The transformation is done at compile time, but which branch is chosen is decided at run time; the
nevermoment holds only if the branch is never chosen. - This lesson’s moment: never. The expression of an unchosen branch is not evaluated at any moment.
Next Step
The first lessons of this topic measured memory and evaluation moments in code that runs in a single flow. New questions appear when several jobs touch the same memory at the same time: whose control a value is under, and who reaches it and when. The next lesson covers actors, Sendable, which stands for a type that can be passed across boundaries, and @MainActor; it measures how the lazy property of an actor behaves on first access, and the work started with async let.
To keep your progress and take notes, Log in
My notes
Log in to take notes.