|
From Swift in Depth by Tjeerd in ‘t Veen This article, adapted from chapter 2 of Swift in Depth, discusses the “or” and “and,” also known or sum and product types, respectively, and how they can be used in Swift. |
Save 37% on Swift in Depth. Just enter code fccveen into the discount code box at checkout at manning.com.
Tag along! It’s more educational and fun if you can check out the code and follow along with the article. You can download the source code at: https://github.com/tjeerdintveen/manning-swift-in-depth/tree/master/ch02-enums/
Enums are based on something called algebraic data types, which is a term that comes from functional programming languages, where enums are sometimes referred to as sum types. It’s good to know these terms when talking in programming concepts. Enums—or sum types—can be thought of as an “or” type. Sum types can only be one thing at a time, e.g.: A traffic light can either be green or yellow or red. Or how a die can either be six-sided or twenty-sided, but not both at the same time. On the other end of the spectrum, we have another concept called product types. A product type is a type that contains multiple values, such as a class, tuple or struct. You can think of a product type as an “and” type. E.g. a User
struct can have both a name and an id. Or an address class can have a street and a housenumber and a zipcode.
Modeling data with a struct
Let’s start off with an example that shows how to think about “or” and “and” types when modeling data.In the upcoming example we’re modelling message data in a chat application. A message could be text that a user may send, but it could also be a join or leave message. A message could even be a signal to send balloons across the screen. Why not; Apple does it in their Messages app.
Figure 1 A chat application.
If we’d list the types of messages that our application supports, we’d have:
-
A join message, such as “Mother-in-law has joined the chat.”
-
A text message that someone can write. Such as “Hello everybody!”
-
A send balloons message, which includes some animations and annoying sounds that others see and hear.
-
A leave message, such as “Mother-in-law has left the chat.”
-
A message which is being drafted, such as “Mike is writing a message.”
Let’s create a data model to represent messages. Our first idea might be to use a struct to model our Message, we’ll start by doing that and showcase the problems it brings. Then we’ll solve these problems by using an enum. We can create multiple types of messages in code, such as a join message when someone enters a chatroom.
Listing 1 A join chatroom message
import Foundation // Needed for the Date type. let joinMessage = Message(userId: "1", contents: nil, date: Date(), hasJoined: true, // We set the joined boolean hasLeft: false, isBeingDrafted: false, isSendingBalloons: false)
We can also create a regular text message.
Listing 2 A text message
let textMessage = Message(userId: "2", contents: "Hey everyone!", // We pass a message date: Date(), hasJoined: false, hasLeft: false, isBeingDrafted: false, isSendingBalloons: false)
In our hypothetical messaging app, we can pass this message data around to other users.
Our Message
struct looks as follows:
Listing 3 The Message struct
import Foundation struct Message { let userId: String let contents: String? let date: Date let hasJoined: Bool let hasLeft: Bool let isBeingDrafted: Bool let isSendingBalloons: Bool }
Although this is one small example, it displays a problem. Because a struct can contain multiple values, we can run into bugs where the Message
struct can both be a text message and a hasLeft
command, as well as a isSendingBalloons
command–which doesn’t work in this example, because a message can only be one or another in the business rules of the application. The visuals won’t support an invalid message either.
For example, we can have an invalid message that can be conflicting. It can show a text, but also a join and a leave message.
Listing 4 An invalid message with conflicting fields.
let brokenMessage = Message(userId: "1", contents: "Hi there", // We have text to show date: Date(), hasJoined: true, // But this message also signals a joining state hasLeft: true, // ... and a leaving state isBeingDrafted: false, isSendingBalloons: false)
What tends to happen is that the more fields a struct contains, the more complex the struct becomes. The unique combinations of states grow exponentially higher with the number of fields added. The more fields you slap on a struct, the higher the chance of bugs.
In a small example it’s harder to run into invalid data, but it definitely happens often enough in real-world projects. Imagine a Message
being created from a local file, or some function that combines two messages. There are no compile-time guarantees that a message is in the right state
We can think about validating a Message
and throwing errors, but then we’re catching invalid messages at runtime (if at all). Instead, we can prevent this invalid message issue at compile time if we model the Message
using an enum.
Turning a struct into an enum
Whenever you’re modeling data, see if you can find mutually exclusive properties. For example, a message can’t be both a join and leave message at the same time. A message can’t send balloons and also be a draft at the same time.
A message can be a join or leave message. A message can also be a draft or sending balloons. When you detect “or” statements in a model, an enum could be a more fitting choice for your data model.
Using an enum to group the fields into cases makes the data much easier to grasp. We can instantly see that there are five types of messages that we can send. Also, it’s clear which fields belong together and which fields don’t.
Let’s improve our model by turning it into an enum.
Listing 5 Message as an enum (lacking values)j
import Foundation enum Message { case text case draft case join case leave case balloon }
The total sum of variations of Message
are five cases. Hence why enums are sometimes called sum types. But we’re not there yet, the cases have no values. We can add values by adding a tuple to each case.
A tuple is an ordered set of values. Such as (userId: String, contents: String, date: Date
). By combining an enum with tuples we can build more complex data structures. Let’s add tuples to the enum’s cases now:
Listing 6 Message as an enum, with values
import Foundation enum Message { case text(userId: String, contents: String, date: Date) case draft(userId: String, date: Date) case join(userId: String, date: Date) case leave(userId: String, date: Date) case balloon(userId: String, date: Date) }
By adding tuples to cases, these cases now have associated values in Swift terms. Structs, tuples, and classes are a product type. This is because the product is the total number of variations a product type can hold, e.g. the number of string variants (for userId) times the number of string variants (for contents) times the number of dates = the product.
Now whenever we want to create a Message
as an enum, we can pick the proper case with related fields. It’s impossible to mix and match the wrong values.
Listing 7 Creating enum messages
let textMessage = Message.text(userId: "2", contents: "Bonjour!", date: Date()) let joinMessage = Message.join(userId: "2", date: Date())
When we want to work with the messages, we can use a switch
case on them and unwrap its inner values.
For example, we can log the messages which have been sent:
Listing 8 Logging messages
logMessage(message: joinMessage) // User 2 has joined the chatroom logMessage(message: textMessage) // User 2 sends message: Bonjour! func logMessage(message: Message) { switch message { case let .text(userId: id, contents: contents, date: date): print("[\(date)] User \(id) sends message: \(contents)") case let .draft(userId: id, date: date): print("[\(date)] User \(id) is drafting a message") case let .join(userId: id, date: date): print("[\(date)] User \(id) has joined the chatroom") case let .leave(userId: id, date: date): print("[\(date)] User \(id) has left the chatroom") case let .balloon(userId: id, date: date): print("[\(date)] User \(id) is sending balloons") } }
It may be a deterrent having to switch on all cases in your entire application to read a value from a single message. You can save yourself some typing by using the if case let
combination to match on a single type of Message.
Listing 9 Matching on a single case
if case let Message.text(userId: id, contents: contents, date: date) = textMessage { print("Received: \(contents)") // Received: Bonjour! }
If we’re uninterested in certain fields when matching on an enum then we can match on these fields with an underscore, or how I like to call it: the “don’t care” operator.
Listing 10 Matching on a single case with the “I don’t care” underscore.
if case let Message.text(_, contents: contents, _) = textMessage { print("Received: \(contents)") // Received: Bonjour! }
Next time you write a struct, see if you can try and group properties. Your data model might be a good candidate for an enum!
Exercises
1. Given this struct, does it represent a remote image well? Can we get compiler benefits by turning it into an enum? If so, change it to an enum. struct RemoteImage { let isLoading: Bool let isCached: Bool let path: String let resolution: Int let data: Data }
2. Given this struct, does it represent a friend type well?
Can we get compiler benefits by turning it into an enum? If so, change it to an enum.
struct Friend {
let firstName: String
let lastName: String
let dateOfBirth: Date
let twitterHandle: String
let instagramHandle: String
let facebookProfileURL: String
}
That’s all for now.
If you want to learn more about the book, check it out on liveBook here and see this slide deck.