Type-Checking Interface in Ruby «
»


Code: , , , ,
2 comments

One of my favorite parts in Sandi Metz’s excellent Practical Object Oriented Design in Ruby is when she describes how to enforce lightweight interfaces so that multiple objects can play a role reliably. And I thought: how I can I enforce this in a much-more heavy-handed and annoying way?

This question also ties into two ridiculous tweets of mine:

protocols-tweet1 protocols-tweet2

Metz’s approach is to enforce each role (aka duck type) with a default implementation in a superclass (ch 6, p127-129). The superclass has a method raising NotImplementError for each method required by the interface. I’ve been leaning away from inheritance lately, so I’m going to work through this with a shared module. Here’s an example:

module Notifier
  def notify message
    raise NotImplementedError
  end
 
  def acknowledged?
    raise NotImplemented Error
  end
end
 
class TextMessageSender
  include Notifier
 
  def notify message
    SMSGateway.send(...)
  end
 
  # ...
end
 
class EmailSender
  include Notifier
 
  def notify message
    Email.new(...).deliver!
  end
 
  # ...
end
 
class ShoppingCart
  def checkout notifier
    # billing code, etc.
    notifier.notify "Thanks for buying our stuff..."
  end
end

The code that sends a message can have either a TextMessageSender or EmailSender injected without having to care which it’s using. If another kind of Notifier comes along and fails to implement the interface, it will raise clear error messages.

This pattern is a sweet spot between an unspecified anything-goes approach and compiler-enforced interfaces.

As a coding exercise, I tried to push this design towards strictness. If you squint hard enough, this is a run-time type check.

But nothing in this pattern requires that the object playing a rule includes the module (or inherits from the superclass, in that style). After some tinkering I came up with a method to enforce the type check:

class Class
  def enforce requirements
    caller_binding = binding.of_caller(1)
    params = method(caller_binding.eval('__method__').parameters
    params.each do |type, name|
      next unless type == :req
      # formating for width of blog here...
      if requirements.has_key? name
        unless caller_binding.local_variable_get(name).is_a? Kernel::const_get(requirements[name])
          raise ArgumentError, "#{name} didn't include #{requirements[name]}"
        end
      end
    end
  end
end
 
# for example:
class ShoppingCart
  def checkout notifier
    enforce notifier: Notifier
    # billing code, etc.
    notifier.notify "Thanks for buying our stuff..."
  end
end

The enforce method looks up the binding of the method it was called from (using the delightfully hack and not-at-all production-safe binding_of_caller gem, the magic behind the insanely useful better_errors Rails debugging tool). From there it users method to look up the method object to check if each of the method’s required parameters includes the appropriate module.

So enforce is a runtime assertion that the object playing a role includes the module that enforces the interface.

Then I decided to push a little farther, and model interfaces explicitly:

require 'set'
 
# here's the Interface model:
 
class Interface
  attr_reader :messages
  def initialize messages
    @messages = Set.new(messages)
  end
 
  def knows? message
    @messages.include(message)
  end
end
 
# and a handy shortcut for defining one:
 
class Kernel
  def interface name, messages
    iface = Interface.new messages
    Kernel::const_set name, iface
  end
end
 
# so somewhere in the code an interface can be cleanly defined with:
interface :Notifier, [:notify, :acknowledged?]
 
# and then we'll set up objects to be checked for interfaces:
class Object
  def provides iface
    @_interfaces ||= []
    @_interfaces << iface
    unless defined? :provides?
      define_method :provides? do |iface|
        @_interfaces.include? iface
 
        # Yes, this is wasteful to check on every method call using 'enforce',
        # but I didn't want to put the 'provides' after the class definition,
        # and I suppose it's required if your object does something really
        # dynamic and tricky. If this were real, I might remove this check from
        # here and add a separate 'dynamically_provides' class method with this check.
        unresponded = iface.messages.select { |m| !arg.respond_to?(m) }
        raise ArgumentError, "Arg #{name} claims to provide #{iface} but doesn't respond_to? #{unresponded.join(', ')}" unless unresponded.empty?
      end
      end
  end
end
 
# this is used as:
class TextMessageSender
  provides Notifier
  def notify ; ... ; end
  def acknowledged? ; ... ; end
end
 
# and enforce changes its check:
class Class
  def enforce requirements
    caller_binding = binding.of_caller(1)
    params = method(caller_binding.eval('__method__').parameters
    params.each do |type, name|
      next unless type == :req
      if requirements.has_key? name
        raise ArgumentError, "Arg #{name} does not provide #{iface}" unless arg.respond_to?(:provides?) and arg.provides? iface
      end
    end
  end
end
 
# so, finally, the interface is enforced the same way:
class ShoppingCart
  # ...
  def checkout notifier
    enforce notifier: Notifier
    # billing code, etc.
    notifier.notify "Thanks for buying our stuff..."
  end
end

It’s certainly not static, compile-time type-checking of arguments, but it’s more thorough than voluntary inclusion of a module. When I started I seriously doubted it would be possible to implement, but Ruby’s metaprogramming (extended by binding_of_caller) is powerful.

And if it wasn’t clear, this is not production-ready code and should never ever be used on a real project. I only wrote this because I wanted to experiment with introspection in Ruby while thinking about interfaces.


Comments

  1. Why go to all the trouble of using caller binding (great fun writing that code though), when you can just pass the parameter to the enforce method:

    enforce notifier, Notifier

    Then the code will work with Ruby 1.9 and JRuby, and it should be a lot quicker as well, as it’s not doing stuff with the call stack.

  2. Multiple arguments and hash syntax. I thought of it as declaring requirements, not passing values around.

    enforce notifier: Notifier, tracker: Incrementable, repository: Storable

Leave a Reply

Your email address will not be published.