Joel Drapper

Sitting ducks

Test

Image by Humanities

A common refactoring in object-oriented programming is to take a complex method or set of methods and extract a new class from them. These classes — often referred to as “service objects” or “operations” — allow you to break a complex operation into small well-named components as private methods. And because classes can hold state in instance variables, you no longer need to pass state around through the use of method arguments.

These classes are usually named NounVerber, e.g. record.clean becomes RecordCleaner.new(record).perform.

Since the instance method, perform, here doesn’t take an argument, it’s common practice to take a shortcut by defining a class method by the same name that delegates its arguments to the initialiser and then calls perform on the new instance.

class RecordCleaner
  def self.perform(...)
    new(...).perform
  end

  def initialize(record)
    @record = record
  end

  def perform
    # do the thing
  end
end

Now RecordCleaner.perform(record) is the same as RecordCleaner.new(record).perform.

You’ll see this pattern everywhere: they’re Jobs, Operations, Services, Queries or Filters; they’re performable, runnable, executable, callable, dispatchable, enactable or enforceable. And besides these abstract verbs, there are infinite permutations of this pattern using specific, concrete verbs, like RecordCleaner.clean(record).

At the end of the day, they’re all the same: a class that does one thing. But we miss some powerful polymorphism by making them not quite the same. What we have here are sitting ducks: infinite permutations of almost-duck-types just begging to be ducks.

If it walks like a duck and it quacks like a duck, then it must be a duck.

Duck typing is recognising that an object is of a given type (e.g. a Duck) because it has all the methods and properties of that type (e.g. it walks and quacks).

All these objects have one public method that does-the-thing. So we should be able to pass these objects to any method that accepts an argument for a-thing-to-be-done. Ruby has a duck type for this: callable.

Callable objects are objects that respond to call. Ruby has several built-in callable objects like Methods and Procs.

Here’s my point: if we just consistently use the method call on all these sitting ducks, they’ll all fit the callable duck type and we can go on using them interchangeably wherever a callable is expected. There’s nothing to stop us from keeping the cute custom verbs as alias methods too.

One more thing…

…and this is where it gets a bit confusing. Ruby has another duck type which I’ll call to-procable. To-procable objects must respond to the method to_proc, returning a Proc object (which is callable). All Ruby’s built-in callable objects, as far as I can tell, are also to-procable. And the nice thing about to-procable objects is they can be coerced into blocks with the ampersand prefix operator.

When calling a method that takes a Block, such as Enumerable#map, you can put an & before any to-procable object and it’ll be coerced into a Block. Symbols, for example, respond to to_proc, returning their namesake method, which is why you can coerce a Symbol into a Block like this:

["foo", "bar"].map(&:upcase) # returns ["FOO", "BAR"]

Methods are also to-procable, meaning the call methods on callable objects can be coerced into Blocks. This means callables, too, are sitting ducks. All callables should be to-procable or at least block-coercible.

Ruby should either:

  1. fall back to coercing blocks from method(:call).to_proc when to_proc is not defined on an object to be coerced; or
  2. provide a default implementation for to_proc on Objects. The only catch with this is you wouldn’t want every object claiming to respond to to_proc if it doesn’t even respond to call.

There’s a discussion about this on the Ruby issue tracking system. For now, you can start making almost-callable objects actually-callable in the hope that one day they’ll be block-coercible too.

You could also define an explicit Callable module to be extended or included into your callable objects, providing a definition of to_proc that delegates to the call method.

module Callable
  def call
    raise NoMethodError
  end

  def to_proc
    method(:call).to_proc
  end
end

Alternatively, you could monkey patch Object to provide a default implementation for to_proc when call is defined.

class Object
  def method_missing(name, ...)
    if name == :to_proc && respond_to?(:call)
      send def self.to_proc
        method(:call).to_proc
      end
    else
      super
    end
  end

  def respond_to_missing?(name, ...)
    (name == :to_proc && respond_to?(:call, ...)) || super
  end
end

I hope that soon, Ruby will be able to coerce any callable to a Block automatically.