Type-Checking Interface in Ruby
« Prefer: Censorship
» 2015 Media Reviews
Code: boundaries, experiment, interfaces, POODR, Ruby
Comments Off on Type-Checking Interface in Ruby
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:
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.