A Simple Sandwich, Part I

A Simple Sandwich, Part I

A deeply silly exploration of sandwich-building instructions in Ruby, inspired by assumptions we make about abstraction and some amusing cruelty to children.

In the maelstrom of Reddit and Twitter links that constitutes my family Signal channel, my brother-in-law shared this video, asking “Danny, is this programmer humour?”

Andrew’s question was tricky for me: the kids’ experience is often my experience when I program, yet when it is my experience, THERE IS NOTHING FUNNY ABOUT IT. But yes, it’s easy to make an analogy to computers here: a computer will tirelessly, uncomplainingly, uncreatively, unintuitively, bloodymindedly follow your instructions as written (so long as they’re written in a shared language), so any failure to achieve desired results necessarily devolves to your failure to give good instructions.1

Watching the video, though, actually got me thinking more about education than programming. During Curriculum Nights at Dunblaine, April Turner, the teacher of the youngest classroom, often leads the parents of her students through a process similar to that shown in the video in order to help them appreciate what it feels like to operate with executive dysfunction. Many of our kids, through inexperience or exceptionality, get overwhelmed by the granular elements of what most people would consider simple tasks. If everything you try to do is crushingly complicated, easily derailed by error, and demands most or all (or more than all) of your working memory to perform, it becomes easier to see why these tasks take longer than they “should”, why kids get exhausted or give up “too easily” and develop learned helplessness, and why disengagement and despondency follow.

Reducing complexity through abstraction is a good strategy in teaching as in programming. For overwhelmed students, “chunking” tasks into rehearsable (and masterable) components, and then accreting small chunks into larger chunks, can eventually help them go from “stick the flat edge of the knife into the previously-opened jar and then drag it around until the condiment adheres to it and then withdraw it and then rub the adhered condiment as evenly as possible against an exposed broad surface of the bread (but not so hard that the bread tears (moderate application strength with reference to the relative densities of the bread and condiment (also, if applicable, account for treacherous and brittle resilience due to the degree of toastedness)))” to “butter the bread”. Similarly, in programming, we can avoid getting overwhelmed by abstracting chunks of code into functions.

With reference to the video, I observed to Andrew that open_container(container) seems to be in Dad’s standard library2, but he doesn’t seem to have a load_knife(container) or spread_condiment(knife, condiment, bread) function handy, so when he gets to these steps, he follows inconsistently implemented and often unreliable instructions, alas.

From Andrew, this earned me:

Twitching Eye Squirrel

… Which is fair. I’m not sure Andrew’s familiar with the <function_name>(<parameters>)> syntax or my assumed (perhaps unwise) logical flow of passing objects to functions as arguments3. So, instead, I tried to illustrate the process with objects and methods: I love how Ruby can often look like plain English and I wanted to share this with him.4

def load_knife(knife, container)
  if knife.contents.present?
    raise "Error!  This knife already has something on it.  Yuck!"
  end

  if container.closed?
    container.open!
  end

  if container.contents.nil?
    raise "Error!  This container is empty!"
  end

  knife.contents = container.contents

  return knife
end

To me, this seemed much clearer (just so long as you don’t actually program in Ruby; then, it probably just raises questions and code smells). Yet, Andrew was unilluminated and memed: Nope

Sandwiches are hard. And, like a good teacher, I gave up on him and left him to continue managing his restaurants.5 … But I developed an itch to take a real shot at some sandwich-making code. And you’re still here, whoever you are, so let’s make a sandwich.


Building the Blueprints

Now, in Rubyland, if you want to make a sandwich from scratch, you must first invent the universe. Or at least some classes. I wanted to start by reaching parity with my back-of-the-napkin assertions above. So we need the concept of a condiment jar, a knife, and some bread.

An approach you’ll see me using here is to start my classes with namespaced custom exception declarations that inherit from StandardError. While I (rather, one) could make them function in bespoke and baroque ways, for our purposes, you can treat these as a list of things that could go wrong.

class PaneOfRawSheetGlass
  class ButterFingersError < StandardError; end  # Oh no.
  class OpenManHoleError < StandardError; end  # Oh geez.
  class SpecialAgentFootchaseError < StandardError; end  # Oh my.
end

Deeper inside the classes, you’ll see me raise them with an explanatory message that gets printed to the console if the exception isn’t ultimately rescued elsewhere.

The Matter

Here’s a CondimentJar blueprint. It can be opened, it can be full, and it can be empty. Importantly, it can relinquish its contents.

class CondimentJar
  class ContainerClosedError < StandardError; end
  class ContainerEmptyError < StandardError; end

  attr_accessor :contents

  def initialize(contents = nil)
    @contents = contents  # Jar is empty by default.
    @state_of_the_lid = :closed  # I wasn't raised in a barn.
  end

  def empty?
    contents.nil?
  end

  # All jars contain one portion for simplicity.
  def has_stuff?
    !empty?
  end

  def closed?
    @state_of_the_lid == :closed
  end

  def close!
    @state_of_the_lid = :closed
  end

  def open?
    @state_of_the_lid == :open
  end

  def open!
    @state_of_the_lid = :open
  end

  def relinquish_condiment!
    if closed?
      raise ContainerClosedError, "The jar is closed and knife-impermeable."
    end

    if empty?
      raise ContainerEmptyError, "The jar is empty.  How disappointing."
    end

    # Provide the contents and empty the container
    return contents.tap { |t| self.contents = nil }
  end
end

The Vector

Next, the idea of Knife would be helpful as a tool for transferring condiments from jars to bread. I initially designed this class with a #load_from method that could open the condiment jar if necessary (as in my rough example for Andrew), but on read-through now, it’s a bit concerning that a knife could know how to do that by itself.6 So, we’ll need to come back to a knife-operator class later on and build in some expertise there.

class Knife
  class KnifeDirtyError < StandardError; end

  attr_accessor :contents

  def initialize
    @contents = nil
  end

  def clean?
    contents.nil?
  end

  def clean!
    contents = nil
  end

  def loaded?
    !clean?
  end

  def load_from!(container)
    if loaded?
      raise KnifeDirtyError, "This knife is already loaded.  Don't mix your condiments!"
    end

    @contents = container.relinquish_condiment!
  end
end

The Medium

Okay. Bread. As we’ve seen from the video, bread is tricky: conventionally, it’s got two condiment-accepting surfaces7 and a tasty, crusty, edge that should not accept condiments8. So, I’m going create a SliceOfBread class and manage its surfaces with the namespaced SliceOfBread::Surface. After all, we may eventually want to do fancy triple-decker club-sandwiches and so on. And the more over-engineered our code, the tastier the sandwich will be. Right…?

The critical method here is #smear!, which will need to be provided with a loaded knife and one of the surfaces of the slice in question.

class SliceOfBread
  class Surface
    attr_accessor :contents

    def initialize
      @contents = nil
    end

    def plain?
      contents.nil?
    end

    def smeared?
      !plain?
    end
  end

  class SurfaceAlreadySmearedError < StandardError; end
  class KnifeNotLoadedError < StandardError; end
  class InvalidKnifeError < StandardError; end
  class InvalidSurfaceError < StandardError; end

  attr_reader :top
  attr_reader :bottom

  def initialize
    @top = Surface.new
    @bottom = Surface.new
  end

  def plain?
    top.plain? and bottom.plain?
  end

  def smeared?
    top.smeared? or bottom.smeared?
  end

  def smear!(knife:, surface:)
    unless knife.is_a?(Knife)
      raise InvalidKnifeError, "That's not hygienic."
    end

    unless [:top, :bottom].include?(surface)
      raise InvalidSurfaceError, "What're you, crazy?  Put that knife away."
    end

    actual_surface = case surface
    when :top
      top
    when :bottom
      bottom
    end

    unless actual_surface.plain?
      raise SurfaceAlreadySmearedError, "This surface was already smeared!"
    end

    unless knife.loaded?
      raise KnifeNotLoadedError, "This knife is too clean to smear with."
    end

    actual_surface.contents = knife.contents
    knife.contents = nil
  end
end

Sandwichness

We can smear bread! But what is a sandwich? I posit, it’s at least two slices of bread stacked vertically on their non-crusty sides, provided that at least one of the inside surfaces is smeared and both of the outside surfaces are plain9. And, for good measure, cut in half on the non-crusty side with a clean knife.

Sandwich#build! is the crucial method here (as it is in the video) and operates essentially as a series of checks to ensure that it is indeed permissible (according to the laws of the universe) to declare the smeared, carefully-ordered stack bread, indeed, a sandwich. Failure at any stage results in the usual process-stopping exception with an appropriate name and helpful message.

class Sandwich
  class NotEnoughSlicesError < StandardError; end
  class OutsideSmearedError < StandardError; end
  class AlreadyBuiltError < StandardError; end
  class TooPlainError < StandardError; end
  class ImmatureSandwichError < StandardError; end
  class AlreadyCutError < StandardError; end

  # These errors already exist in the Knife and SliceOfBread namespaces
  # respectively; I feel okay about adding them again here because I might
  # want to add specific code to these versions in the future.
  class InvalidKnifeError < StandardError; end
  class DirtyKnifeError < StandardError; end

  attr_reader :slices

  def initialize(*slices_of_bread)
    @slices = slices_of_bread || [ ]
    @built = false
    @cut = false
  end

  def flavours
    slices.flat_map do |slice|
      [ slice.bottom.contents, slice.top.contents ]
    end.uniq.compact
  end

  def flavours_human_readable
    f = flavours.map(&:downcase)
    if f.count == 2
      return f.join(' and ')
    end

    f[-1] = 'and ' + f[-1]
    f.join(', ')
  end

  def <<(slice)
    @slices << slice
  end

  def ready_to_eat?
    @built and @cut
  end

  def build!
    if @built
      raise AlreadyBuiltError, "It's already a glorious tower of food!"
    end

    if slices.count < 2
      raise NotEnoughSlicesError, "#{slices.length} #{slices.length == 1 ? 'slice' : 'slices'} of bread does not a sandwich make."
    end

    unless slices.first.bottom.plain? and slices.last.top.plain?
      raise OutsideSmearedError, "This sandwich would be icky to hold."
    end

    # Check all but the top slice for plainness
    if slices[..-2].map(&:plain?).any?(true)
      raise TooPlainError, "This sandwich might actually be a loaf."
    end

    @built = true
  end

  def cut!(knife)
    unless @built
      raise ImmatureSandwichError, "Build the sandwich and then cut it in one glorious stroke."
    end

    unless knife.is_a?(Knife)
      raise InvalidKnifeError, "That's not hygienic."
    end

    unless knife.clean?
      raise DirtyKnifeError, "No!  You'll get the edge all yucky with that knife."
    end

    if @cut
      raise AlreadyCutError, "One cut will do."
    end

    @cut = true
  end
end

There. We’ve got the components, tools, and definition of a standard sandwich with the associated methods necessary to go from ingredients and tools to a finished, verifiable product. Sandwiches have meaning and instantiability in our universe.

The Problem Unsolved

But, without an intelligence to orchestrate a sandwich, to ensure the ingredients and tools and procedures are valid and sane, there are a lot of mistakes to make and a lot of snarky comments to encounter. As seen in the video, sandwiches can fail.

bread = 5.times.map { SliceOfBread.new }
pb = CondimentJar.new("Peanut Butter")
knife = Knife.new

knife.load_from!(pb)

#=> The jar is closed and knife-impermeable. (CondimentJar::ContainerClosedError)

pb.open!

bread.first.smear!(
  surface: :top,
  knife: "a shoe, why not?"
)

#=> That's not hygienic. (SliceOfBread::InvalidKnifeError)

# ... And so on.

What we need is intelligence. What we need is Dad.

Part II.

  1. On a mature platform, anyway. If the language or framework or silicon or whatever is buggy, then it must be the computer’s fault. QED. Shut up. I’m perfect. ↩︎

  2. That is, he can perform the requisite unscrewing motion on a closed container to open it without needing to be told explicitly how. ↩︎

  3. Let alone ambiguously mutating them in the process. Gross. ↩︎

  4. I know Ruby methods do implicit returns. But it seemed polite to be explicit with the knife. Handle first, etc. ↩︎

  5. Somehow. ↩︎

  6. What if it wanted to #load_from something other than a condiment? Someone? I can only write so many gate conditions. ↩︎

  7. I won’t be subclassing SliceOfBread::Heel today. ↩︎

  8. No oil-drizzled focaccia. ↩︎

  9. At least, according to the requisite, apocryphally-card-playing earl↩︎