The problem
You need to ensure inputs are of a core type with context-specific extra semantics.
The approach
Define new implicit conversion protocols mimicking Ruby’s native protocols such as #to_path
.
Explanation
Ruby defines a number of protocols for converting objects into core types such as String
, Array
, and Integer
. But there may come a time when the core protocols don’t capture the conversion semantics your apps or libraries need.
Consider a 2D drawing library. Points on the canvas are identified by X/Y pairs. For simplicity, these pairs are simply two-element arrays of integers.
Ruby defines #to_a
and #to_ary
for converting to =Array=s. But that doesn’t really capture intent of converting to an X/Y pair. Just like the #to_path
conversion used by File.open
, even though we are converting to a core type we’d like to add a little more meaning to the conversion call. We’d also like to make it possible for an object to have a coordinate conversion even if otherwise it doesn’t really make sense for it to have a general Array
conversion.
In order to capture this input requirement, we define the #to_coords
conversion protocol. Here’s a method which uses the protocol:
# origin and ending should both be [x,y] pairs, or should # define #to_coords to convert to an [x,y] pair def draw_line(start, endpoint) start = start.to_coords if start.respond_to?(:to_coords) start = start.to_ary # ... end
Later, we decide to encapsulate coordinate points in their own Point
class, enabling us to attach extra information like the name
of the point. We define a #to_coords
method on this class:
class Point attr_reader :x, :y, :name def initialize(x, y, name=nil) @x, @y, @name = x, y, name end def to_coords [x,y] end end
We can now use either raw X/Y pairs or Point
objects interchangeably:
start = Point.new(23, 37) endpoint = [45,89] draw_line(start, endpoint)
But the #to_coords
protocol isn’t limited to classes defined in our own library. Client code which defines classes with coordinates can also define #to_coords
conversions. By documenting the protocol, we open up our methods to interoperate with client objects which we had no inkling of at the time of writing.
I’m not sure I understand why using #to_coords is useful here. If we decide to represent points as Point objects, wouldn’t we change every reference to use its #x/#y protocol?
1) The Point class is one possible future; not a certainty; and
2) Even if we DO decide to start using points, that’s a pretty big upheaval in an established project to go through and change everything; especially if we aren’t sure this “Point” thing is a good idea yet; and finally…
3) What if there’s lots of client code assuming that our API will both receive AND return (or call-back with) x/y pairs?
In that case, why not ask
start.respond_to(:x) && start.respond_to(:y)
instead, and make the interface smaller?I go into great detail on that question in another section of the book 🙂
In a nutshell, code that constantly asks #respond_to? is horrible and completely defeats the purpose of duck-typing. Using respond_to? to check for a conversion method is a useful compromise with an important difference: it’s not asking “Do you support interface X”, but rather “can you give me something that supports interface X”. It adds a level of indirection which means that no matter how big interface X becomes, only one #respond_to? check will be needed.
Interesting. I should probably go read the book then. 🙂