A Simple Sandwich, Part II

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 CondimentsNumber of Slices
0n/a (not a sandwich)
12 (top slice plain)
22 (inside surfaces smeared)
33 (top slice plain)
43
54 (top slice plain)
64
75 (top slice plain)
85

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


  1. … And was made out of Ruby code… ↩︎

  2. 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. ↩︎