Understanding Swift’s OptionSet

Published by donnywals on

Every once in a while I look at a feature in Swift and I fall down a rabbit hole to explore it so I can eventually write about it. The OptionSet protocol is one of these Swift features.

If you've ever written an animation and passed it a list of options like this, you have already used OptionSet:

UIView.animate(
  withDuration: 0.6, delay: 0, options: [.allowUserInteraction, .curveEaseIn],
  animations: {
    myView.layer.opacity = 0
  }, completion: { _ in })

You may not have realized that you weren't passing an array to the options parameter, and that's not surprising. After all, the options parameter accepts an array literal so it makes a lot of sense to think of the list of options as an array.

But, while it might look like an array, the options parameter accepts an object of type UIView.AnimationOptions which conforms to OptionSet.

Similarly, you may have written something like the following code in SwiftUI:

Rectangle()
  .fill(Color.yellow)
  .edgesIgnoringSafeArea([.leading, .trailing, .bottom])

The edgesIgnoringSafeArea accepts a list of edges that you want to ignore. This list looks a lot like an array since it's passed as an array literal. However, this too is actually an OptionSet.

There are many more examples of where OptionSet is used on Apple's platforms and if you're curious I recommend that you take a look at the documentation for OptionSet under the Relationships section.

In this week's post, I would like to zoom in on what an OptionSet is. And more importantly, I want to zoom in on how an OptionSet works because it's quite an interesting topic.

I'll first show you how you can define a custom OptionSet object and why you would do that. After that, I will explain how an OptionSet works. You will learn about bit shifting and some bitwise operators since that's what OptionSet uses extensively under the hood.

Understanding what an OptionSet is

If you've looked at the documentation for OptionSet already, you may have noticed that it's not terribly complex to create a custom OptionSet. So let's talk about when or why you might want to write an OptionSet of your own before I briefly show you how you can define your own OptionSet objects.

An OptionSet in Swift is a very lightweight object that can be used to represent a fairly large number of boolean values. While you can initialize it with an array literal, it's actually much more like a Set than an array. In fact, OptionSet inherits all of the SetAlgebra that you can apply to sets which means that OptionSet has methods like intersection, union, contains, and several other methods that you might have used on Set.

In the examples I've shown you in the introduction of this post, the OptionSets that were used represented a somewhat fixed set of options that we could either turn off or on. When a certain option is present in the OptionSet we want that option to be on, or true. if we omitted that option we want that option to be ignored. In other words, it should be false.

So when we pass .leading in the list of options for edgesIgnoringSafeArea we want it to be ignored. If we don't pass .leading in the list, we want the view to respect the leading safe area edge because it wasn't present in the list of edges that we want to ignore.

What's interesting about OptionSet in the context of edgesIgnoringSafeArea is that we can also pass .all instead of [.all] if we want to ignore all edges. The reason for this is that OptionSet is an object that can be initialized using an array literal but as I've mentioned before, it is not an array.

Instead, it is an object that stands on its own and it uses a single raw value to represent all options that it holds. Before I explain how that works exactly, let's see how you can define a custom OptionSet because I'm sure that'll provide some useful context.

Defining a custom OptionSet

When you define an OptionSet in Swift, all you do is define a struct (or class) that has a raw value. This raw value can theoretically be any type you want but commonly you will use a type that conforms to FixedWidthInteger, like Int or Int8 because you will get a lot of functionality for free that way (like the conformance to SetAlgebra) and it simply makes more sense.

Next, you should define your options where each option has a unique raw value that's a power of two.

Let's look at an example:

struct NotificationOptions: OptionSet {
  static let daily = NotificationOptions(rawValue: 1)
  static let newContent = NotificationOptions(rawValue: 1 << 1)
  static let weeklyDigest = NotificationOptions(rawValue: 1 << 2)
  static let newFollows = NotificationOptions(rawValue: 1 << 3)

  let rawValue: Int8
}

Looks simple enough right? But what does that << do you might ask. I'm glad you asked and I will talk about it in the next section. Just trust me when I say that the raw values for my options are 1, 2, 4, and 8.

If you'd define this OptionSet in your code you might use it a little bit like this:

class User {
  var notificationPreferences: NotificationOptions = []
}

let user = User()
user.notificationPreferences = [.newContent, .newFollows]

user.notificationPreferences.contains(.newContent) // true
user.notificationPreferences.contains(.weeklyDigest) // false

user.notificationPreferences.insert(.weeklyDigest)

user.notificationPreferences.contains(.weeklyDigest) // true

As you can see you can treat notificationPreferences like a Set even though the type of notificationPreferences is NotificationOptions and your options are represented by a single integer which means that this is an extremely lightweight way to store a set of options that are essentially boolean toggles.

Let's see how this magic works under the hood, shall we?

Understanding how OptionSet works

In the previous section I showed you this OptionSet:

struct NotificationOptions: OptionSet {
  static let daily = NotificationOptions(rawValue: 1)
  static let newContent = NotificationOptions(rawValue: 1 << 1)
  static let weeklyDigest = NotificationOptions(rawValue: 1 << 2)
  static let newFollows = NotificationOptions(rawValue: 1 << 3)

  let rawValue: Int8
}

I told you that the raw values for my options were 1, 2, 4, and 8.

The reason these are my raw values is because I applied a bitshift operator (<<) to the integer 1. Let's take a look at what that means in greater detail.

The integer 1 can be represented in Swift by writing out its bytes as follows:

let one = 0b00000001
print(one == 1) // true

In this case I'm working with an Int8 which uses 8 bits for its storage (you can count the 0s and 1s after 0b to see that there are eight). You can imagine that an Int64 which uses 64 bits as its storage would mean that I have to type a lot of zeroes to represent the full storage in this example.

When we take the integer 1 (or 0b00000001) and apply << 1 to this value, we shift all of its bits to left by one step. This means that the last bit in my integer becomes 0 and the bit that came before the last bit becomes 1 since the last bit shifts leftward by 1. So that means our value is now 0b00000010 which happens to be how the integer two is represented. If apply << 2 to 1, we end up with the following bits: 0b00000100 which happen to be how four is represented. Shifting to left once more would result in the integer eight, and so forth. With a raw value of Int8 we can shift to the left seven times before we reach 0b00000000 and get the integer 0. So that means that an OptionSet with Int8 as its raw value can hold eight options. Int16 can hold sixteen options all the way up to Int64 which will hold up to sixty-four values.

That's a lot of options that can be represented with a single integer!

Now let's see what happens when we add a new static let to represent all options:

static let all: NotificationOptions = [.daily, .newContent, .weeklyDigest, .newFollows]

What's the raw value for all? You know it's not an array of integers since the type of all is NotificationOptions so that list of options must be represented as a single Int8.

If you're curious about the answer, it's 15. But why is that list of options represented as 15 exactly? The simple explanation is that all individual options are added together: 1 + 2 + 4 + 8 = 15. The more interesting explanation is that all options are added together using a bitwise OR operation.

A bitwise OR can be performed using the | operator in Swift:

print(1 | 2 | 4 | 8) // 15

A bitwise OR compares all the bits in each integer and whenever it encounters a bit that's set to 1, it's set to 1 in the final result. Let's look at this by writing out the bits again:

0b00000001 // 1
0b00000010 // 2
0b00000100 // 4
0b00001000 // 8
---------- apply bitwise OR
0b00001111 // 15

If you want to write this out in a Playground, you can use the following:

print(0b00000001 | 0b00000010 | 0b00000100 | 0b00001000 == 0b00001111) // true

Pretty cool, right?

With this knowledge we can try to understand how contains and insert work. Let's look at insert first because that's the simplest one to explain since you already know how that works.

An insert would simply bitwise OR another value onto the current value. Let's use the following code as a starting point:

class User {
  var notificationPreferences: NotificationOptions = []
}

let user = User()
user.notificationPreferences = [.newContent, .newFollows]

In this code we use two options which can be represented as follows: 0b00000010 | 0b00001000. This results in 0b00001010 meaning that we have a raw value of 10. If we then insert a new option, for example .daily, the OptionSet will simply take that raw value of 10 and bitwise OR the new option on top: 0b00001010 | 0b00000001 which means we get 0b00001011 which equals eleven.

To check whether an OptionSet contains a specific option, we need to use another bitwise operator; the &.

The & bitwise operator, or bitwise AND compares two values and sets any bits that are 1 in both values to 1. All other bits are 0. Let's look at an example based on the code from before again:

user.notificationPreferences = [.newContent, .newFollows]

You know that the notificationPreferences's raw value is 10 and that we can represent that as 0b00001010. So let's use the bitwise AND to see if 0b00001010 contains the .newContent option:

0b00001010 // the option set
0b00000010 // the option that we want to find
---------- apply bitwise AND
0b00000010 // the result == the option we want to find

Because the result of applying the bitwise AND equals the value we were trying to find, we know that the option set contains the option we were looking for. Let's look at another example where we check if 0b00001010 contains the weeklyDigest option:

0b00001010 // the option set
0b00000100 // the option that we want to find
---------- apply bitwise AND
0b00000000 // the result == 0

Since the bits that we wanted to find weren't present in the option set, the output is 0 since all bits are 0 in the result of our operation.

With this knowledge you can also perform more complicated SetAlgebra operations. For example, at the start of this article I mentioned that OptionSet has a union method that's provided by SetAlgebra. The union method returns the combination of two OptionSet objects. We can easily calculate this using a bitwise OR operator. Let's assume that we have two OptionSet objects:

let left: NotificationOptions = [.weeklyDigest, .newFollows]
let right: NotificationOptions = [.newContent, .weeklyDigest]

We can calculate the union using left.union(right) which would give an OptionSet that contains weeklyDigest, newFollows and newContent, but let's see how we can calculate this union ourselves using the bitwise OR:

0b00001100 // weeklyDigest and newFollows
0b00000110 // newContent and weeklyDigest
---------- apply bitwise OR
0b00001110 // newContent, weeklyDigest, and newFollows

While you don't have to understand how all of these bitwise operations work, I do think it's very valuable to have this knowledge, even if it's just to help you see how nothing is truly magic, and everything can be explained.

The key information here isn't that you can do bit shifting in Swift, or that you can apply bitwise operators. That's almost a given for any programming language.

The important information here is that OptionSet can store a tremendous amount of information in a single integer with just a couple of bits while also providing a very powerful and flexible API on top of this storage.

While I haven't had to define my own OptionSets often, it's very useful to understand how you can define them, you never know when you might run into a case where you need a flexible, lightweight storage object like OptionSet provides.

In Summary

I was planning to write a short and consise article on OptionSet at first. But then I found more and more interesting concepts to explain, and while there still is more to explain I think this article should provide you with a very good understanding of OptionSet and a couple of Swift's bitwise operators. There are more bitwise operators available in Swift and I highly recommend that you go ahead and explore them.

For now, you saw how to use, and define OptionSet objects. You also saw how an OptionSet's underlying storage works, and you learned that while you can express an OptionSet as an array literal, they are nothing like an array. You now know that a list of different options can be fully represented in an integer.

If you have any questions about this article, or if you have any feedback for me, I would love to hear from you on Twitter.



Receive weekly updates about my posts

Categories: Swift