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.