Receive Policies Instead of Data

Hi folks, today I wanna share something that drew my attention while reading Confident Ruby.

Avdi presents the following method:

def delete_files(files, ignore_errors=false, log_errors=false)
  files.each do |file|
    begin
      File.delete(file)
    rescue => error
      puts error.message if log_errors
      raise unless ignore_errors
    end
  end
end

And its invocation:

require 'fileutils'
FileUtils.touch 'does_exist'
delete_files(['does_not_exist', 'does_exist'], true, true)

This code is not good at all.

  • the method worries to much in handling edge cases (error, logs)
  • The true, true in calls to delete_files does not help. We have to refer to the method definition to remember what those flags mean.
  • What if we wanted to log using a special format? Or to somewhere other than STDOUT? There is no provision for customizing how errors are logged.
  • What do we do if we ever decide to handle permissions errors differently from no such file or directory? Add yet another flag?

The book point us the fundamental problem: both ignore_errors and log_errors attempt to specify policies using data.

Let's fix this by passing a block as argument:

def delete_files(files, &error_policy)
  error_policy ||= ->(file, error) { raise error }

  files.each do |file|
    begin
      File.delete(file)
    rescue => error
      error_policy.call(file, error)
    end
  end
end
delete_files(['does_not_exist', 'does_exist']) do |file, error|
  puts error.message
end

Look how flexible it is now. We define a default behavior for error handling but also let it open for customization by calling a given block.

What if we need more control over the behavior? Just pass more policies:

def delete_files(files, options={})
  error_policy = options.fetch(:on_error) { ->(file, error) { raise error } }
  symlink_policy = options.fetch(:on_symlink) { ->(file) { File.delete(file) } }

  files.each do |file|
    begin
      if File.symlink?(file)
        symlink_policy.call(file)
      else
        File.delete(file)
      end
    rescue => error
      error_policy.call(file, error)
    end
  end
end
delete_files(
  ['file1', 'file2'],
  on_error: ->(file, error) { warn error.message },
  on_symlink: ->(file) { File.delete(File.realpath(file)) }
)

Conclusion

Block and proc as argument help callers to specify policies, freeing us from hard-coded decisions which someone else could make.

Written on October 11, 2018

Share: