8. Enums and Pattern Matching
An enum in C is a set of names for integers. An enum in Swift is a type
whose value is one of a fixed set of cases, and a case may carry values of its
own. That second half turns it into something C has no word for and Lux calls a
sum type: a value that is one shape or another, never both, never neither.
This is the feature Swift is built around. Optional is one. Result is one.
Most of the types worth defining in a program turn out to be one.
8.1 Cases and Raw Values
The simple form names a set of alternatives, and the type may be given a raw value so that it can cross the boundary into a file or a network.
// An enum with raw values is a name for a value the outside world uses.
enum Level: Int, CaseIterable {
case debug = 10
case info = 20
case warning = 30
case error = 40
var label: String {
"\(self)".uppercased()
}
var isNoisy: Bool {
self <= .info
}
}
extension Level: Comparable {
static func < (a: Level, b: Level) -> Bool {
a.rawValue < b.rawValue
}
}
for level in Level.allCases {
print(level.rawValue, level.label, level.isNoisy)
}
// Coming back from a raw value can fail, so it hands back an optional.
print(Level(rawValue: 30)?.label ?? "unknown")
print(Level(rawValue: 35)?.label ?? "unknown")
10 DEBUG true
20 INFO true
30 WARNING false
40 ERROR false
WARNING
unknown
Several things are worth taking from that.
An enum can have methods and computed properties, which surprises people who
arrive expecting a list of constants. isNoisy belongs on the type because it
is a fact about levels, and putting it there means it cannot drift out of step
with a copy of the same logic somewhere else.
CaseIterable gives you allCases, in declaration order, for free. The
compiler writes it, and it stays right when you add a case.
The raw value is not the case. Level.warning is a Level; 30 is an Int
that happens to represent it. Going from the case to the raw value always works
and is written .rawValue; coming back may fail, so Level(rawValue:) is a
failable initializer returning Level?. Strings work as raw values too, and
with no values given, the raw value of each case is its own name, which is how
most parsing enums are written.
Inside the type, .debug may be written without the type name wherever the type
is already known, which is why self <= .info reads as it does.
8.2 Associated Values
A case may carry values, and different cases may carry different ones. That is what makes an enum a type rather than a list of names.
// One type, several shapes, each carrying only what its own case needs.
enum Command {
case quit
case move(direction: String, steps: Int)
case say(String)
case unknown(String)
}
func parse(_ line: String) -> Command {
let parts = line.split(separator: " ").map(String.init)
switch parts.first {
case "quit", "q":
return .quit
case "go":
guard parts.count == 3, let steps = Int(parts[2]) else {
return .unknown(line)
}
return .move(direction: parts[1], steps: steps)
case "say":
return .say(parts.dropFirst().joined(separator: " "))
default:
return .unknown(line)
}
}
func run(_ c: Command) -> String {
switch c {
case .quit:
return "bye"
case .move(let direction, let steps) where steps > 10:
return "that is too far to go \(direction)"
case .move(let direction, let steps):
return "going \(direction) \(steps) step\(steps == 1 ? "" : "s")"
case .say(let text) where text.isEmpty:
return "you say nothing"
case .say(let text):
return "you say \"\(text)\""
case .unknown(let line):
return "I do not know how to \"\(line)\""
}
}
for line in ["go north 3", "go north 1", "go up 99", "say hello there", "say", "dance", "q"] {
print(run(parse(line)))
}
going north 3 steps
going north 1 step
that is too far to go up
you say "hello there"
you say nothing
I do not know how to "dance"
bye
Command is one type with four shapes. A .quit carries nothing, a .move
carries a direction and a count, a .say carries a line of text. There is no
Command with a steps and no direction, no Command that is both a move and
a quit, and no field that is meaningful for one case and ignored for the others.
Compare the alternative. A struct with an optional direction, an optional step count, an optional message and a kind tag can represent all of those nonsense combinations, and every function reading it has to decide what to do about them. The enum makes the nonsense unspellable, which means nobody has to write the code that handles it.
switch over the enum is exhaustive, so when you add a .look case next month
the compiler stops at every function that has to change and names the file and
line. That is the argument for this design in one sentence. A missed case is a
compile error rather than a bug report.
Note parts.first in parse: it is a String?, and the switch matches it
against string literals directly, because an optional is an enum and a literal
is a pattern for the .some case. The default catches nil along with
everything else.
Exercise 8-1. Add a .look case to Command and let the compiler show you
every place that needs changing.
Exercise 8-2. Rewrite Command as a struct with optional fields, write the
same run function against it, and count the impossible states you had to
handle.
8.3 Recursive Enums
A case cannot normally hold a value of its own type, since that would make the
size of the type infinite. indirect puts a level of pointer between them and
the problem goes away.
// A tiny expression tree. `indirect` lets a case hold its own type.
indirect enum Expr {
case number(Double)
case add(Expr, Expr)
case subtract(Expr, Expr)
case multiply(Expr, Expr)
case divide(Expr, Expr)
case negate(Expr)
}
func evaluate(_ e: Expr) -> Double {
switch e {
case .number(let n):
return n
case .add(let a, let b):
return evaluate(a) + evaluate(b)
case .subtract(let a, let b):
return evaluate(a) - evaluate(b)
case .multiply(let a, let b):
return evaluate(a) * evaluate(b)
case .divide(let a, let b):
return evaluate(a) / evaluate(b)
case .negate(let a):
return -evaluate(a)
}
}
func show(_ e: Expr) -> String {
switch e {
case .number(let n):
if let whole = Int(exactly: n) { return "\(whole)" }
return "\(n)"
case .add(let a, let b):
return "(\(show(a)) + \(show(b)))"
case .subtract(let a, let b):
return "(\(show(a)) - \(show(b)))"
case .multiply(let a, let b):
return "(\(show(a)) * \(show(b)))"
case .divide(let a, let b):
return "(\(show(a)) / \(show(b)))"
case .negate(let a):
return "-\(show(a))"
}
}
// (2 + 3) * -(10 / 4)
let e = Expr.multiply(
.add(.number(2), .number(3)),
.negate(.divide(.number(10), .number(4)))
)
print(show(e), "=", evaluate(e))
print(show(.add(.number(1), .multiply(.number(2), .number(3)))))
((2 + 3) * -(10 / 4)) = -12.5
(1 + (2 * 3))
That is an interpreter for arithmetic in forty lines, and the shape generalizes:
a tree is an enum with a leaf case and a branch case, a JSON value is an enum
with six cases, a linked list is an enum with empty and node. Two functions
walk the same tree without either of them knowing about the other, which is what
you get when the data is separate from the operations on it.
indirect may be written on a single case or on the whole enum, as here. The
cost is one allocation per node, which is what a tree costs in any language.
Exercise 8-3. Add a .power(Expr, Expr) case and let the compiler find the
two functions that need it.
Exercise 8-4. Write depth(_:) returning how deeply nested an Expr is.
Exercise 8-5. Write a JSON enum with cases for null, boolean, number,
string, array and object, and a function that renders one as text.
8.4 Patterns Outside switch
The patterns a switch arm uses work in several other places, which saves
writing a switch with one real arm and a default that does nothing.
// Patterns are not only for switch.
enum Event {
case click(x: Int, y: Int)
case key(Character)
case scroll(Int)
}
let events: [Event] = [
.click(x: 3, y: 4), .key("a"), .scroll(-2), .key("q"), .click(x: 5, y: 5),
]
// if case tests a single pattern.
if case .click(let x, let y) = events[0] {
print("first click at \(x),\(y)")
}
// for case keeps only the elements that match.
for case .key(let c) in events {
print("key", c)
}
// and it composes with where.
for case .click(let x, let y) in events where x == y {
print("click on the diagonal at \(x),\(y)")
}
// guard case is the early-exit form.
func firstScroll(_ events: [Event]) -> Int? {
for e in events {
guard case .scroll(let amount) = e else { continue }
return amount
}
return nil
}
print("first scroll:", firstScroll(events) ?? 0)
// Optional patterns work the same way, because Optional is an enum.
let maybes: [Int?] = [1, nil, 3, nil, 5]
for case let n? in maybes {
print("value", n)
}
first click at 3,4
key a
key q
click on the diagonal at 5,5
first scroll: -2
value 1
value 3
value 5
for case let n? in maybes is the idiom for walking a collection of optionals
and skipping the empty ones. It is compactMap said as a loop, and when the
body does real work it reads better.
The ~= operator is what all of this is built on, and you can give it an
overload for your own type to make your own values matchable in a case. That
is rarely needed and is worth knowing exists.
8.5 Enum or Class Hierarchy
Both model “one of several kinds of thing” and they fail in opposite directions.
An enum fixes the set of cases and makes adding an operation easy: write a new
function with a switch and you are done. Adding a case is the expensive
move, because every existing switch must be revisited, and the compiler makes
you do it.
A class hierarchy fixes the set of operations and makes adding a kind easy: write a new subclass and implement the methods. Adding an operation is the expensive move, because every existing class must grow a method, and nothing makes you do it, so one of them will be missed.
Choose by which axis moves. A set of commands, a set of token types, a set of states: the cases are known and the operations keep coming, so use an enum. A set of plugins, a set of shapes a drawing program can be extended with: the operations are known and the kinds keep coming, so use a protocol, which is Chapter 10.
When both keep moving, neither answer is comfortable, and that discomfort is real rather than a failure to pick correctly.