Sonntag, 3. Mai 2015

Specify Ruby method consumer fallbacks!

A public API is not always as simple as getting or setting a value. Especially when it comes to different expectations on unexpected behaviour. Should an exception be raised, or should it be handled. Best bet is to let the API consumer decide.
The Hash#fetch is such a Ruby example:
 %w(Ruby Java).fetch(0)
=> "Ruby"
and it raises the KeyError exception, when something went unexpected:
 %w(Ruby Java).fetch(2)
=> IndexError: index 2 outside of array bounds: -2...2
but the API provides a user friendly way to deal with unexpected behaviour. It just delegates the decision to the consumer. There are 3 options. First one is capturing the exception like:
begin
  %w(Ruby Java).fetch(2)
rescue IndexError
  puts "Python is missing."
end
or a more convenient way:
%w(Ruby Java).fetch(2, "Add a language.")
by simply assigning the fallback value as the second parameter. The third way to deal with the unexpected behaviour is passing a block:
%w(Ruby Java).fetch(2) { |index| 
  "Add a language at position #{index}." 
}
That consumer friendly approach not only applies to Hash#fetch but also to Hash#delete and many others. And it should also be adopted to the own API if required.
For example:
class Person
  def from_csv file
    File.open file
  end
end
should be refactored to:
class Person
  def from_csv file, &fallback
    File.open file
    rescue Errno::ENOENT => error
    if block_given?
      yield(error, self)
    else
      raise
    end
    self
  end
end
Now it is up to the consumer how to deal with the exceptional behaviour:
Person.new.from_csv('person_1.csv')
=> Errno::ENOENT: No such file or directory @ rb_sysopen - person_1.csv

Person.new.from_csv('person_1.csv') { |e| 
  raise IOError.new e.message 
}
=> IOError: No such file or directory @ rb_sysopen - person_1.csv

person = Person.new.from_csv('person_1.csv') { |e, person| 
  person.name = 'unknown' 
}
=> #<Person:0x00000005303f90 @name="unknown">
but the method itself can be refactored even a little more to:
class Person
  def from_csv file, &fallback
    fallback ||= ->(error) { raise }
    File.open file
    rescue Errno::ENOENT => error
    fallback.call(error)
    self
  end
end
It works the same but removes the awful if/else condition by setting up a default lambda.
Further articles of interest:

Supported by Ruby 2.1.1