Weekly Swift articles, podcasts and tips by John Sundell.

The power of subscripts in Swift

Published on 18 Aug 2019

Using subscripting to access elements within various collections, like arrays and dictionaries, is something that’s very common not only in Swift — but in almost all relatively modern programming languages. However, the way subscripting is actually implemented in Swift is both quite unique, and really powerful — as it lets us add subscripting APIs to our own types, just like those found in the standard library.

This week, let’s take a look at how subscripting works in Swift, and a few different ways to incorporate it into the way we design APIs — including some brand new capabilities that are being added in Swift 5.1.

Subscripts vs methods

Arguably the biggest benefit of subscripting is the incredibly lightweight syntax that it gives us at the call site. Rather than having to call specific methods with names that we either need to remember or look up, subscripting lets us simply retrieve a value using just its index or key:

let fifthElement = array[4]
let accessToken = dictionary["token"]

Just compare the above two APIs to what their method equivalents would look like:

let fifthElement = array.element(at: 4)
let accessToken = dictionary.value(forKey: "token")

However, while subscripting is really convenient for a somewhat narrow set of use cases, it could also lead to quite confusing code if used outside the realm of dynamically getting and setting values. For example, it’s not very clear that this line of code causes a notification to be sent:

notificationsToSend[.userUpdated] = Notification(value: user)

Since the above API is for performing an action, rather than assigning a value, a good old fashioned method would arguably be much more appropriate:

send(Notification(value: user), forEvent: .userUpdated)

So subscripting is definitely not a replacement for methods, but rather a way to more easily provide access to an underlying set of values — whether that’s a collection, a database, or a model that’s being accessed using a key path.

Custom subscripting

Let’s say that we’re building a project management app that lets our users organize their tasks by placing them on a two-dimensional grid. To keep things simple, we’ve modeled our grid as a row-ordered array of Task values, with a given horizontal width:

struct Grid {
    var tasks: [Task]
    var width: Int
}

While accessing any task within the first row can be done simply by subscripting the tasks array directly (such as tasks[2] for the third task), once we start traversing our grid vertically we have to do some basic math in order to calculate the index that corresponds to a given set of X and Y coordinates. To avoid code duplication and potential errors, we encapsulate those calculations within a method — like this:

extension Grid {
    func task(atX x: Int, y: Int) -> Task? {
        // We choose to be a bit defensive here, and return nil
        // for invalid coordinates, rather than crash. This is
        // because we expect this code to be called within many
        // different contexts across our app.
        guard x >= 0 && y >= 0 && x < width else {
            return nil
        }

        let index = x + y * width

        guard index < tasks.count else {
            return nil
        }

        return tasks[index]
    }
}

Note that we could’ve used UInt for our coordinates, rather than signed integers, which would save us the >= 0 checks. However, that’d push that verification burden onto our API users, since all Int values would first have to be converted into UInt before calling our API — which would make it much more cumbersome to use.

Just like the hypothetical Array and Dictionary APIs that we took a look at earlier, the above method works, but is a bit unnecessarily verbose. Since we are, in fact, just accessing an element from within a collection — let’s also provide a subscripting equivalent of the above API, like this:

extension Grid {
    subscript(x: Int, y: Int) -> Task? {
        return task(atX: x, y: y)
    }
}

Read-only subscripts look very much like methods in Swift, only that they use the subscript keyword, rather than func followed by a name.

With the above in place, we can now access any task within our grid really easily, simply by subscripting using the coordinates that we’re interested in:

func selectTask(at point: CGPoint) {
    let x = Int(point.x / tileSize)
    let y = Int(point.y / tileSize)
    let task = grid[x, y]
    select(task)
}

The reason subscripting works so well above is both because the concept of accessing a tile within a grid is highly similar to retrieving a value from an array or dictionary — but also because it’s quite easy to understand that x and y refers to coordinates, without requiring any additional wording.

Overloading

Just like methods and free functions, Swift subscripts can be overloaded to provide different functionality for a different set of inputs. For example, let’s say that we wanted to provide an additional subscripting API for accessing a whole row of tasks within our grid, which could be done like this:

extension Grid {
    // We add a computed convenience property here to calculate
    // the height of the grid (which might be asymetrical): 
    var height: Int {
        return Int(ceil(Double(tasks.count) / Double(width)))
    }

    subscript(rowIndex: Int) -> ArraySlice<Task> {
        guard rowIndex >= 0 && rowIndex < height else {
            return []
        }

        let lowerBound = rowIndex * width
        let upperBound = min(lowerBound + width, tasks.count)
        return tasks[lowerBound..<upperBound]
    }
}

Above we return an ArraySlice, rather than a proper Array, to avoid having to copy the range of tasks that we’re returning each time that our subscript is accessed. This is very similar to last week’s approach of giving computed properties constant time complexity.

While our new subscript looks great above, once we start using it, we’ll find out that it looks quite ambiguous at the call site — since subscripts don’t get external parameter labels by default, making it look like we’re accessing just a single task, rather than a whole row:

func selectTasksOnRow(withIndex index: Int) {
    let tasks = Array(grid[index])
    select(tasks)
}

Ambiguity is one of the most prominent risks when deploying subscripts, since we need to make sure that all usages of our subscript-based APIs give anyone reading our code enough context to understand what’s going on — which is definitely not the case above.

Thankfully, the above problem can be quite easily fixed, since we can in fact add external parameter labels to a subscript if we want to — the exact same way we add custom external labels to function parameters — by adding a label right before the name of a parameter:

extension Grid {
    // It's completely fine to use the same name for a parameter's
    // external label as for its name.
    subscript(rowIndex rowIndex: Int) -> ArraySlice<Task> {
        ...
    }
}

With the above tweak in place, our call site now looks a lot more clear — as we’re explicitly referring to rowIndex when using our new subscript:

func selectTasksOnRow(withIndex index: Int) {
    // Here we make a deliberate choice to convert the returned
    // ArraySlice into a proper Array, rather than always doing
    // that whenever our subscript is accessed.
    let tasks = Array(grid[rowIndex: index])
    select(tasks)
}

Striking that kind of balance between reducing verbosity and still providing enough clarity at the call site is a common challenge whenever we’re designing any kind of APIs, but is especially important when using subscripts — since they default to not including any sort of wording at all.

Getters, setters, and generics

Another thing that subscripts and functions have in common is that they can be generic, which enables us to retain type safety in situations that would otherwise leave us with an untyped (or Any) value.

As an example, let’s take a look at how we could use that capability to improve the type safety of the very commonly used UserDefaults system API.

We’ll start by extending UserDefaults with a generic Key type, which carries the type of Value that we’re looking for (almost like a phantom type). We’ll also add a read-write subscript that lets us both retrieve and store values in a type-safe manner:

extension UserDefaults {
    struct Key<Value> {
        var name: String
    }

    subscript<T>(key: Key<T>) -> T? {
        get {
            return value(forKey: key.name) as? T
        }
        set {
            setValue(newValue, forKey: key.name)
        }
    }
}

The above newValue variable that appears within our set block is automatically generated by the compiler, and represents the new value that was assigned using our subscript — exactly like when using property observers.

With the above in place we can now extend UserDefaults.Key with computed factory-like properties for creating our keys — with each key being associated with the exact type of its corresponding value, giving us complete type safety:

extension UserDefaults.Key {
    static var bookmarks: UserDefaults.Key<[String]> {
        return .init(name: "bookmarks")
    }

    static var notificationSnoozed: UserDefaults.Key<Bool> {
        return .init(name: "notificationSnoozed")
    }
}

Since static properties can be used with dot syntax, we can now easily work with UserDefaults values like this:

class SettingsViewModel {
    private let userDefaults: UserDefaults

    init(userDefaults: UserDefaults = .standard) {
        self.userDefaults = userDefaults
    }

    func snoozeNotifications() {
        userDefaults[.notificationSnoozed] = true
    }
}

Pretty cool! But perhaps even cooler is that attempting to store a value of an incorrect type will now give us a compiler error:

// Error: Cannot assign value of type 'String' to type 'Bool?'
userDefaults[.notificationSnoozed] = "yep!"

We could also give our new type-safe subscripting API additional power by adding a second overload that accepts a default value — just like how Dictionary works:

extension UserDefaults {
    subscript<T>(
        key: Key<T>,
        default defaultProvider: @autoclosure () -> T
    ) -> T {
        get {
            return value(forKey: key.name) as? T
                ?? defaultProvider()
        }
        set {
            setValue(newValue, forKey: key.name)
        }
    }
}

Above we use @autoclosure to avoid having to evaluate the default value expression unless needed — which can help improve performance for heavy operations, and reduce the risk of unexpected side-effects. For more info, check out “Using @autoclosure when designing Swift APIs”.

Using our new subscript variant, we can now easily do things like append an element to an array stored within UserDefaults, or create a new one if needed — all in one line of code:

func addBookmark(named name: String) {
    userDefaults[.bookmarks, default: []].append(name)
}

Again, we have to aim to provide enough context and wording to make it obvious what our subscripting API does, and following the same conventions as Dictionary certainly helps in this regard, since anyone familiar with that subscripting API will most likely understand what we’re going for above.

Static subscripts

Finally, let’s take a look at a new feature that’s being introduced as part of Swift 5.1 — static subscripts — which work much the same way as instance subscripts, only that they enable us to subscript directly against a type itself.

For example, we could use this new feature to provide dedicated types for accessing the command line arguments and environmental variables that were passed when invoking a tool or script:

struct Arguments {
    static subscript(index: Int) -> String? {
        let arguments = CommandLine.arguments

        guard index < arguments.count - 1 else {
            return nil
        }

        // We discard the first command line argument here,
        // since it contains the execution path of our program.
        return arguments[index + 1]
    }
}

struct Environment {
    static subscript(key: String) -> String? {
        return ProcessInfo.processInfo.environment[key]
    }
}

Since the above two pieces of data are inherently universal within a program, it could be quite convenient to be able to access them without having to worry about passing instances around, just by subscripting our new Arguments and Environment types — like this:

let sourcePath = Arguments[0]
let targetPath = Arguments[1]
let apiToken = Environment["API_TOKEN"]

While being able to subscript types is really cool, it’s important to not put context-specific data within a static context, since doing so could heavily reduce the testability and separation of concerns within our code — which is the same set of problems that singletons often cause.

Conclusion

What makes many of Swift’s features — including subscripts — so powerful, is that rather than only having a finite number of use cases hardcoded into the compiler or standard library, any type can adopt them.

Especially when building custom collections, or working with any other group of values, using subscripts can let us design really concise and lightweight APIs. However, it’s important to carefully consider whether a subscript-based API provides enough context within each given situation — and if not, a method might be a better choice.

What do you think? Have you used custom subscripts in the past, or is it something you’ll try out? Let me know — along with your questions, comments or feedback — on Twitter or via email.

Thanks for reading! 🚀