Adding @resultBuilder
in Swift 5.4 was important, but you might have missed it. It’s the secret engine behind the easy syntax you use to describe a view’s layout: @ViewBuilder
. If you’ve ever wondered whether you could create custom syntax like that in your projects, the answer is yes! Even better, you’ll be amazed at how straightforward it is.
In this tutorial, you’ll learn:
- Swift syntax for creating a result builder
- Tips for planning your result builder
- How to use a result builder to create a mini-language
Note: This beginner-level tutorial assumes you’re comfortable building an iOS app using Xcode and Swift, familiar with the Swift type system and have a good understanding of SwiftUI.
Getting Started
Download the starter project by clicking the Download Materials button at the top or bottom of this tutorial. Open the starter project.
Introducing Decoder Ring
Agent: Your mission, should you choose to accept it, is to complete the Decoder Ring app. Although you have top-secret code experts at your disposal to design the best ciphers, they would prefer not to spend much time implementing them in Swift. Can you design a Domain Specific Language that allows them to concentrate on cipher implementation and not be bothered with that Swift intricacies? Of course, you can!
Note: A Domain Specific Language (DSL) is a programming language specifically tailored for a particular purpose (or domain). This stands in contrast to a general-purpose language like Swift, which can be used for various software purposes.
If you build and run Decoder Ring, you will find a simple app with a single screen.
The top field is a text entry field where an agent can type a message to be enciphered, which is then displayed in the bottom field. By switching the mode from Encode to Decode, the agent can instead paste an enciphered message into the top field to be deciphered in the bottom field. Currently, the app lacks enciphering/deciphering functionality.
It’s time to get cracking!
Making Your First Result Builder
To understand how result builders function, it’s best to dive right in. Create a file named CipherBuilder.swift. Add the following code:
// 1
@resultBuilder
// 2
enum CipherBuilder {
// 3
static func buildBlock(_ components: String...) -> String {
components
.joined(separator: " ")
.replacingOccurrences(of: "e", with: "🥚")
}
}
- You start with the
@resultBuilder
attribute, used to specify that the following definition is a result builder.@resultBuilder
can annotate any type that allows a static method. - You’ve used an
enum
becauseCipherBuilder
doesn’t need to have instances created. Instead, it only containsstatic
methods. - You implement a static
buildBlock(_:)
function. This is the only requirement for a result builder. Your function takes any number ofString
arguments and returns aString
containing all the arguments joined with a space and all instances of the lettere
replaced with the egg emoji: 🥚.
The agency’s eggheads have called this the Egg Cipher. Next, you need to use your new result builder somewhere in the app. Open ContentView.swift and add the following at the end of the file:
// 1
@CipherBuilder
// 2
func buildEggCipherMessage() -> String {
// 3
"A secret report within the guild."
"4 planets have come to our attention"
"regarding a plot that could jeopardize spice production."
}
- Now, you can use
CipherBuilder
to annotate your code. You specify thatbuildEggCipherMessage()
is a result builder implemented inCipherBuilder
. - Your method returns a
String
, matching the return type of your result builder. - Inside your method, you list several strings matching the expected argument type
String...
in your result builder.
To show the output in the view body
, add a modifier to the end of the ZStack
:
.onAppear {
secret = buildEggCipherMessage()
}
This code calls your result builder and set the output label to the returned value. Build and run to see the result.
As expected, the three strings are joined, and each instance of “e” is replaced with an egg.
Understanding Result Builders
It’s worth exploring what’s going on here. You’re simply listing strings in the body of buildEggCipherMessage()
. There are no commas, and it’s not an array. So how does it work?
The compiler rewrites the body of your buildEggCipherMessage()
according to the rules you’ve defined in CipherBuilder
. So when Xcode compiles this code:
{
"A secret report within the guild."
"4 planets have come to our attention"
"regarding a plot that could jeapardize spice production."
}
You can imagine it becomes something like this:
return CipherBuilder.buildBlock(
"A secret report within the guild.",
"4 planets have come to our attention",
"regarding a plot that could jeapardize spice production."
)
As you expand your knowledge of result builders, imagining what the compiler translates your code to will help you understand what’s happening. As you’ll see, all kinds of programming logic can be supported using result builders, including loops and if-else statements. It’s all rewritten auto-magically to call your result builder’s foundational static function.
Result builders have been in Swift since 5.1 under different guises. With the arrival of SwiftUI, before result builders were officially part of the Swift language, they existed as a proposed feature called
@_functionBuilder
. This was the first implementation from Apple that powered the @ViewBuilder
syntax of SwiftUI. Initially, the expected official name was @functionBuilder
. However, after revising the proposal (SE-0289), that name became @resultBuilder
. Be aware that you might find references to @functionBuilder
or even @_functionBuilder
in blogs and other resources.Planning Your Cipher Builder
Now, the Egg Cipher isn’t exactly uncrackable. Back to the drawing board!
Any effective cipher will have steps, or cipher rules, to perform. Each rule applies an operation on the text and provides a new result. Taking the secret message as plain text, the cipher performs each rule sequentially until it yields the final enciphered text.
For your cipher, each rule will take a String
input, modify it in some way and output a String
result that’s passed to the following rule. Eventually, the last rule will output the final text. The deciphering process will be the same except in reverse. Your CipherBuilder
will need to support any number of rules and, preferably, share rules across cipher definitions so you can test different combinations of ciphers.
As you’ll see, the amount of code you need to implement the result builder is quite small. Most of your time goes toward planning the types you’ll need for your DSL to make sense and be practical.
Defining a Cipher Rule
First, you need to define what a cipher rule is. Create a file called CipherRule.swift and add:
protocol CipherRule {
func encipher(_ value: String) -> String
func decipher(_ value: String) -> String
}
There will be multiple rule types, so you’ve wisely opted for a protocol. Both encipher(_:)
and decipher(_:)
take a String
and output a String
. When enciphering a message, the plain text passes through each rule’s encipher(_:)
function to produce the cipher text; when deciphering, the cipher text passes through each rule’s decipher(_:)
function to produce the plain text.
Open CipherBuilder.swift. Update buildBlock(_:)
to use CipherRule
as its type.
static func buildBlock(_ components: CipherRule...) -> CipherRule {
components
}
Because your agent training has raised your powers of observation well above average, you’ll have noticed a problem: How can a varying number of CipherRule
arguments be output as a single CipherRule
? Can an array of CipherRule
elements also be a CipherRule
, you ask? Excellent idea; make it so!
Add the following extension below the CipherRule
protocol:
// 1
extension Array: CipherRule where Element == CipherRule {
// 2
func encipher(_ value: String) -> String {
// 3
reduce(value) { encipheredMessage, secret in
secret.encipher(encipheredMessage)
}
}
func decipher(_ value: String) -> String {
// 4
reversed().reduce(value) { decipheredMessage, secret in
secret.decipher(decipheredMessage)
}
}
}
- You extend
Array
by implementingCipherRule
when theElement
is also aCipherRule
. - You fulfill the
CipherRule
definition by implementingencipher(_:)
anddecipher(_:)
. - You use
reduce(_:_:)
to pass the cumulativevalue
through each element, returning the result ofencipher(_:)
. - You reverse the order and use
reduce(_:_:)
again, this time callingdecipher(_:)
.
This code is the core of any cipher in Decoder Ring and implements the plan in the previous diagram.
Do not worry about the compiler error, you will resolve it in the Building a Cipher section.
Writing the Rules
It’s time to write your first rule: The LetterSubstitution
rule. This rule will take a string and substitute each letter with another letter based on an offset value. For example, if the offset was three, then the letter “a” is replaced by “d”, “b” is replaced by “e”, “c” with “f” and so on…
Create a file called LetterSubstitution.swift and add:
struct LetterSubstitution: CipherRule {
let letters: [String]
let offset: Int
// 1
init(offset: Int) {
self.letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".map(String.init)
self.offset = max(1, min(offset, 25))
}
// 2
func swapLetters(_ value: String, offset: Int) -> String {
// 3
let plainText = value.map(String.init)
// 4
return plainText.reduce("") { message, letter in
if let index = letters.firstIndex(of: letter.uppercased()) {
let cipherOffset = (index + offset) % 26
let cipherIndex = cipherOffset < 0 ? 26
+ cipherOffset : cipherOffset
let cipherLetter = letters[cipherIndex]
return message + cipherLetter
} else {
return message + letter
}
}
}
}
- Your initializer creates an array of all the upper-case letters and checks that the
offset
is between 1 and 25. - You implement the core logic of the rule in
swapLetters(_:offset:)
. - You create an array of all the letters in the message and assign it to the
plainText
variable. - You loop through each letter in
plainText
and build a result using the appropriate substitute letter determined by theoffset
. Of course, you’re careful to check that the offset of the substitute is valid.
Next, you must add the CipherRule
functions needed to fulfill the protocol. Add the following above swapLetters(_:offset:)
:
func encipher(_ value: String) -> String {
swapLetters(value, offset: offset)
}
func decipher(_ value: String) -> String {
swapLetters(value, offset: -offset)
}
Both required functions call swapLetters(_:offset:)
. Notice that decipher(_:)
passes in the negative offset to reverse the enciphered letters.
That’s your first rule. Well done, Agent.
Building a Cipher
Now, it’s time to put your CipherBuilder
to the test. The eggheads at HQ have an idea for something they call the Super-secret-non-egg-related-so-really-uncrackable Cipher. That’s quite the mouthful, so how about just creating a file called SuperSecretCipher.swift and adding the following:
struct SuperSecretCipher {
let offset: Int
@CipherBuilder
var cipherRule: CipherRule {
LetterSubstitution(offset: offset)
}
}
SuperSecretCipher
has an Int
property for the letter offset
plus a special property: cipherRule
. cipherRule
is special because you’ve added the @CipherBuilder
annotation, just like you did for buildEggCipherMessage()
. This means cipherRule
is now a result builder. Inside the body of the result builder, you use your new LetterSubstitution
rule and the offset
value.
Open ContentView.swift. Remove onAppear(perform:)
and buildEggCipherMessage()
.
Replace the body of processMessage(_:)
with the following:
let cipher = SuperSecretCipher(offset: 7)
switch secretMode {
case .encode:
return cipher.cipherRule.encipher(value)
case .decode:
return cipher.cipherRule.decipher(value)
}
processMessage(_:)
is called whenever the message text changes or the switch is toggled. SuperSecretCipher
has an offset
of 7
, but that’s configurable and ultimately up to the eggheads. If the mode is .encipher
, it calls encipher(_:)
on cipherRule
. Otherwise, it calls decipher(_:)
.
Build and run to see the result of all your hard work.
Remember to try the decipher mode.
Expanding Syntax Support
Those eggheads from HQ have reviewed your work and requested changes (of course, they have). They’ve requested you allow them to specify how many times to perform the substitution, so it’s “doubly, no Triply, no QUADRUPLY uncrackable”. Maybe they’ve cracked under the strain! :]
Hop to it, Agent. You might be wondering, given your thoughtful implementation…is it even that hard?
Open SuperSecretCipher.swift. Add the following property to SuperSecretCipher
:
let cycles: Int
Replace `cipherRule` with the following:
Now, this is where things start to get even more interesting. Update the body of cipherBuilder
like so:
for _ in 1...cycles {
LetterSubstitution(offset: offset)
}
Open ContentView.swift. In ContentView, update processMessage(_:)
with the new argument. Replace:
let cipher = SuperSecretCipher(offset: 7)
With:
let cipher = SuperSecretCipher(offset: 7, cycles: 3)
If you build, you see a new error:
Not a problem. Open CipherBuilder.swift.
If you’re feeling lucky, try that Fix button. Otherwise, add the following method to CipherBuilder
:
static func buildArray(_ components: [CipherRule]) -> CipherRule {
components
}
This is another one of those special static functions you can add to any result builder. Because you’ve planned and ensured that any array of CipherRule
s is also a CipherRule
, your implementation of this method is to simply return components
. Well done, you!
Build and run. Your app should triple-encipher the message:
Brilliant!
Understanding Result Builder Loops
How does that loop work? Add a breakpoint inside both result builder functions (by clicking the line numbers). Build and run.
When you type a letter, you can see each step. Each time execution stops, click the continue button to jump to the next breakpoint until it’s finished.
You’ll find that the compiler hits the buildBlock
three times, the buildArray
once, and then the buildBlock
one last time. You can imagine the compiler creating something like this:
// 1
let rule1: CipherRule = CipherBuilder.buildBlock(
LetterSubstitution(offset: 7)
)
let rule2: CipherRule = CipherBuilder.buildBlock(
LetterSubstitution(offset: 7)
)
let rule3: CipherRule = CipherBuilder.buildBlock(
LetterSubstitution(offset: 7)
)
// 2
let rule4: CipherRule = CipherBuilder.buildArray(
[rule1, rule2, rule3]
)
- This is where you loop three times. The result builder calls
buildBlock(_:)
each time to output a single rule. In this case, the rule is an instance ofLetterSubstitution
. - The result builder assembles these three rules into a single array and calls
buildArray(_:)
. Once again, the result is output as a single rule. - Finally, the result builder calls
buildBlock(_:)
again to return that rule as the result.
You’ll never see this code anywhere, but imagining what’s happening internally when you plan a result builder is helpful. It’s all in the planning and your use of CipherRule
as the primary type that’s paid off handsomely. Nice work, Agent.
Adding Support for Optional Values
Okay…so now those eggheads are scrambling to produce an even stronger cipher. They feel it’s unwise to allow official terminology to be output in the cipher text. So they would like to optionally supply a dictionary of official terms and an obfuscated replacement. Like swapping “brains” for “Swiss cheese”, you muse.
It’s time for another CipherRule
!
Create a file called ReplaceVocabulary.swift and add:
struct ReplaceVocabulary: CipherRule {
// 1
let terms: [(original: String, replacement: String)]
func encipher(_ value: String) -> String {
// 2
terms.reduce(value) { encipheredMessage, term in
encipheredMessage.replacingOccurrences(
of: term.original,
with: term.replacement,
options: .caseInsensitive
)
}
}
func decipher(_ value: String) -> String {
// 3
terms.reduce(value) { decipheredMessage, term in
decipheredMessage.replacingOccurrences(
of: term.replacement,
with: term.original,
options: .caseInsensitive
)
}
}
}
-
terms
is an array of tuples with twoString
s each, matching the original term with its replacement. - In
encipher(_:)
, you loop through the array and perform the replacements in a case-insensitive manner. -
decipher(_:)
does the same but swaps all the replacements with originals.
Open SuperSecretCipher.swift. Add this property to let the eggheads control the optionality:
let useVocabularyReplacement: Bool
It’s a simple Bool
that you now need to use in cipherRule
. Add the following before the cycles
loop:
if useVocabularyReplacement {
ReplaceVocabulary(terms: [
("SECRET", "CHOCOLATE"),
("MESSAGE", "MESS"),
("PROTOCOL", "LEMON GELATO"),
("DOOMSDAY", "BLUEBERRY PIE")
])
}
The idea is that, for a message such as “the doomsday protocol is initiated”, your cipher will first replace it with “the BLUEBERRY PIE LEMON GELATO is initiated” before the letter substitution occurs. This will surely confound enemy spies!
If you build and run the app, you see a familiar build error:
This time, open CipherBuilder.swift. Add the following method to CipherBuilder
:
static func buildOptional(_ component: CipherRule?) -> CipherRule {
component ?? []
}
This is how result builders handle optionality, such as an if
statement. This one calls buildOptional(_:)
with a CipherRule
or nil
, depending on the condition.
How can the fallback value for CipherRule
be []
? This is where you take advantage of the Swift type system. Because you extended Array
to be a CipherRule
when the element type is CipherRule
, you can return an empty array when component
is nil
. You could expand that function body to express these types explicitly:
let fallback: [CipherRule] = .init(arrayLiteral: [])
return component ?? fallback
But you’re in the business of allowing the compiler to just do its thing. :]
In your result builder’s design, that empty array will not affect the result, which is precisely what you’re looking for in the if useVocabularyReplacement
expression. Pretty smart, Agent. That’s the sort of on-your-feet thinking that’ll get HQ’s attention…and maybe that promotion?
Open ContentView.swift. Update cipher
inside processMessage(_:)
to take in the new useVocabularyReplacement
parameter:
let cipher = SuperSecretCipher(
offset: 7,
cycles: 3,
useVocabularyReplacement: true
)
Build and run to see how your SuperSecretCipher
performs.
Perfect! The eggheads are finally satisfied, and your presence is required at HQ. On to the next mission, Agent, and remember that result builders are at your disposal.
Where to Go From Here?
You’ve only begun to explore the possibilities of result builders. You can find information about additional capabilities in the documentation:
For inspiration, you might want to check out Awesome result builders, a collection of result builders you can find on GitHub.
If you’re looking for an extra challenge, try implementing support for if { ... } else { ... }
statements and other result builder logic. Or check out this list of historical ciphers at Practical Cryptography and pick one to form a new CipherRule
. You’ll find a couple of familiar entries in that list. :]
I hope you enjoyed this tutorial on result builders. If you have any questions or comments, please join the forum discussion below.