In Progress
Unit 1, Lesson 21
In Progress

Benign Value

Video transcript & code

Just as nobody expects the Spanish Inquisition, exceptions in Ruby code are often unexpected and rarely welcome. Exterminating unwanted exceptions can be a worthwhile endeavour, but then the question becomes: how do we represent failures in a less disruptive fashion?

Here's a method I had reason to work on recently.

require "addressable/uri"

def custom_thumbnail_url(url, attributes)
  uri                     = Addressable::URI.parse(url)
  uri_params              = uri.query_values
  resize_param            = uri_params["image_crop_resized"]
  orig_width, orig_height = resize_param.split('x').map(&:to_i)
  width                   = attributes.fetch(:width) { orig_width }
  height                  = attributes.fetch(:height) { orig_height }
  uri.query_values        = {"image_crop_resized" => "#{width}x#{height}"}
  uri.to_s
end

It's a pretty straightforward data-munging function: it takes in a string containing a URL, and returns a string containing a modified URL.

require "./method"
custom_thumbnail_url("http://example.org/image1.jpg?image_crop_resized=100x60",
                     width: 640,
                     height: 480)
# => "http://example.org/image1.jpg?image_crop_resized=640x480"

This method encodes some implicit assumptions about the format of the incoming string. Like many such assumptions, these turn out not to always hold true. And when the method receives a string with unexpected contents, the results are a surprising and rather unhelpful NoMethodError.

require "./method"
custom_thumbnail_url("http://example.org/default.gif",
                     width: 640,
                     height: 480)
# =>

# ~> NoMethodError
# ~> undefined method `[]' for nil:NilClass
# ~>
# ~> /home/avdi/Dropbox/rubytapas/336-what-changed/337-error-value/method.rb:...
# ~> xmptmp-in15256g0f.rb:2:in `<main>'

Let's assume that an exception is not what we want if this method runs into trouble. In fact, let's say that the only kind of output we want this method to have is in the form of a return value. What kind of value should it return when it can't finish successfully?

One possibility is that it returns the original input string unmodified.

require "addressable/uri"

def custom_thumbnail_url(url, attributes)
  uri                     = Addressable::URI.parse(url)
  uri_params              = uri.query_values or return url
  resize_param            = uri_params["image_crop_resized"]
  orig_width, orig_height = resize_param.split('x').map(&:to_i)
  width                   = attributes.fetch(:width) { orig_width }
  height                  = attributes.fetch(:height) { orig_height }
  uri.query_values        = {"image_crop_resized" => "#{width}x#{height}"}
  uri.to_s
end

But this could seriously inconvenience code further down the line. What if the input isn't a valid URL at all? Code that depends on this output might see a string with contents, assume that everything's fine, and dump it into a user-visible HTML layout. At best, this could break the view. At worst, it could reveal important implementation details.

require "./method2"
custom_thumbnail_url("NOT FOUND",
                     width: 640,
                     height: 480)
# => "NOT FOUND"

Another possibility is that the method return a flag value that's widely used to indicate failure: our old friend, nil.

require "addressable/uri"

def custom_thumbnail_url(url, attributes)
  uri                     = Addressable::URI.parse(url)
  uri_params              = uri.query_values or return nil
  resize_param            = uri_params["image_crop_resized"]
  orig_width, orig_height = resize_param.split('x').map(&:to_i)
  width                   = attributes.fetch(:width) { orig_width }
  height                  = attributes.fetch(:height) { orig_height }
  uri.query_values        = {"image_crop_resized" => "#{width}x#{height}"}
  uri.to_s
end

But this means that any and all methods which depend on this method's output must be aware of the possibility of nil return values. Any code that assumes the result will always be a string may get a rude surprise.

require "./method3"
result = custom_thumbnail_url("http://example.org/default.gif",
                              width: 640,
                              height: 480)
result                          # => nil
result.split("://")             # =>

# ~> NoMethodError
# ~> undefined method `split' for nil:NilClass
# ~>
# ~> xmptmp-in15256Urg.rb:6:in `<main>'

Another possibility is to return a clearly recognizable benign value. A good choice in this case might be the empty string.

require "addressable/uri"

def custom_thumbnail_url(url, attributes)
  uri                     = Addressable::URI.parse(url)
  uri_params              = uri.query_values or return ""
  resize_param            = uri_params["image_crop_resized"]
  orig_width, orig_height = resize_param.split('x').map(&:to_i)
  width                   = attributes.fetch(:width) { orig_width }
  height                  = attributes.fetch(:height) { orig_height }
  uri.query_values        = {"image_crop_resized" => "#{width}x#{height}"}
  uri.to_s
end

Why the empty string? Well, as a string, it's much less likely to break code which is expecting a string.

require "./method4"
result = custom_thumbnail_url("http://example.org/default.gif",
                              width: 640,
                              height: 480)
result                          # => ""
result.split("://")             # => []

But the empty string, like nil is also a clearly recognizable flag value. If we need to write code that's aware of the error case, we can just switch on whether the string is empty.

In fact, in the app this method comes from, that's exactly what happens in the helper method that ultimately handles the generated URL strings. The helper checks to see if the string is empty. If not, it uses the value; but if it is empty, it substitutes a default URL instead.

def episode_thumbnail_path(episode, root_path=settings.root)
  if !episode.video_thumbnail_url.empty?
    episode.video_thumbnail_url
  else
    "/images/video-thumbnails/default.png"
  end
end

Here's another reason to choose the empty string as the flag value: with a tiny addition, we can enable the client code to handle nil values as well.

def episode_thumbnail_path(episode, root_path=settings.root)
  if !episode.video_thumbnail_url.to_s.empty?
    episode.video_thumbnail_url
  else
    "/images/video-thumbnails/default.png"
  end
end

Because nil values stringify to the empty string, both nils and empty strings can be handled by the same test.

nil.to_s                        # => ""

We now have a resilient convention for flagging a bad thumbnail URL. So long as we are consistent in using this convention, we can safely replace an unexpected or un-handleable value with an empty string, at any point in the program, and know that it won't cause major problems down the line. In the worst case, it'll result in a missing image somewhere in the applications display. But view code can also explicitly check for the empty string flag value, and substitute some kind of safe default.

The biggest downside to this approach is that, unlike with an exception, we've thrown away any and all information about why and where there was a problem in the first place. In an upcoming episode, we'll look at a related approach that enables us to preserve some of this error context. Until then: happy hacking!

Responses