Understanding Swift’s OptionSet
Published on: August 18, 2020Every 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 OptionSet
s 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 OptionSet
s 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.