A Simple Sandwich, Part II
Adding competence, if not intelligence, to a heartless universe.
This post is the second part of a brief, pointless series in which I explore how to make a sandwich in Ruby in response to a video where a father maliciously and capriciously complies with his children’s well-intentioned sandwich-building instructions.
If you haven’t read Part I yet, you should probably do that first.
… And, if you’d prefer to just read the code and judge me, here’s the gist.
In servile accordance with the dictates of whimsy, I have formed a universe for making sandwiches. But as we have seen, sandwiches are hard: there are many components and tools and avenues for failure. So, when we last considered the myriad fail-states, I resolved to create an intelligence that would ensure smooth operation. Practically speaking, I want to build a class that leverages knowledge of the system so that it can make sandwiches correctly, at which point I can just ask it to make_me_a_sandwich
.
If the dad in the previous post’s video had been taking his role as patriarch and provider seriously1, what we should have seen was:
daddy = Chef.new
lunch = daddy.make_me_a_sandwich!("Peanut Butter", "Jelly")
#=> Poof: you're a sandwich. (Chef::TakenForGrantedError)
… Well. Courtesy is important. What we should have seen was:
daddy = Chef.new
lunch = daddy.please.make_me_a_sandwich!("Peanut Butter", "Jelly")
pp lunch
#=>
#<Sandwich:0x00007feb529d4398
@built=true,
@cut=true,
@slices=
[#<SliceOfBread:0x00007feb529d4c80
@bottom=#<SliceOfBread::Surface:0x00007feb529d4a78 @contents=nil>,
@top=
#<SliceOfBread::Surface:0x00007feb529d4bb8 @contents="Peanut Butter">>,
#<SliceOfBread:0x00007feb529d6080
@bottom=#<SliceOfBread::Surface:0x00007feb529d4d98 @contents="Jelly">,
@top=#<SliceOfBread::Surface:0x00007feb529d5838 @contents=nil>>]>
My Two Dads
As I built my Chef
class, I struggled between two approaches for a while. And, although I landed on the version you’ll see in this article, it’s been suggested to me that I address both (and I’ll ultimately try to build both).
Today’s Chef
operates in a way that resembles my brother-in-law and my wife when they cook – at least from my perspective: because they command all the necessary (arcane) knowledge for the cooking task ahead of time, their planning and preparation circumvents all conceivable procedural problems. That is, there’s no room for mistakes because the tools, environment, and request are all vetted and corrected before the operation begins; all that remains is to watch a glorious monument to competence and inevitability construct itself and deliver delicious success.
I appreciate that by pedestalling my family this way I both essentialize the skill and intelligence they’ve worked years to build and I ignore the ongoing creativity and problem-solving they do while they work and encounter (even for them) unexpected complications. … But it’s my class and I know how to build a sandwich in my universe, so I’ll construct a flawless sandwich maker. Crucially, this sandwich maker will never encounter the exception classes I built in the previous article; it doesn’t know they exist and can’t rescue
them, but as long as the nature of sandwiches in my universe never changes, that won’t matter. This is a brittle approach, but for now, we’ll get a sandwich.
Initialization and Pleasantries
Let’s start with the exceptions and initializer for Chef
. Instances of Chef
will manipulate the components of our sandwich universe, so they should have the option of initializing with those components and will store them as the properties @condiments
, @knife
, and @slices_of_bread
.
I’ve also built in a sense of self-respect with @under_appreciated
because courtesy is important.
class Chef
class TakenForGrantedError < StandardError; end
class UnspeakablyBlandError < StandardError; end
def initialize(condiments: [], knife: nil, slices_of_bread: [])
@condiments = condiments
@knife = knife
@slices_of_bread = slices_of_bread
@under_appreciated = true
end
# ...
Courtesy is easy in this universe:
class Chef
# ...
def please
@under_appreciated = false
self
end
# ...
(Chef#please
returns the instance (self
) in order to allow for chaining, as in daddy.please.<carry_on>
.) The only public method left to cover, then, is #make_me_a_sandwich!
.
Do it.
Okay.
Sanity Check
Chef#make_me_a_sandwich!
takes an arbitrary number of arguments to represent the desired smearables; these requests should come in as simple strings to represent the fact that the user (ravenous child, etc.) doesn’t need to have deep knowledge about the structure and procedures of Sandwich Universe to make the request: Chef
will take care of it. What is needed is a sane request (at least one non-nil
, String
-type flavour).
And, you know. That other important thing.
class Chef
# ...
def make_me_a_sandwich!(*requested_flavours)
if @under_appreciated # Courtesy is important!
raise TakenForGrantedError, "Poof: you're a sandwich."
end
@under_appreciated = true # You get ONE request per courtesy.
# No vacuum- or integer-sandwiches please; they give me a headache.
requested_flavours.keep_if { |flavour| flavour.is_a? String }
if requested_flavours.count < 1
raise UnspeakablyBlandError, "I think you might just want some dry toast."
end
# ...
These gate conditions account for the two custom exceptions Chef
knows: TakenForGrantedError
and UnspeakablyBlandError
. If we’ve made it through that gauntlet, we are guaranteed a sandwich, says Chef
.
Don’t mess up.
Here’s how:
class Chef
# ...
def make_me_a_sandwich!(*requested_flavours)
# ... Gate Conditions / Sanity Checks Done
# Let's make sure we've got what we need and fix any problems.
ensure_condiments_are_actually_condiments
ensure_bread_is_actually_bread
ensure_knife
ensure_condiments_are_available(requested_flavours)
ensure_enough_bread_is_available(requested_flavours)
# ...
This series of private method calls serves as a checklist to make sure the inventory of the particular Chef
instance taking the order is sufficiently stocked.2 The first three will discard any unsuitable components that might already be on-hand and confuse the process (with #ensure_knife
taking the extra step of grabbing one if needed):
class Chef
# ...
private
# Throw out any empty bottles and/or cans of paint.
def ensure_condiments_are_actually_condiments
@condiments.keep_if do |condiment|
condiment.is_a?(CondimentJar) and condiment.has_stuff?
end
end
# Keep our existing bread collection sane and unsullied.
def ensure_bread_is_actually_bread
@slices_of_bread.keep_if do |slice|
slice.is_a?(SliceOfBread) and slice.plain?
end
end
# Safety first.
def ensure_knife
return if @knife.is_a?(Knife)
puts "I'll probably want a knife for this..."
@knife = Knife.new
puts "... Full tang mokume gane butter knife. Perfect."
end
# ...
Condiment-Get
The remaining insurance methods are more sensitive to the specific nature of the requested sandwich, so let’s look at them individually. The easy one is #ensure_condiments_are_available
: given the requested flavours, check what’s on hand and go to the store to pick up anything that’s missing.
class Chef
# ...
private
# ...
# Gather any missing condiments...
def ensure_condiments_are_available(requested_flavours)
missing_condiments = [ ]
# Let's avoid multiple trips to the store by taking stock before we go.
requested_flavours.each do |flavour|
unless @condiments.find { |jar| jar.contents == flavour}
missing_condiments << flavour
end
end
if missing_condiments.any?
puts "Looks like we could use a few things from the store..."
missing_condiments.each do |condiment|
@condiments << CondimentJar.new(condiment)
puts "Bought me some #{condiment}"
end
puts "There we go. That's better."
end
end
# ...
Bread-Get
The final method, #ensure_enough_bread_is_available
, requires a bit of knowledge about Sandwichness. As a quick recap, we’re treating a valid sandwich as one which has at least two slices of bread with the outside surfaces plain, at least one condiment smeared on an inside surface, and only one condiment to an inside surface. There are to be no plain slices of bread in our sandwich except possibly the final slice (because if the last smeared surface was the top of a slice, the sandwich will need an extra, unsmeary “hat” to complete it).
This means the following holds true:
Number of Condiments | Number of Slices |
---|---|
0 | n/a (not a sandwich) |
1 | 2 (top slice plain) |
2 | 2 (inside surfaces smeared) |
3 | 3 (top slice plain) |
4 | 3 |
5 | 4 (top slice plain) |
6 | 4 |
7 | 5 (top slice plain) |
8 | 5 |
So, to figure out how much bread is required for a sandwich with n slices, we take half the number of condiments, round up, and add one. Then, just grab bread from the bag until we’ve got the required amount:
class Chef
# ...
private
# ...
# Make sure we won't run out in the middle of the operation.
def ensure_enough_bread_is_available(requested_flavours)
sufficient_slices = 1 + (requested_flavours.count / 2.0).ceil
return if @slices_of_bread.count >= sufficient_slices
puts "This isn't enough bread to make your sandwich. Let me just grab some more."
while @slices_of_bread.count < sufficient_slices
@slices_of_bread << SliceOfBread.new
puts "Yoink!"
end
puts "... There we go. Pile o' bread!"
end
Compose Food
Returning to #make_me_a_sandwich!
, we’ve now created a safe environment in which a competent sandwich artisan could get on with business.
- Starting with a blank sandwich, we’ll iterate through our condiments and smear slices of bread, starting with the top surface of the first slice (leaving the bottom plain and unyucky to hold) and then alternating surfaces until we run out of ingredients.
- Whenever we wind up with a slice that has its top surface smeared, we’ll add it to the sandwich stack and grab some fresh bread.
- Once all the condiments have been applied and slices stacked, if the top of the stack is smeary, we’ll slap on a final slice of bread.
build!
,cut!
, see that it is good, and set it free.
class Chef
# ...
def make_me_a_sandwich!(*requested_flavours)
# Gate Conditions / Sanity Checks Done ...
# Insurance Methods Done ...
# Create an empty proto-sandwich. Really, just the idea of a sandwich.
sandwich = Sandwich.new
@knife.clean!
surface_to_smear = :top
current_slice = @slices_of_bread.pop
requested_flavours.each do |flavour|
condiment = @condiments.find { |jar| jar.contents == flavour}
condiment.open!
@knife.load_from!(condiment)
current_slice.smear!(
knife: @knife,
surface: surface_to_smear
)
# If ready, add the current slice to the stack and grab another.
if current_slice.top.smeared?
sandwich << current_slice
current_slice = @slices_of_bread.pop
end
# Swap the target surface for the next condiment.
surface_to_smear = (surface_to_smear == :top ? :bottom : :top)
end
# Add the final slice
sandwich << current_slice
# The magic happens
sandwich.build!
sandwich.cut!(@knife)
if sandwich.ready_to_eat? # How could it not be? Still.
puts "Finit. One #{sandwich.flavours_human_readable} sandwich; bon appétit!"
return sandwich # Needlessly explicit "return"; assume to be flourish.
else
puts "Hmm. Something went wrong despite my genius."
end
end
Inevitable Success
daddy = Chef.new
lunch = daddy.please.make_me_a_sandwich!(
"Relish",
"Marmite™",
"Nutella™",
"Sriracha"
)
#=>
I'll probably want a knife for this...
... Full tang mokume gane butter knife. Perfect.
Looks like we could use a few things from the store...
Bought me some Relish
Bought me some Marmite™
Bought me some Nutella™
Bought me some Sriracha
There we go. That's better.
This isn't enough bread to make your sandwich. Let me just grab some more.
Yoink!
Yoink!
Yoink!
... There we go. Pile o' bread!
Finit. One relish, marmite™, nutella™, and sriracha sandwich; bon appétit!
pp lunch
#=>
#<Sandwich:0x00007f0ced4a2d78
@built=true,
@cut=true,
@slices=
[#<SliceOfBread:0x00007f0ced4a31d8
@bottom=#<SliceOfBread::Surface:0x00007f0ced4a30e8 @contents=nil>,
@top=#<SliceOfBread::Surface:0x00007f0ced4a3138 @contents="Relish">>,
#<SliceOfBread:0x00007f0ced4a3408
@bottom=#<SliceOfBread::Surface:0x00007f0ced4a3318 @contents="Marmite™">,
@top=#<SliceOfBread::Surface:0x00007f0ced4a33b8 @contents="Nutella™">>,
#<SliceOfBread:0x00007f0ced4a38e0
@bottom=#<SliceOfBread::Surface:0x00007f0ced4a36b0 @contents="Sriracha">,
@top=#<SliceOfBread::Surface:0x00007f0ced4a3840 @contents=nil>>]>
Again, while this approach furnishes the user a mouth-wateringly valid sandwich smeared with one or more condiments, it feels like a bit of a cheat: Chef
never discovers a missing ingredient or misconfigured spreading implement while up to its elbows in proto-food (the way I do); Chef
never needs to problem-solve on the fly, heroically rescuing a meal from self-induced inedibility (the way I do); Chef
never needs to cry.
We should fix that.
… But first, Contorno
… And was made out of Ruby code… ↩︎
Incidentally, the checklist is order-dependent as it’s presented here which adds a bit of unneeded brittleness. It would be smarter for me to call some of these methods within the other methods (like
#ensure_bread_is_actually_bread
as the first step of#ensure_enough_bread_is_available
so that nothing can contaminate my precious loaf between steps); with that caveat, the checklist in my example is exploded because it’s a bit more intuitive to follow, I hope. ↩︎