In Progress
Unit 1, Lesson 21
In Progress

Safe Navigation Part 3: Everything is Optional

Video transcript & code

In the previous episode, we ended on something of a cliffhanger note. Here's what I said:

Today we've seen some current and future methods of safely navigating across networks of objects. Now I'd like to show you a different way of going about it, one which is both strange and familiar all at once. But that's going to need an episode of it's own. So until next time: happy hacking!

In that episode, we looked at using the ActiveSupport #try method or the upcoming Ruby "safe navigation operator" in order to traverse chains of objects in which some of the intervening objects might be nil.

@product.try(:department).try(:curator).try(:email_address)

@product.?department.?curator.?email_address

But what if we didn't need a new syntax or a special method name for this situation at all? What if I told you that there is a way to handle the "safe navigation" problem using a familiar pattern that we already use every day?

Consider some variable that might contain an object we can make use of, but might instead contain a nil value. Like every value in Ruby, nil is technically an object. But we often treat it as if it represents the absence of an object, or a "missing" object.

box = ["Schrodinger's Cat", nil].sample

So another way to say this is that we have a box, the variable, which may or may not contain an object. Or, to put it in slightly different terms once again, we have a box that might contain one object, and might contain no objects.

box = ["Schrodinger's Cat", nil].sample
Array(box)                      # => ["Schrodinger's Cat"]

box = ["Schrodinger's Cat", nil].sample
Array(box)                      # => []

What else can you think of that fits this description: a container which might contain an object, or might contain no objects? You can probably think of lots of potential answers to that question. But as I've already hinted here, one of the simplest and most common entities to satisfy this description is a Ruby array.

An array might have an object in it. It might have no objects in it.

["Schrodinger's Cat"]
[]

Of course, it might also have many objects in it. But the fact that it has a superset of the capabilities we're looking for isn't important. The relevant fact is that an array satisfies our description.

Now, let's say we have an array, which may contain an object. We want to do something to that object, if it is there. But if the array is empty, we don't want to do anything.

array = []
array << "Schrodinger's Cat" if rand < 0.50
array                           # => ["Schrodinger's Cat"]

How do we accomplish this? The answer is so simple and common it hardly seems worth saying it. We use the #each method. The block passed to #each is executed once for every element in the array. Which, in the case of no objects, is never.

array = []
array << "Schrodinger's Cat" if rand < 0.50
array.each do |content|
  puts "Found: #{content}"
end

# >> Found: Schrodinger's Cat

If we care about the return value of the action, we don't use #each, we use #map instead. Again, this is Ruby 101.

array = []
array << "Schrodinger's Cat" if rand < 0.50
array.map { |content|
  content.upcase
}
# => ["SCHRODINGER'S CAT"]

As you probably know, there's a shortcut for this pattern of having a block that just sends one message to its argument: instead of a block, we can pass the symbolic name of the message, prepended with an ampersand.

array = []
array << "Schrodinger's Cat" if rand < 0.50
array.map(&:upcase)             # => ["SCHRODINGER'S CAT"]

So, we've now seen a similarity in behavior between Ruby variables, and arrays containing one element. But how does this help us?

What if we could treat any object as if it was an array of zero or one element?

This is pretty easy to do with some simple patches.

class Object
  def each
    yield self
    nil
  end

  def map
    yield self
  end
end

class NilClass
  def each
    nil
  end

  def map
    nil
  end
end

We add an #each method to the Object base class. Its behavior is very simple: it just yields the object itself to whatever block is passed. It then returns nil, in order to be consistent with the behavior of Array#each.

We add a #map method too. The only difference is that this one returns the result of the yield instead of discarding it.

Now we add the same methods to NilClass. Except this time, they are implemented as no-ops. Nils will respond to these messages without error, but nothing will happen.

Now let's go back to our variable which might be nil. We want to print something out, but only if there is something inside. We can now do this by sending #each to the variable, and putting our operation inside it. When it is nil, nothing happens. When it has a non-nil value, we see output.

require "./patches"

box = ["Schrodinger's Cat", nil].sample
box                             # => "Schrodinger's Cat"
box.each do |content|
  puts "Found: #{content}"
end

# >> Found: Schrodinger's Cat

Or if we need to, we can instead #map to apply a transformation only when the value is non-nil. Note that the return value of #map in this case is a singular value, rather than an array.

require "./patches"

box = ["Schrodinger's Cat", nil].sample
box                             # => nil
box.map(&:upcase)               # => nil

box = ["Schrodinger's Cat", nil].sample
box                             # => "Schrodinger's Cat"
box.map(&:upcase)               # => "SCHRODINGER'S CAT"

But because we can now use #map on almost any object, we can still chain operations the way we are used to with enumerables.

require "./patches"

box = ["Schrodinger's Cat", nil].sample
box                             # => "Schrodinger's Cat"
box.map(&:upcase).map(&:reverse) # => "TAC S'REGNIDORHCS"

Now that we've seen some examples of how these patches work, let's return to our example of a deeply-chained series of message sends. Let's compare the version of of the code that uses ActiveSupport's #try to using our new #map extension.

In the #try version, we put the whole expression inside a conditional. If the the chain produces a non-nil value, we use the email address we found to perform an action.

Notice how despite the use of #try, we still had to wrap one last nil-checking conditional around the whole expression.

Now let's do the same operation but using our #map and #each extensions. We map from department to curator to email_address. Then we use #each at the end, to send an email only if the result was non-nil.

require "./models"
require "active_support"
require "active_support/core_ext"
require "./patches"

if email = @product.try(:department).try(:curator).try(:email_address)
  puts "Emailing a complaint to #{email}..."
end

@product.map(&:department).map(&:curator).map(&:email_address).each do |email|
  puts "Emailing a complaint to #{email}..."
end

@product.department.curator = nil
@product.map(&:department).map(&:curator).map(&:email_address).each do |email|
  puts "Emailing a complaint to #{email}..."
end

# >> Emailing a complaint to dan@example.com...
# >> Emailing a complaint to dan@example.com...

These two versions are in many ways very similar to each other. But the second one re-uses syntax and techniques that are already familiar to us from manipulating collections.

The advantage here is in more than just familiarity. Consider this new scenario: the structure of our models changes. Instead of having a single, optional department, a product now has a list of departments that it may exist in.

class Product
  attr_accessor :departments
  def initialize(name)
    @name        = name
    @departments = []
  end
end

class Department
  attr_accessor :curator
  def initialize(name)
    @name = name
  end
end

class Curator
  attr_accessor :email_address
  def initialize(name)
    @name = name
  end
end

@product = Product.new("Bass-O-Matic")
@product.departments << Department.new("Kitchen")
@product.departments << Department.new("Fishing")
@product.departments[0].curator = Curator.new("Dan Akroyd")
@product.departments[0].curator.email_address = "dan@example.com"
@product.departments[1].curator = Curator.new("Jane Curtain")
@product.departments[1].curator.email_address = "jane@example.com"

If we just do a naive update and change #department to #departments in code that uses this attribute, the code is now quietly broken.

require "./models2"
require "active_support"
require "active_support/core_ext"

@product.try(:departments).try(:curator).try(:email_address)
# => nil

To fix it we have to drastically update the shape of our accessing code, mirroring the new structure of the models. We iterate over the new departments list, and move the rest of our #try chain inside this block.

require "./models2"
require "active_support"
require "active_support/core_ext"

@product.try(:departments).each do |dept|
  if email = dept.try(:curator).try(:email_address)
    puts "Emailing a complaint to #{email}"
  end
end

# >> Emailing a complaint to dan@example.com
# >> Emailing a complaint to jane@example.com

Now let's see what we have to do to update the #map and #each version to cope with this new structure.

require "./models2"
require "active_support"
require "active_support/core_ext"
require "./patches"

@product.map(&:departments).map(&:curator).map(&:email_address).each do |email|
  puts "Emailing a complaint to #{email}..."
end

# >> Emailing a complaint to dan@example.com...
# >> Emailing a complaint to jane@example.com...

That's it. A one-character change. By treating singular values and collections interchangeably, we've insulated our accessing code somewhat from structural changes in the target models.

Now, it's not all sunshine and roses. If one of the departments lacks a curator, things blow up.

require "./models2"
require "active_support"
require "active_support/core_ext"
require "./patches"

@product.departments[1].curator = nil
@product.map(&:departments).map(&:curator).map(&:email_address).each do |email|
  puts "Emailing a complaint to #{email}..."
end

# ~> NoMethodError
# ~> undefined method `email_address' for nil:NilClass
# ~>
# ~> xmptmp-in9959aYi.rb:7:in `map'
# ~> xmptmp-in9959aYi.rb:7:in `<main>'

But here we're on familiar ground, working with collections that may contain nil elements. We can introduce #compact into the chain to get rid of these unwanted elements.

require "./models2"
require "active_support"
require "active_support/core_ext"
require "./patches"

@product.departments[1].curator = nil
@product.map(&:departments).map(&:curator).compact.map(&:email_address).each do |email|
  puts "Emailing a complaint to #{email}..."
end

# >> Emailing a complaint to dan@example.com...

Now, I imagine that you're having one of two reactions to this technique. Either you think this is some of the nuttiest code you've ever seen. Or you're nodding along.

If you're nodding along, it's because you've seen this before. And if you have, it's probably because you have some experience in a statically-typed functional programming language such as Haskell, Scala, or ML. What I've shown you today is a common idiom in those languages, where it usually goes by the name of the option or the maybe type.

An Option Type is a type of data that may either have some value, or may have a value of "none". And typically you would use the map function conditionally apply operations to the value, if it is present.

In Ruby, any variable can potentially contain a nil value without restriction. So it makes sense for us to treat every value as having the optional type.

This is a powerful new way of looking at objects and variables. It's also pretty major change to two of Ruby's most fundamental core classes. In the next episode, we'll make our patches less invasive. Until then, happy hacking!

Responses