Ash Furrow wrote up this post yesterday: http://ashfurrow.com/blog/protocols-and-swift/. It's a good post, you should read it. The problem he outlines is this:
"Here's the problem: you have two types that want to talk to each other."
Ash's solution to the problem was to use protocols to essentially turn the problem inside out. His solution definitely works and it's better than his original way of doing it. It did get me thinking about solving the same problem in maybe a slightly more flexible way.
The drawback with Ash's solution, in my opinion, is that it still takes a heavy leaning on an object-oriented paradigm. I don't think we need that, especially in this case.
If we break down the problem into parts, I think we end up with this:
- A set of nutritional information (calories and hydration level). 2. Types of food (wet and dry) that can affect that nutritional information. 3. An animal type (cat) that has a set of nutritional information and the
ability to eat food.
The issue with the object-oriented approach is that it coupled the notion of eating with the animal type (see the eat
extension). I don't think we really want that. Instead, we want to capture the relationship between food and nutrition through some eat
function.
Let's setup the types:
enum Food {
case Dry(caloriesPerGram: Double)
case Wet(caloriesPerGram: Double, hydrationPerGram: Double)
}
protocol Nutrition {
var calorieCount: Double { get set }
var hydrationLevel: Double { get set }
}
struct Cat: Nutrition {
var calorieCount: Double = 0
var hydrationLevel: Double = 0
}
Ok, so what do we have:
- The
Food
type is an enum because we have clear restrictions on the type of
food available and how it impacts Nutrition
.
- The
Nutrition
item is a protocol that defines the data that it needs. This
is done because we are saying that a cat "has a" set of nutrition information.
- The
Cat
is simply a data type that is made up ofNutrition
data.
The last thing we are missing is the ability to eat. If you are thinking about objects then I think the obvious thing to do is to add an eat
function on
Nutrition
. There is another approach though: thinking instead about the transformations of the data.
Because I care more about transforming data, I'm going to create a top-level function eat
:
func eat(var nutrition: Nutrition, food: Food, grams: Double) -> Nutrition {
switch food {
case let .Dry(caloriesPerGram):
nutrition.calorieCount += caloriesPerGram * grams
case let .Wet(caloriesPerGram, hydrationPerGram):
nutrition.calorieCount += caloriesPerGram * grams
nutrition.hydrationLevel += hydrationPerGram * grams
}
return nutrition
}
func eat<T: Nutrition>(nutrition: T, food: Food, grams: Double) -> T {
return eat(nutrition, food, grams) as T
}
There are a few things to note:
- The
var
usage in the parameter list just let's us get a copy of that
parameter and use it locally as a new variable. 2. There is absolutely no coupling with any of the types that could possibly
contain Nutrition
information. 3. There is a generic version so we do not have to cast when dealing with
concrete implementations of Nutrition
, such as Cat
.
The usage of the code looks like this:
let kibble = Food.Dry(caloriesPerGram: 40)
let fancyFeast = Food.Wet(caloriesPerGram: 80, hydrationPerGram: 0.2)
var dave = Cat()
dave = eat(dave, kibble, 30)
dave = eat(dave, fancyFeast, 20)
And then we can do some other more functional oriented operations a bit more naturally:
let cats = [Cat(), Cat(), Cat(), Cat(), Cat(), Cat(), Cat()]
let caloriesEatenByCats = reduce(map(cats) { cat in
eat(cat, kibble, 30) }, 0) { sum, cat in
sum + cat.calorieCount }
But here is where the really nice part of this choice comes along: if I want to add a Dog
type, I only need to create the struct for it I'm done.
struct Dog: Nutrition {
var calorieCount: Double = 0
var hydrationLevel: Double = 0
}
let dogs = [Dog(), Dog(), Dog(), Dog()]
let caloriesEatenByDogs = reduce(map(dogs) { dog in
eat(dog, kibble, 30) }, 0) { sum, dog in
sum + dog.calorieCount }
In Ash's implementation, you have to then go add an eat
function on each new type you are dealing with. We lose no flexility either because if a Dog
is supposed to eat
differently, we can just create an overload for it.
The other nice part is we can mix and match our Nutrition
types like this:
let animals: [Nutrition] = [Dog(), Cat()]
let caloriesEatenByAnimals = reduce(map(animals) { animal in
eat(animal, kibble, 30) }, 0) { sum, animal in
sum + animal.calorieCount }
It's just another way of thinking about the problem. Swift seems to be a language that values immutability and composition over mutability and if we start to think about solving problems that way, I think we end up with more solutions like I presented where it's really just about how we manipulate the data in composable ways.