Published Aug 09. 2024 | Marcel Kulina

Crafting Elegant Swift Code: Beyond the Basics

How to create Swift code that is aesthetically pleasing and, more importantly, self-documenting.

Swift, renowned for its clarity and expressiveness, offers a plethora of features to write code that's not only functional but also aesthetically pleasing. In this article, we delve into the art of crafting elegant Swift code, exploring techniques that elevate your code from good to great.

Beginner Level

Argument Labels: Enhancing Clarity

Swift's argument labels are more than mere gimmicks. They serve a crucial role in making your function calls self-documenting. Consider this example:

func count(fruitName: String) {
    print(3)
}

// Get the count
count(fruitName: "Apple")

While the function name clearly states that we get the count for a certain fruit, we can make it even more expressive:

func count(of: fruitName: String) {
    print(3)
}

count(of: "Apple")

This looks way better on the calling side and makes the code more readable. Additionally, the of makes it very clear we need the count of a fruit.

Moving Part of a Function Name into the First Label

Argument labels also allow us to make the names of functions shorter and more concise. Consider this example:

calculatePriceFor(item: String) {
    print(3.99)
}

calculatePriceFor(item: "Chocolate")

The function name is very lengthy and verbose. But we can utilise argument labels again here:

calculatePrice(for item: String) {
    print(3.99)
}

calculatePrice(for "Chocolate")

Now the code is more readable and self-documenting. It's very clear that we want to get the price for an item.

If-Else as an expression

Swift allows If-Else to be used as an expression, further reducing boilerplate code and making the goal of the code more explicit. Look at this example:

var user: User? = nil

if isNight {
    user = nil
} else {
    user = User("John Doe")
}

Here we check if it is night. If so, the user should not be there as they should be asleep.

But this is very verbose. Of course, we could use the ternary operator ?:, but for more complex scenarios it becomes very hard to read. Instead, we can utilise the same code as above, but as an expression:

var user = if isNight {
    nil
} else {
    User("John Doe")
}

Now, this is a lot better isn't it?

Implicit unwrapping in if and guard

Swift no longer requires writing both the name of the new variable that is unwrapped and the old one. Instead we can shorten if and guard. Again, have a look at this example:

var user = ...

guard let user = user else {
    fatalError("User missing")
}

While this works, we can make the code more readable here as well:

var user = ...

guard let user else {
    fatalError("User Missing")
}

warning

If you don't want to shadow the optional value you still need to unwrap the old way in guard and if

Default Struct Inits

Swift automatically creates the init for structs when they do not have a custom init and all of its properties have a default value. This can reduce the code needed to create simple structs drastically. Look at this code:

struct User {
    let name: String
    let age: Int

    init(name: String, age: Int) {
        self.name = name
        self.age = age
    }
}

var user = User(name: "John Doe", age: 30)

It is exactly the same as this one:

struct User {
    let name: String
    let age: Int
}

var user = User(name: "John Doe", age: 30)

tip

It's generally good practice to keep structs POD. This way, the compiler is often able to create the init. If it is not, sometimes it is enough to have an empty custom init for class types held by the struct so that Swift can set its default value.

Advanced Level

Custom Sequences

Instead of holding arrays and the logic for it in some manager class, sometimes it makes sense to create a custom Sequence. Let's assume we want to get a fibonacci sequence. In its most basic implementation, we could do it like this:

func fibonacci(upTo limit: Int) -> [Int] {
    var sequence: [Int] = [0, 1]
    while sequence.last! < limit {
        let next = sequence[sequence.count - 1] + sequence[sequence.count - 2]
        sequence.append(next)
    }
    return sequence.filter { $0 <= limit }
}

let fibNumbers = fibonacci(upTo: 100)
for number in fibNumbers {
    print(number)
}

This works, but it has some limitations:

  • Eager Evaluation: The entire sequence is generated upfront, even if you only need the first few elements.
  • Limited Flexibility: It's tied to generating the sequence up to a limit. Iterating over an infinite Fibonacci sequence isn't directly possible, and even if we only want to go very high, the memory consumption will be massive.

Now let's have a look at a custom Sequence instead:

struct FibonacciSequence: Sequence {
    func makeIterator() -> FibonacciIterator {
        return FibonacciIterator()
    }
}

struct FibonacciIterator: IteratorProtocol {
    private var current = 0
    private var next = 1

    mutating func next() -> Int? {
        let result = current
        (current, next) = (next, current + next)
        return result
    }
}

// Usage
for number in FibonacciSequence() {
    if number > 100 { break } // Stop when needed
    print(number)
}

While we had to write a little more code now, we have a couple of benefits:

  • The Code is reusable and the name of the type already self-documents what it is doing.
  • We can have an infinite sequence.
  • Memory consumption stays the same, no matter how far we go.
  • Automatically allows us to use swift's for-in, map, and filter functionalities.

To recap, a custom sequence makes sense under the following circumstances:

  • Lazy generation is desired: When working with large sets of data, lazy generation can speed up the app and make it more efficient as well.
  • Custom Iterator logic: You want your data to be iterable in a native Swift way.
  • Standard Library: Since many of Swift's standard features can work on sequences, you're tapping right into them.

ResultBuilders

ResultBuilders are a great way to make potentially verbose and boilerplate heavy code more readable and approachable. One ResultBuilder most developers will be familiar with is the ViewBuilder, or @viewBuilder. It is used in SwiftUI to enable the declarative syntax.

But ResultBuilders can be used aside from SwiftUI as well. Let's have a look at some code where researches try to do some experiments.

First, we need some data types to work with:

struct Molecule {
    let name: String
    let concentration: Double
}

struct Sample {
    let id: String
    let molecules: [Molecule]
}

struct Test {
    let name: String
    let samples: [Sample]

    init(name: String, samples: [Sample]) {
        self.name = name
        self.samples = samples
    }
}

The code is pretty self-explaining. We create structs for molecules, samples, and tests. Each sample may have any number of molecules and each test may have any number of samples - easy!

To create a test, we would write the following code:

let test = Test(
    name: "Experiment A",
    samples: [
        Sample(id: "S1", molecules: [
            Molecule(name: "H2O", concentration: 0.5),
            Molecule(name: "CO2", concentration: 0.2)
        ]),
        Sample(id: "S2", molecules: [
            Molecule(name: "NaCl", concentration: 0.3)
        ])
    ]
)

This code works great, but for many tests, samples, and molecules, one could argue it becomes very messy. Let's write a few ResultBuilders to fix that:

@resultBuilder
struct TestBuilder {
    static func buildBlock(_ components: Sample...) -> [Sample] {
        return components
    }
}

@resultBuilder
struct SampleBuilder {
    static func buildBlock(_ components: Molecule...) -> [Molecule] {
        return components
    }
}

We also need to tweak the Sample and Test structs:

struct Sample {
    // ... (same as before)

    init(@SampleBuilder molecules: () -> [Molecule]) {
        self.id = UUID().uuidString 
        self.molecules = molecules()
    }
}

struct Test {
    // ... (same as before)

    init(name: String, @TestBuilder samples: () -> [Sample]) {
        self.name = name
        self.samples = samples()
    }
}

Now we can use the ResultBuilders to write the same tests in a declarative way:

let testWithBuilder = Test(name: "Experiment B") {
    Sample {
        Molecule(name: "H2O", concentration: 0.7)
        Molecule(name: "O2", concentration: 0.3)
    }
    Sample {
        Molecule(name: "NaCl", concentration: 0.8)
    }
}

To recap, use ResultBuilders when:

  • You have deeply nested hierarchical data structures that are created by hand.
  • You have a varying number of nested elements.
  • You deal with deeply nested array or dictionary initialisations.

Protocols with Associated Types

While not the most advanced thing in general, I find it quite underused for the benefits it provides.

Sometimes, we need to define a protocol but also the kind of data it works on. Since we may not know this type upfront but only that a set of functions from the protocol should work on it, we can introduce an associated type. Let's have a look at this example:

protocol Container {
    associatedtype Item

    func add(_ item: Item)
    func find(query: @escaping (_: Item) -> Bool) -> Item?
}

struct Cat {
    let name: String
}

class CatContainer: Container {
    typealias Item = Cat

    func add(_ item: Cat) {
        // ...
    }

    func find(query: @escaping (_: Cat) -> Bool) -> Cat? {
        // ...
    }
}

In this example the CatContainer enforced that Item should be Cat. All its functions and properties automatically map to Cat when Item is mentioned in the protocol.

To recap, use protocols with associated types when:

  • Common behaviour but different data types are required.
  • A generic alone does not solve the requirements.

Additional Thoughts

That's it for this article. When working with Swift, always make sure you're using the most appropriate tool for the job. Sometimes one of the things mentioned can make the code a lot more readable even though they require some additional setup. Often, especially when used frequently, this drawback is outweighed by the increased clarity by far.

Thank you for reading.