A Neatly Typed Message: Improving Code Readability

Krzysztof Siejkowski dives into the readability of Swift code, treating it not as a well-defined goal to achieve, but as a spectrum that you need to decide where to land on. Looking at the variations of popular Cocoa patterns and Swift language constructs, we’ll identify their readability tradeoffs and chances for improvement. We’ll also learn some fine techniques to widen readability spectrum using the power of the Swift type system.


Introduction

My name is Christopher. I am an iOS developer at Polidea. We are a software house from Warsaw, Poland, specializing in apps communicating with hardware using Bluetooth or Wi-Fi. I also co-organize a meetup called Mobile Warsaw, but today I’ll be speaking about code readability.

What’s our biggest problem?

In 2006, Microsoft in collaboration with Carnegie Mellon University published a paper called “Maintaining Mental Models: A Study of Developer Work Habits.” The authors conducted a survey among programmers working at Microsoft. And one of the questions they asked was whether a particular task was seen as a problem.

The problem reported most often by these programmers was understanding the rationale behind a piece of code. How did the programmers try to overcome this? Mostly, just by reading. Reading took over 40% of the time spent on solving this problem. When this was not enough, they tried running a debugger, adding print statements, and so on. If those also failed, they tried to get to the original author and ask them for an explanation. (The second-most commonly stated problem was dealing with the requests from the teammates looking for help with understanding the code.)

When I read this article, I found it very relatable. I’ve been both a person trying to understand the code, and a person asked to explain what I wrote. And sometimes I couldn’t really tell because the original author was me, but six months ago, and I wrote something I couldn’t easily understand myself. What insight can we gain from this research? Readability is crucial for understanding code. It makes a real impact on how we work.

There are many voices stating the importance of writing readable code. One example is this widely cited quote from the book Structure and Interpretation of Computer Programs:

“programs must be written for people to read, and only incidentally for machines to execute”

We need techniques to control the intellectual complexity embodied in software. Writing code means controlling the complexity. The more complex the system we write, the more difficult it becomes to understand.

Complexity

Some of the complexity is intrinsic, which means it comes from the logic that we are supposed to write and we cannot do that much about this part. But there is also incidental complexity, which is the part that we introduce ourselves. This one is under our control. To minimize it, we need to work extra hard on the readability of our code.

Get more development news like this

Readability Quest

Let’s play the readability game to find out. It’s time for us to go on a quest. Our mission is to find a readability sweet spot.

Level 1-1: comments

The first world we’ll explore is the API design world. Imagine you’ve obtained this string, representing an e-mail, and you want to split it into an user name and a domain.


func split(email: String)  (String, String)? {
    let components = email.components(separatedBy: "@")
    guard components.count == 2,
          let username = components.first,
          let domain = components.last,
          !username.isEmpty && !domain.isEmpty
        else { return nil }
    return (username, domain)
}

let splitEmail = split(email: "[email protected]")

We split the string into parts separated by the @ sign. Then we check that there are only two available parts. We bind them to the variables and ensure that they are not empty. If anything fails, we return nil. If everything succeeds, we return a tuple containing the user name as the first element and the domain as the second. Simple.

Back to readability. We are in the API design world, so let’s concentrate on the method signature.


func split(email: String)  (String, String)?

Imagine seeing this signature for the first time. There are many questions that you may ask. How is the e-mail split? What are the string’s elements in a tuple? Why is the result an optional?

This is the first level of the readability game: comments.


/// Splits the email into username and domain.
/// - Parameter email: A string that possibly
/// contains the email address value.
/// - Returns: Either:
///     - Tuple with username as first element
///     and domain as second
///     - Nil when passed argument cannot be split
func split(email: String)  (String, String)?

Now we’ve added a detailed description. Comments are a great tool for explaining the rationale behind a code, but they might also get confusing. Comments do not compile and do not execute in Swift. It’s up to the developers to prevent them from falling out of sync with the code that they are describing.

Comments are also a translation from the programming language to a natural language, with the possibility of misunderstandings and omissions. Here we state that the argument cannot be split. It’s not really true; we can also get nil if the argument was split, but the username or domain was empty. Something was lost in translation.

Level 1-2: symbols

We could try to dodge those problems by using the Swift language itself. Here is the second level of readability game, symbols.


typealias SplitEmail = (username: String,
                          domain: String)
                          
struct SplitEmail {
    let username: String
    let domain: String
}

func split(email: String)  SplitEmail?

We want to state that the e-mail is split into the user name and the domain. We can use a names tuple, probably behind the type alias, or a container struct. From the method signature’s perspective, there is no difference. Both those solution share a common problem: they increase the number of symbols defined in the wrap.

Each symbol carries two types of costs. Those costs will be paid by the reader. The first time a fellow programmer sees SplitEmail, they need to check and understand what exactly this symbol means. It’s one more intermediary step to keep in mind. This is the discoverability cost. For each another time, the cost of refreshing one’s mind is going down because it’s no longer an unfamiliar concept to learn. This is recognition cost.

The goal is: Either a lot or not at all. There’s no point in introducing a type that is used only once, because the discoverability cost is fixed and the recognition cost is marginal. Also, note that standard library types are usually cheap because the discoverability cost for them has been paid a long time ago and they’re being refreshed constantly.

Now our signature expresses the structure of SplitEmail. However, we haven’t yet explained why the result type isn’t optional. Optionals are capturing the idea that the value might be missing, but they don’t capture the reason why it’s missing.

Level 1-3: wrapper

A wrapper is an extension of the container. It doesn’t only store the values, but also provides some additional context.


enum SplitEmail {
    case valid(username: String, domain: String)
    case invalid
    
    init(from email: String) {
        let components = email.components(separatedBy: "@")
        if /* same checks as before */ {
            self = .valid(username: username, domain: domain)
        } else {
            self = .invalid
        }

We want to express that the past string cannot be split into non-empty user name and domain. Our wrapper enum does that by having two cases, one that keeps valid results as associated values and the second one to indicate that the e-mail was invalid.

This cleans our method signature of a question mark. But does it help or hurt readability? It depends. The way we modeled the idea of splitting an e-mail becomes more complex. The reader will now need to understand the rationale behind the enum with two cases and the length initializer. The discoverability cost is growing higher.

We can go even further in extracting the essence of splitting with concepts.

Level 1-4: concepts


protocol Splitter {
    associatedtype Splitting
    associatedtype Splitted
    func split(value: Splitting)  (Splitted, Splitted)?
}

struct EmailSplitter: Splitter {
    func split(value: String)  (String, String)? {
        let components = value.components(separatedBy: "@")
        guard /* same checks as before */ else { return nil }
        return (first, second)
    }

We are using the Swift type system to grasp the very idea of splitting. Splitting is a process in which a single value is transformed into two values. We modeled transform as a protocol with associated types, and then the e-mail splitter becomes one among many possible implementations of this splitter protocol.

The operation can either succeed or fail. The result is an enum with two cases. It’s generic over the value type and the transform type. It ensures that the transform can work. I saw this pattern in Swift for the first time in the Validated framework by Benjamin Encz (which I encourage you all to check).


enum Split<Value, S: Splitter> where S.Splitting == Value,
                                      S.Splitted == Value {
    case splitted(Value, Value)
    case invalid
    
    init(_ value: Value, using splitter: S) {
        if let (first, second) = splitter.split(value: value) {
            self = .splitted(first, second)
        } else { self = .invalid }

Now the result has its own type, the value has its own type, and the process of transforming the value into a result also has its own type.


func split(email: String)  Split<String, EmailSplitter>

// result: Split
// value: String
// transform: EmailSplitter

The signature contains all the same information that we have put into comments a few minutes ago. We are splitting string containing e-mail using an e-mail splitter into a possibly failing result of splitting.

Is complexity justified?

If the idea of splitting is an important idea in an app, then we are going to have a lot of concrete types of splitters working on different values. By putting each one of them into a separate scope, we will have to deal with the intrinsic complexity.

But if we have done all this work, if we have extracted all those types just to play with the method signature, we are only introducing incidental complexity. We are obfuscating our code. We should have just written a comment and moved on.

In game terms, we went through four levels and it’s the end of the first world. We can already see there is no universal solution, only trade-offs. Maybe we’ll have some more luck in the second world.

World 2: Delegation

The second world is about delegation patterns. The delegate is defined with a protocol and it’s passed to the announcing object. When we express this concept in code, we are choosing among many possibilities. Should the delegate be assigned only once or multiple times? Is it optional or required? Should it be retained or not?

Let’s look how Apple answers these questions in their usual delegate implementation.


protocol Delegate: class {
    func foo()
}

struct Announcing {
    open weak var delegate: Delegate?
}

var announcing = Announcing()
announcing.delegate = delegate

There is a public mutable weak delegate property. We can extract four things:

  1. the delegate is not retained because it’s weak;
  2. the delegate is not required, because it can be never assigned;
  3. the delegate can be changed during the lifetime of announcing object because it’s a var;
  4. there is no harm in a free access to a delegate because the scope is open.

Are those assumptions valid?

Conventions for Readability

Sometimes there is an explanation in the comments or in the docs, but often there is no answer there. Instead of docs, Apple is relying on the fact that it’s a convention within their libraries that the delegates are not retained, they’re optional, they can’t be changed, and that you should not really call their methods by yourselves. And the programming guides and the community, they share that knowledge among the new members so everyone is on the same page.

But let’s diverge from the choices Apple made. We want to have our delegate required and immutable. Please enter the first level of the second world, initializer injection.

Level 2-1: Initializer injection


struct Announcing {
    private weak var delegate: Delegate?
    
    init(to delegate: Delegate) {
        self.delegate = delegate
    }
}

let announcing = Announcing(to: delegate)

All we’ve done is we’ve changed the property visibility to private and we’ve moved the assignment to initializer. Now it’s impossible to create an announcing object without the delegate, and it’s impossible to directly access a delegate from the outside.

There is unfortunately some collateral damage. We have erased the information that the delegate is weak from the public API. To make sure there will be no reference cycle, one has to read the implementation of the announcing object.

Next Up: New Features in Realm Obj-C & Swift

General link arrow white

We can take a different route. Please enter the second level, a weak capture in a closure.

Level 2-2: Weak closure


struct Announcing {
    private let delegate: ()  Delegate?
    
    init(to delegate: @escaping ()  Delegate?) { ... }
}

let announcing = Announcing(to: {
    [weak delegate] in delegate
})

The responsibility to decide whether we want the announcing object to retain the delegate has been moved outside. The announcing object doesn’t even know whether it starts the delegate strongly or weakly. However, would it be clear for a reader that the closure in the initializer is a way to avoid reference cycles? I don’t think so.

Closure is a language construct used in multiple ways, in multiple places, and there’s nothing to specify its role here. Please enter the third level, the closure creation in a function.

Level 2-3: Weak function


func weak<T: AnyObject>(_ object: T)  ()  T? {
    return { [weak object] in object }
}

struct Announcing {
    private let delegate: ()  Delegate?
    init(to delegate: @escaping ()  Delegate?) { ... }
}

let announcing = Announcing(to: weak(delegate))

The weak function is simply wrapping reference in the closure. Now it’s explicit what we are passing to the announcing object. We are passing a weak-ified delegate.

However, the rationale is still not clearly stated in the initializer signature. It still takes a closure without specifying its role. We can try to improve that using the same technique that we have used in the first world of our readability game, the wrapper pattern.

Level 2-4: Weak wrapper


struct Weak<T: AnyObject> {
    private let internalValue: ()  T?
    init(_ value: T) {
        internalValue = { [weak value] in value }
    }
}
struct Announcing {
    private let delegate: Weak<Delegate>
    init(to delegate: Weak<Delegate>) { ... }
}

But… using a wrapper to improve the method signature? We have been here before. We have already worked with type aliases and containers and concepts. We know there is no readability sweet spot waiting for us here. This is the end of the second world in our game metaphor.

Readability Is Contextual

Is our improved delegation really that much more readable? We are not leveraging the convention that Apple provide. There is a short background for protocol developers. Driving away from them might only increase the understanding difficulties.

So there is always a wider context in which our code exists. To write readable code, we need to look beyond what we control ourselves. What shall we do? Should we move to another world? There are many worlds to choose from, but is there really any world that will provide the ultimate solution? I don’t think so.

How can we write readable code without clear guidance? How do we choose the right balance of complexity, symbols cost, leveraging conventions, explaining the role of language constructs, and many other factors? Fortunately, there is a “God mode” in the readability game: empathy.

Empathy

To choose how to write readable code, we need to embrace the perspective of the reader. We need to imagine what they might be thinking, what questions they might ask. We need to put ourselves in the shoes of another person, to identify whether we have chosen a reasonable set of trade-offs.

Everyone is reading the code through the lens of their personal experience, their programming background, their fluency in Swift, their aesthetics and attention span. If we treat the code as the way to describe the system to another person, then just like with any other form of expression, we need to put the spotlight on the recipient.

Readability Depends on the Reader

There is no readability without the reader. That’s what makes empathy so crucial. Readability is a function of a reader. It’s a function that we need to choose where to land on.

To win the readability game, we need to listen to our teammates and the community. And we need to remember that by thinking about others, we are also helping our future selves.

There is no single right way to write code. What matters is being ready to learn and understand other people. If we stay open to feedback, and we keep seeing value in different perspectives, we can enjoy a top place at the readability game high score table.

Krzysztof Siejkowski

Krzysztof (or Chris) is an iOS developer at Polidea, a hardware-friendly software house in Warsaw, Poland. He’s a co-organizer of Mobile Warsaw, a community for mobile developers, and a Swift enthusiast. A cultural anthropologist by training, he tries to see programming techniques from a humanistic perspective.

Transcribed by Sandra Sanchez-Roige
Edited by Curtis Chen