Raw value initializers for enums with associated types
Once you add associated types to an enum the task of creating instances quickly becomes very repetitive. See how a simple trick can greatly simplify this
Enums
are a beautiful way of structuring information in Swift. Sometimes you
find yourself initializing enums from raw values, maybe because the
values were intermittendly stored somewhere else, say in the
NSUserDefaults
:
enum Device: String {
case phone, tablet, watch
}
let aDevice = Device(rawValue: \"phone\")
print(aDevice)
Prints Optional(main.Device.phone)
The Problem
As soon as you're using associated values in your enums, this doesn't work anymore:
enum Example {
case Factory(workers: Int)
case House(street: String)
}
Swift can't create an instance of Example
because the two cases,
Factory and House have different associated types (the workers
integer and the street
string. Each invocation of Example
requires
different parameters, so this can't be generalized.
However, that's not the case when your associated types all match up:
enum Device {
case phone(name: String, screenSize: CGSize)
case watch(name: String, screenSize: CGSize)
case tablet(name: String, screenSize: CGSize)
}
In thise case, all the associated types
are the same. There're a
myriad of other ways to model this, but I found the device enum
to be
a concise example for what I'm about to explain. Even though every
Device
invocation is the same now, you still can't just call it with
some sort of raw value and expect the correct type. Instead, what you
have to do is perform a
match
in order to create the correct instance:
import Foundation
enum Device {
case phone(name: String, screenSize: CGSize)
case watch(name: String, screenSize: CGSize)
case tablet(name: String, screenSize: CGSize)
static func fromDefaults(rawValue: String, name: String, screenSize: CGSize) -> Device? {
switch rawValue {
case \"phone\": return Device.phone(name: name, screenSize: screenSize)
case \"watch\": return Device.watch(name: name, screenSize: screenSize)
case \"tablet\": return Device.tablet(name: name, screenSize: screenSize)
default: return nil
}
}
}
let b = Device.fromDefaults(rawValue: \"phone\", name: \"iPhone SE\", screenSize: CGSize(width: 640, height: 1136))
print(b)
prints Optional(main.Device.phone(\"iPhone SE\", (640.0, 1136.0)))
This looks ok, but it is already a bit of repetitive code. Once you develop more than just three enum cases / two associated types, this will quickly get out of hand.
enum Vehicle {
case .car(wheels: Int, capacity: Int, weight: Int, length: Int, height: Int, width: Int, color: Int, name: Int, producer: Int, creation: NSDate, amountOfProducedUnits: Int)
case .ship(wheels: Int, capacity: Int, weight: Int, length: Int, height: Int, width: Int, color: Int, name: Int, producer: Int, creation: NSDate, amountOfProducedUnits: Int)
case .yacht(wheels: Int, capacity: Int, weight: Int, length: Int, height: Int, width: Int, color: Int, name: Int, producer: Int, creation: NSDate, amountOfProducedUnits: Int)
case .truck(wheels: Int, capacity: Int, weight: Int, length: Int, height: Int, width: Int, color: Int, name: Int, producer: Int, creation: NSDate, amountOfProducedUnits: Int)
case .motorbike(wheels: Int, capacity: Int, weight: Int, length: Int, height: Int, width: Int, color: Int, name: Int, producer: Int, creation: NSDate, amountOfProducedUnits: Int)
case .helicopter(wheels: Int, capacity: Int, weight: Int, length: Int, height: Int, width: Int, color: Int, name: Int, producer: Int, creation: NSDate, amountOfProducedUnits: Int)
case .train(wheels: Int, capacity: Int, weight: Int, length: Int, height: Int, width: Int, color: Int, name: Int, producer: Int, creation: NSDate, amountOfProducedUnits: Int)
...
}
I think you get my point.
The Solution
So.. how do we solve this? Interestingly, there's a quirky similarity between the initializer of an associated type and a closure. Take this code:
enum Example {
case test(x: Int)
}
let exampleClosure = Example.test
What is the type of exampleClosure
here? The type is
(Int) -> Example
. That's right, calling an associated value enum
case without any parameters will yield a closure that, when called with
the correct types, will return an instance of said type.
This means that, the following is valid, working Swift:
enum Fruit {
case apple(amount: Int)
case orange(amount: Int)
}
let appleMaker = Fruit.apple
let firstApple = appleMaker(amount: 10)
let secondApple = appleMaker(amount: 12)
print(firstApple, secondApple)
So, how would that help us simplify the gross code duplication problem above? Have a look:
import Foundation
enum Device {
case phone(name: String, screenSize: CGSize)
case watch(name: String, screenSize: CGSize)
case tablet(name: String, screenSize: CGSize)
private static var initializers: [String: (name: String, screenSize: CGSize) -> Device] = {
return [\"phone\": Device.phone, \"watch\": Device.watch, \"tablet\": Device.tablet]
}()
static func fromDefaults(rawValue: String, name: String, screenSize: CGSize) -> Device? {
return Device.initializers[rawValue]?(name: name, screenSize: screenSize)
}
}
let iPhone = Device.fromDefaults(rawValue: \"phone\", name: \"iPhone SE\", screenSize: CGSize(width: 640, height: 1134))
print(iPhone)
Optional(main.Device.phone(\"iPhone SE\", (640.0, 1134.0)))
So, let's try to figure out what happened here. We have a new property
initializers
on our Device
. It's a Dictionary
of type
[String: (name: String, screenSize: CGSize) -> Device]
. I.e. something
that maps from a String
key to a closure with the same type as our
Device
cases. The dictionary contains the initializers of each of our
distinct cases, simply by using the same trick as above, just handing in
the closure: phone:Device.phone
The fromDefaults
function, then, only has to know the key of the
device we'd like to create, and it can call the appropriate closure.
This leads to a much shorter implementation, especially for bigger enums
(like our Vehicle example above). As you can see, creating a
Device
instance is then as simple as:
Device.initializers[\"phone\"]?(name: \"iPhone 5\", screenSize: CGSize(width: 640, height: 1134)))
Just as with raw values, in case there is no enum
case phone we'd
just get an empty optional back.
This solution isn't perfect of course. You still have to have the
initializers
dictionary, however it will be much less repetitve than
having to match
over all cases manually.
Finally, I suppose it goes without saying that the code above ignores an
important best practice to be concise and to be able to concentrate on
the task at hand; Nevertheless: having stringified code like
Device.initializers["phone"]
is not the best way to write this.
Instead, those keys should be properly defined somewhere else.