A Simple Sandwich, Part I
Dec 28, 2022
I wrote this as a sort of response to a post by my friend Danny Fekete. For any of this to make sense, you’ll want to read his post first. These are my thoughts related to the ideas Danny brought up as well as Haskell code inspired by Danny’s Ruby code. I would describe the subject as programming philosophy.
Food for Thought
The point of the “exact instructions challenge” shown in this video is to explain to kids (or any non-programmers) what it’s like to write a computer program to solve a task. I used to think this was a good explanation, but now I’ve changed my mind. What’s being said is that giving a computer instructions to solve a task is challenging because you need to be very precise. The computer is dumb and won’t fill in the gaps; it will not necessarily do what you have in mind if you give it vague instructions. Instead, it does the wrong thing or produces an error. So let’s think about this. Why is it different from giving a human vague instructions? Well, a human can fill in the blanks. Why? Because they have the same context as the instruction-giver and both minds abstract the details. If a human did not have the proper context, they would do the task wrong, like the dad in the video. So, the dad is not acting specifically like a computer but rather any receiver with improper context that is attempting the task anyway. Realistically, if a computer has improper context to perform an instruction, it will produce an error rather than try to do it and fail. In whatever programming language you choose, if you try to run the function makeSandwich
and that function doesn’t exist, it will produce an error instead of trying to make a sandwich (whatever it means for a computer to make a sandwich…).
So really, what we’re talking about here is context and abstraction, not necessarily humans vs computers. Now you think I’m being pedantic. True, computers are usually the ones with less context and can’t do abstraction on their own. But programmers are the ones deciding what context is needed! If a language is made at a higher level of abstraction than another, it may need less context than the other to accomplish the same task. For example, in Haskell, I can write putStrLn (unwords ["Hello,", "world!"])
to print “Hello, world!”, but in C, I would have to write a lot more code (giving more context about how to make an array into a sentence of words). The fact is, no programmer writes code from scratch. Everyone is building upon other work, even if you’re including the language’s standard library. In fact, if I take the effort to write the unwords
function in C, then the resulting code is not too different from Haskell: printf("%s", unwords((char**){"Hello,", "world!"}, 2));
.
In the end, if someone (it doesn’t matter who) has made a makeSandwich
function and it makes a sandwich (somehow…), I can tell the computer to run that function and I don’t need to give it extra information. The same as telling a human who I trust to make a sandwich, “make me a sandwich”.
Now that we have sophisticated AI (demonstrated by tools like ChatGPT), computers actually have as much context as the average person for many problems. The AI can do spoken language processing that appears to be on par with humans. I can give it a problem to solve, written in plain English, and it will give a solution! Many people would be fooled into thinking they’re chatting with a human.
Programming a Sandwich
That said, let’s say we want to demonstrate what it would be like to program the computer to make a sandwich anyway. In Danny’s post, he wrote a great, complete example in Ruby code. After reading through it, it brings up some interesting points to talk about:
- Error handling
- Mutability and state
- Programming paradigms (object-oriented programming, functional programming)
- Type theory
Fortunately for me, Danny already handled the hardest part: deciding what functionality to include in the sandwich-making code. After all, we can’t expect the computer to make a real sandwich in the end, so one has to (somewhat arbitrarily) decide what a valid solution requires.
For my own exploration, I decided to take the following steps:
- Translate Danny’s OOP (object-oriented programming) solution into Haskell
- Write an idiomatic Haskell solution
- Write a Haskell solution using type-level programming
- Write an Agda solution
Before getting to the code, I want to express some thoughts about programming concepts.
Error Handling
Deciding how to handle errors is a notoriously tricky problem in programming. We have to think about what kind of errors our code may encounter. Are they logic-breaking errors, in which case we want our app to print a message and quit running when it encounters one? Or are they more like warnings, where we can print a message to the user and allow the code to continue running? Should we even handle them at all or just let the program break when it may? Maybe we should mix and match? Can we reduce the possible errors some other way?
Before I could write any Haskell solution to the sandwich-making problem, I had to decide how to handle errors. After all, many things can go wrong along the way, like attempting to put a knife in a closed jar of peanut butter.
Because Haskell is a purely functional language, every function is considered either to be “pure” or in a monad (such as “IO”). Using the monad concept is how Haskell gets around the “impure” or “side effect” capability while retaining a pure mathematical foundation. It is favourable to keep as many functions pure as possible, so handling errors in a pure way would be ideal.
Breaking errors
One option to handle errors is to simply break execution and print an error message whenever one happens. In Haskell, this would be done using the built-in error
function, which can be used in any function. But this comes with caveats:
- We can’t know which functions may produce an error by looking at their type signature.
- We can’t recover from errors.
Exceptions
Another option is to throw exceptions. Then we can catch errors, but it comes with a big caveat:
- Every function that may throw an exception (or include another function that may throw an exception) must be in the IO monad. That means virtually all the code has to live in the IO monad, and we don’t have any pure functions.
Maybe
A more idiomatic option in Haskell is to use the Maybe
type. Any function that might produce an error returns a Maybe a
value, which is either Nothing
or Just a
, where a
is any type. For example,
relinquishContents :: Jar -> Maybe (Jar, Condiment)
This version of relinquishContents
takes a Jar
and returns either Nothing
if it fails (because the jar is closed or empty) or a pair (Jar, Condiment)
of a new empty Jar
and a Condiment
. The problem with using Maybe
is we don’t have any message attached to the error side; we just have Nothing
. So we know something went wrong, but we don’t know what it is.
Either
Finally, the solution I settled on is the other idiomatic option in Haskell: the Either
type. It’s almost the same as Maybe
, except its values are Left a
and Right b
, where a
and b
are any types. This way, we have,
relinquishContents :: Jar -> Either String (Jar, Condiment)
In the error case, relinquishContents
returns a Left String
(such as Left "The jar is closed and knife-impermeable."
). In the good case, it returns a Right (Jar, Condiment)
. In other words, we either have an error message or a good return value.
Taking this path, every function that might produce an error must return an Either
type, which allows us to keep most of the code pure. Also, we have no choice but to write code which handles the errors where they may occur. We can’t simply skip over the fact that a function may produce an error; we have to handle both sides of the result: Left
and Right
.
Mutability and State
In OOP, it’s common to make an instance of an object with some properties and then mutate its properties along the way. For example, we may have a CondimentJar
with a contents
property that starts off as some string, like “Peanut Butter”. When we want to empty the jar, we set its contents
property to nil
, effectively mutating the state of the jar.
This can often make code easy to write but harder to follow. For example, in the Ruby solution’s Sandwich
class, I didn’t know what the build!
method was going to do when I first saw it. Based on the name, I figured it would build a sandwich and mutate the instance somehow. I didn’t know if it would also mutate something inside the sandwich, like the slices of bread. Without reading the rest of the code, how could I know what other variables might get mutated down the line? To discover the function’s purpose and result, I had to read its entire body. In Haskell, I only need to read a function’s type signature to know exactly what its capabilities are.
In pure functional code, there’s no such thing as mutability. When we have a CondimentJar
with its contents
set to “Peanut Butter”, we can’t simply change that jar. It will always have peanut butter as its contents. Instead of mutating the jar’s state, we must make a new jar which is a copy of the first one, but with its contents
set to a different value.
To people less familiar with the concept of immutability, this may seem like a burden, and sometimes it is! But really, it’s just a different perspective on writing code. Instead of keeping track of every variable and its current state at any point in the code, immutability ensures that no variables can ever change, so we can easily discern their value.
Now, this presents us with a philosophical problem in the sandwich-making context. What sense does it make to have a jar which is always full of peanut butter and a knife which is always clean, and when we put them to use we have a new empty jar and a new loaded knife? And does it make sense that we still have access to the old objects? The way I see it, we can think of this in different ways.
In one way, we can say we simply don’t care that it doesn’t represent the real world accurately and as long as we don’t make use of the old objects after we use them, we’re not doing anything we couldn’t do in reality. We just have to always use the latest version of each object.
Another way to think about it is that having access to the old objects is like being able to travel through time. We can think of every variable as being in a particular snapshot of the universe, which we can always go back to. However, this idea breaks down when we can access a new object and its older counterpart simultaneously, which is kind of like having multiple universes that can interact (like the Marvel multiverse).
The most accurate representation of state in a pure functional context is to keep all stateful things in a variable that must be passed as an argument to any function that may update state. This is like passing around the universe (or at least, the important things in it) so we only have access to one version of it at any point in time. In Haskell, there are libraries which handle this in a monad. Then we can write code which looks like we’re updating state, but anything that involves state must be inside the state monad.
In my code, I opted for the first approach; simply ignore the problem! I figure the sandwich-making is represented well enough and the code is simpler to understand.
Haskell Solution - OOP Translation
Since Haskell is a functional language, the following Haskell code is not idiomatic. It is a translation of the object-oriented Ruby solution. Similarly to how we might translate a poem from Portuguese to English word-for-word, the result may have proper grammar and spelling, but the English translation won’t sound poetic like it would if it were composed in English from the start.
Condiment.hs
module Condiment where
type Condiment = String
data OpenOrClosed = Open | Closed
deriving (Eq)
data Jar = Jar
contents :: Maybe Condiment
{ lid :: OpenOrClosed
,
}
newJar :: Condiment -> Jar
= Jar
newJar c = Just c
{ contents = Closed
, lid
}
isEmpty :: Jar -> Bool
Jar{contents=Nothing} = True
isEmpty = False
isEmpty _
hasStuff :: Jar -> Bool
= not . isEmpty
hasStuff
isClosed :: Jar -> Bool
Jar{lid=Closed} = True
isClosed = False
isClosed _
closeJar :: Jar -> Jar
= cj {lid=Closed}
closeJar cj
isOpen :: Jar -> Bool
= not . isClosed
isOpen
openJar :: Jar -> Jar
= cj {lid=Open}
openJar cj
relinquishContents :: Jar -> Either String (Jar, Condiment)
@Jar{contents=Just c}
relinquishContents cj| isClosed cj = Left "The jar is closed and knife-impermeable."
| isEmpty cj = Left "The jar is empty. How disappointing."
| otherwise = Right (cj{contents=Nothing}, c)
In this OOP translation, a Haskell record is defined for each of its Ruby class counterpart. In idiomatic Haskell, records are used frequently but not to represent classes/objects in such a way. And the small functions like isClosed
correspond to OOP methods or property accessors, which would be replaced by pattern matching in idiomatic Haskell.
In Condiment.hs, the newJar
function acts like an object constructor in OOP (e.g., Ruby’s initialize
method). It takes a Condiment
to tell it what the contents of the jar should be and gives back a closed jar full of that condiment.
-- Haskell
let pb = Condiment.newJar "Peanut Butter"
# Ruby
= CondimentJar.new("Peanut Butter") pb
As discussed above about error handling, relinquishContents
returns an Either
type which may either be an error message (Left String
) or a pair of a new empty jar and a condiment (Right (Jar, Condiment)
).
relinquishContents :: Jar -> Either String (Jar, Condiment)
@Jar{contents=Just c}
relinquishContents cj| isClosed cj = Left "The jar is closed and knife-impermeable."
| isEmpty cj = Left "The jar is empty. How disappointing."
| otherwise = Right (cj{contents=Nothing}, c)
Knife.hs
module Knife where
import qualified Condiment
data Knife = Knife
contents :: Maybe Condiment.Condiment
{
}
new :: Knife
= Knife {contents=Nothing}
new
isClean :: Knife -> Bool
Knife {contents=Nothing} = True
isClean = False
isClean _
clean :: Knife -> Knife
= k {contents=Nothing}
clean k
isLoaded :: Knife -> Bool
= not . isClean
isLoaded
loadFrom :: Knife -> Condiment.Jar -> Either String (Knife, Condiment.Jar)
loadFrom k cj| isLoaded k = Left "This knife is already loaded. Don't mix your condiments!"
| otherwise = uncurry load <$> Condiment.relinquishContents cj
where
= (k {contents=Just c}, cj') load cj' c
Interesting to note here is the decision to include loadFrom
in the Knife.hs module. It seemed right to put it here because the OOP version has loadFrom
as a method of the Knife
object. But in this Haskell version, loadFrom
is a pure function that happens to take a Knife
and a Condiment.Jar
as two arguments, so it doesn’t need to belong in any specific module. It would work just as well to put it in Main.hs. In fact, the same could be said for any of the functions. The choice to put them in a particular module is somewhat arbitrary; it simply makes sense intuitively to bundle them together based on context. In the case of loadFrom
, it would make just as much sense to put it in Condiment.hs or Main.hs.
Bread.hs
module Bread where
import qualified Condiment
import qualified Knife
data Surface = Surface
contents :: Maybe Condiment.Condiment
{
}
newSurface :: Surface
= Surface
newSurface = Nothing
{ contents
}
surfaceIsPlain :: Surface -> Bool
Surface {contents=Nothing} = True
surfaceIsPlain = False
surfaceIsPlain _
surfaceIsSmeared :: Surface -> Bool
= not . surfaceIsPlain
surfaceIsSmeared
data Slice = Slice
top :: Surface
{ bottom :: Surface
,
}
newSlice :: Slice
= Slice
newSlice = newSurface
{ top = newSurface
, bottom
}
sliceIsPlain :: Slice -> Bool
Slice {top=t, bottom=b}
sliceIsPlain = surfaceIsPlain t && surfaceIsPlain b
sliceIsSmeared :: Slice -> Bool
= not . sliceIsPlain
sliceIsSmeared
smearSurface :: Knife.Knife -> Surface -> Either String (Knife.Knife, Surface)
smearSurface k s| surfaceIsSmeared s = Left "This surface was already smeared!"
| Knife.isClean k = Left "This knife is too clean to smear with."
| otherwise = Right (Knife.clean k, s {contents=Knife.contents k})
Sandwich.hs
module Sandwich where
import qualified Bread
import qualified Condiment
import qualified Knife
import qualified Data.Maybe as Maybe
import qualified Data.List as L
data Sandwich = Sandwich
slices :: [Bread.Slice]
{ built :: Bool
, isCut :: Bool
,
}
new :: [Bread.Slice] -> Sandwich
= Sandwich
new slices = slices
{ slices = False
, built = False
, isCut
}
flavours :: Sandwich -> [Condiment.Condiment]
= concat . map sliceFlavours . slices
flavours where
sliceFlavours :: Bread.Slice -> [Condiment.Condiment]
= Maybe.catMaybes . map Bread.contents . sequence [Bread.top, Bread.bottom]
sliceFlavours
showFlavours :: Sandwich -> String
= f . flavours
showFlavours where
f :: [Condiment.Condiment] -> String
f cs| length cs == 2 = L.intercalate " and " cs
| otherwise = L.intercalate ", " (init cs) ++ ", and " ++ last cs
isReadyToEat :: Sandwich -> Bool
= built sw && isCut sw
isReadyToEat sw
build :: Sandwich -> Either String Sandwich
build sw| built sw = Left "It's already a glorious tower of food!"
| length (slices sw) < 2 = Left "Not enough slices"
| outsideSmeared = Left "This sandwich would be icky to hold."
| tooPlain = Left "This sandwich might actually be a loaf."
| otherwise = Right (sw {built=True})
where
bottomSmeared :: Bool
= Bread.surfaceIsSmeared . Bread.bottom . head $ slices sw
bottomSmeared
topSmeared :: Bool
= Bread.surfaceIsSmeared . Bread.top . last $ slices sw
topSmeared
outsideSmeared :: Bool
= length (slices sw) >= 2 && (bottomSmeared || topSmeared)
outsideSmeared
tooPlain :: Bool
= any Bread.sliceIsPlain . init . tail $ slices sw
tooPlain
cut :: Sandwich -> Knife.Knife -> Either String Sandwich
cut sw k| (not . built) sw = Left "Build the sandwich and then cut it in one glorious stroke."
| Knife.isLoaded k = Left "No! You'll get the edge all yucky with that knife."
| isCut sw = Left "One cut will do."
| otherwise = Right (sw {isCut=True})
Main.hs
module Main where
import qualified Condiment
import qualified Knife
import qualified Bread
import qualified Sandwich
main :: IO ()
= do
main let bread = replicate 5 Bread.newSlice
let pb = Condiment.newJar "Peanut Butter"
let jelly = Condiment.newJar "Jelly"
let knife = Knife.new
-- First attempt. Didn't open the jar of peanut butter.
either printError putStrLn $ do
<- knife `Knife.loadFrom` pb -- Problem
(pbKnife, pbEmpty) <- Bread.smearSurface pbKnife . Bread.top . head $ bread
(usedKnife1, surface1) <- knife `Knife.loadFrom` Condiment.openJar jelly
(jellyKnife, jellyEmpty) <- Bread.smearSurface jellyKnife . Bread.bottom . last $ bread
(usedKnife2, surface2) let sw = Sandwich.new bread
Sandwich.build swreturn "Sandwich made!"
-- Next attempt. Used too much bread inside.
either printError putStrLn $ do
<- knife `Knife.loadFrom` Condiment.openJar pb
(pbKnife, pbEmpty) <- Bread.smearSurface pbKnife . Bread.top . head $ bread
(usedKnife1, surface1) <- knife `Knife.loadFrom` Condiment.openJar jelly
(jellyKnife, jellyEmpty) <- Bread.smearSurface jellyKnife . Bread.bottom . last $ bread
(usedKnife2, surface2) let sw = Sandwich.new bread
-- Problem
Sandwich.build sw return "Sandwich made!"
-- Successful sandwich making!
either printError putStrLn $ do
<- knife `Knife.loadFrom` Condiment.openJar pb
(pbKnife, pbEmpty) <- Bread.smearSurface pbKnife . Bread.top . head $ bread
(usedKnife1, surface1) <- knife `Knife.loadFrom` Condiment.openJar jelly
(jellyKnife, jellyEmpty) <- Bread.smearSurface jellyKnife . Bread.bottom . last $ bread
(usedKnife2, surface2) let sw = Sandwich.new [head bread, last bread]
Sandwich.build swreturn "Sandwich made!"
where
printError :: String -> IO ()
= putStrLn ("Error: " ++ e) printError e
This module, Main.hs, is where things get more interesting. In Haskell, we always need a main
function that lives in the IO (input/output) monad, otherwise we would never be able to see any results from running our application. The functions that return an Either
type for errors are also used as a monad. Haskell’s do
notation uses monadic operations which end up looking like imperative instructions. It’s really just syntactic sugar for doing things sequentially and updating context as it goes. The interesting thing is that each code block containing a sandwich-making attempt acts similarly to try-catch
exception handling in other languages. If anything goes wrong, that is, if any function returns a Left
value, it gets printed with “Error:” before it. If all goes right, it prints the result of the block: “Sandwich made!”.
Again, there’s nothing stopping us from using pbKnife
more than once because we’re not keeping track of any state, so some care must be taken when making a sandwich.
In the end, I think this version is quite readable (if you know Haskell), though the modules contain more functions than needed, and the custom types are all records instead of more intuitive types, as in the following version.
What is a sandwich?
Something about the Sandwich
type (or Ruby class) doesn’t sit well with me. In my mind, it doesn’t make sense for a sandwich to have a built
property that says whether the sandwich is built correctly or not. What would it mean to have a sandwich that is not built? That sounds like something that is not a sandwich, which shouldn’t be part of the definition of what a sandwich is. If it is possible to make an instance of a sandwich that is not a proper sandwich, maybe the type/class/definition of a sandwich needs more refining. In this definition, a sandwich is allowed to have any number of slices. Wouldn’t it make more sense for a sandwich to require at least two slices of bread? Or, even more accurately, a sandwich requires exactly two slices of bread (any inner slices of bread can be considered part of the sandwich–unless that’s all it has, in which case it is a loaf). I see it as partway to an accurate definition of a sandwich but stopped short.
Haskell Solution - Idiomatic
In this version, I attempted a more idiomatic Haskell solution, using fewer records, more pattern matching, and more features of types.
I also took some liberties to reframe parts of the problem. In Danny’s code, I noticed a validation to make sure we’re using a knife where another utensil wouldn’t work.
def smear!(knife:, surface:)
unless knife.is_a?(Knife)
raise InvalidKnifeError, "That's not hygienic."
end
# ...
I decided if we’re going to be checking that we’re using a knife, we may as well include other utensils. Otherwise, the only kind of utensil the program knows about is a knife. In Haskell, we never need to check whether a value is of a particular type; that’s made explicit by static typing, and the compiler does the type-checking work.
I took a similar approach to bread. We may also include different flavours of bread to make things more interesting.
Because this version is idiomatic code, it’s also much shorter, so I put it all in a single module.
Main.hs
module Main where
data UtensilShape = Knife | Spoon | Fork
deriving (Show, Eq)
data Utensil = Utensil
uShape :: UtensilShape
{ uCondiment :: Maybe Condiment
,
}deriving (Show)
fetchUtensil :: UtensilShape -> Utensil
= Utensil
fetchUtensil shape = shape
{ uShape = Nothing
, uCondiment
}
data Condiment = PeanutButter | Jelly
deriving (Show, Eq)
data OpenOrClosed = Open | Closed
deriving (Show, Eq)
data CondimentJar = CondimentJar
cjCondiment :: Maybe Condiment
{ cjLid :: OpenOrClosed
,
}deriving (Show)
fetchCondimentJar :: Condiment -> CondimentJar
= CondimentJar
fetchCondimentJar c = Just c
{ cjCondiment = Closed
, cjLid
}
loadFrom :: Utensil -> CondimentJar -> Either String (Utensil, CondimentJar)
CondimentJar{cjLid=Closed} = Left "The jar is closed and knife-impermeable."
loadFrom _ CondimentJar{cjCondiment=Nothing} = Left "The jar is empty. How disappointing."
loadFrom _ Utensil{uShape=Fork} _ = Left "Forks aren't the right shape for condiments."
loadFrom @CondimentJar{cjCondiment=Just c}
loadFrom u cj= Right (u { uCondiment = Just c }, cj { cjCondiment = Nothing })
openJar :: CondimentJar -> CondimentJar
= cj { cjLid = Open }
openJar cj
data BreadFlavour = Sourdough | WholeGrain | White
deriving (Show)
data SliceOfBread = SliceOfBread
sobFlavour :: BreadFlavour
{ sobTop :: Maybe Condiment
, sobBottom :: Maybe Condiment
,
}deriving (Show)
fetchSliceOfBread :: BreadFlavour -> SliceOfBread
= SliceOfBread
fetchSliceOfBread flavour = flavour
{ sobFlavour = Nothing
, sobTop = Nothing
, sobBottom
}
data Surface = Top | Bottom
deriving (Show, Eq)
smearSliceOfBread :: Utensil -> Surface -> SliceOfBread -> Either String (SliceOfBread, Utensil)
smearSliceOfBread u surface slice| uShape u /= Knife = Left "You can't smear with that!"
| uCondiment u == Nothing = Left "This knife is too clean to smear with."
| surface == Top && sobTop slice /= Nothing = Left "This surface was already smeared!"
| surface == Bottom && sobBottom slice /= Nothing = Left "This surface was already smeared!"
| otherwise = Right (smearedSlice, cleanUtensil)
where
smearedSlice| surface == Top = slice { sobTop = uCondiment u }
| surface == Bottom = slice { sobBottom = uCondiment u }
= u { uCondiment = Nothing}
cleanUtensil
data Sandwich = Sandwich
swBottom :: SliceOfBread
{ swTop :: SliceOfBread
, swPieces :: [(SliceOfBread, SliceOfBread)]
,
}deriving (Show)
makeSandwich :: SliceOfBread -> SliceOfBread -> Either String Sandwich
makeSandwich bottom top| sobTop bottom == Nothing && sobBottom top == Nothing = Left "This sandwich might actually be a loaf."
| sobTop top /= Nothing || sobBottom bottom /= Nothing = Left "This sandwich would be icky to hold."
| otherwise = Right Sandwich { swBottom = bottom, swTop = top, swPieces = [(bottom, top)] }
-- A sandwich is always cut through all the pieces, doubling them all
cutSandwich :: Utensil -> Sandwich -> Either String Sandwich
cutSandwich u sw| uShape u == Fork || uShape u == Spoon = Left "You can't cut a sandwich with that!"
| uCondiment u /= Nothing = Left "No! You'll get the edge all yucky with that knife."
| otherwise = Right sw { swPieces = newPieces }
where
= concat [swPieces sw, swPieces sw]
newPieces
main :: IO ()
= do
main let knife = fetchUtensil Knife
let pb = fetchCondimentJar PeanutButter
let jelly = fetchCondimentJar Jelly
-- First attempt. Didn't open the jar of peanut butter.
either printError putStrLn $ do
<- knife `loadFrom` pb -- Problem
(pbKnife, emptyPB) return "Sandwich made!"
-- Next attempt. Too plain.
either printError putStrLn $ do
<- knife `loadFrom` openJar pb
(pbKnife, emptyPB) <- knife `loadFrom` openJar jelly
(jellyKnife, emptyJelly) let bottomSlice = fetchSliceOfBread Sourdough
let topSlice = fetchSliceOfBread WholeGrain
<- makeSandwich bottomSlice topSlice
sw return "Sandwich made!"
-- Successful sandwich making!
either printError putStrLn $ do
<- knife `loadFrom` openJar pb
(pbKnife, emptyPB) <- knife `loadFrom` openJar jelly
(jellyKnife, emptyJelly) let bottomSlice = fetchSliceOfBread Sourdough
let topSlice = fetchSliceOfBread WholeGrain
<- smearSliceOfBread pbKnife Top bottomSlice
(bottomSliceWithPB, cleanKnife) <- smearSliceOfBread jellyKnife Bottom topSlice
(topSliceWithJelly, cleanKnife) <- makeSandwich bottomSliceWithPB topSliceWithJelly
sw return "Sandwich made!"
where
printError :: String -> IO ()
= putStrLn ("Error: " ++ e) printError e
To replace the new
functions from the OOP-translated version, I included functions like fetchCondimentJar
, which act similarly. The different naming convention (“fetch-” instead of “new-”) is because I started thinking of the instance-creating functions as being analogous to fetching something from the kitchen. When it’s time to get a condiment jar, we can use fetchCondimentJar
to fetch one of the given condiments. Interestingly, these idiomatic functions still behave much like OOP constructors.
I also merged the previous Slice
and Bread
into a single type, SliceOfBread
:
data SliceOfBread = SliceOfBread
sobFlavour :: BreadFlavour
{ sobTop :: Maybe Condiment
, sobBottom :: Maybe Condiment
, }
A slice of bread has a top and bottom, both of which can be smeared with a condiment or nothing (hence the Maybe Condiment
type). There’s no need for a separate type just for Slice
.
Another difference is the type definition for Surface
. Instead of using a record, a surface only needs to represent the top or bottom of a slice of bread, so a surface can be a sum type (a choice between multiple values):
data Surface = Top | Bottom
As for the Sandwich
type and its shortcomings discussed above, it has been updated in this version.
data Sandwich = Sandwich
swBottom :: SliceOfBread
{ swTop :: SliceOfBread
, swPieces :: [(SliceOfBread, SliceOfBread)]
, }
Now, a sandwich doesn’t have a built
property because if an instance of a sandwich exists, it is because it was built. Still, it could be improved. After all, with this definition, it’s easy to make a sandwich that doesn’t make sense:
-- A sandwich whose top and bottom slices are sourdough,
-- but consists of a single piece whose slices are whole grain
= Sandwich
impossibleSandwich = fetchSliceOfBread Sourdough
{ swBottom = fetchSliceOfBread Sourdough
, swTop = [(fetchSliceOfBread WholeGrain, fetchSliceOfBread WholeGrain)]
, swPieces }
No matter how much error handling we add along the way to making a sandwich, our definition of a sandwich makes it possible to skip the error checks and create an erroneous sandwich. Even if we force the user only to make a sandwich using the proper functions, we have to be sure we include checks for all the possible mistakes that could be made. Are we sure we didn’t miss one?
The other approach is to avoid possible errors altogether by using type-safe definitions, making it so a sandwich can only be made when its type is fulfilled. To relate this to the video, the kids are frustrated because their father is failing in ways they didn’t expect him to fail. He’s doing things they didn’t want him to do. On the computer, why would we program the ability to do things we don’t want to happen? We don’t want it to be possible to attempt to put a knife in a closed jar, so we shouldn’t make a function where that can happen. Type systems can help us resolve this. Stay tuned for Part II…
See the full Haskell code on GitHub: https://github.com/SlimTim10/simple-sandwich