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:
… 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:
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.
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. ↩︎
That is, he can perform the requisite unscrewing motion on a closed container to open it without needing to be told explicitly how. ↩︎
Let alone ambiguously mutating them in the process. Gross. ↩︎
I know Ruby methods do implicit returns. But it seemed polite to be explicit with the knife. Handle first, etc. ↩︎
Somehow. ↩︎
What if it wanted to
#load_from
something other than a condiment? Someone? I can only write so many gate conditions. ↩︎I won’t be subclassing
SliceOfBread::Heel
today. ↩︎No oil-drizzled focaccia. ↩︎
At least, according to the requisite, apocryphally-card-playing earl. ↩︎