Collecting Input
3.8 Define conversion functions
The fewer demands an object makes, the easier it is to use… If an object can accommodate many different kinds of objects that might be provided as helpers, it makes fewer demands about the exact kinds of objects it needs around it to perform its responsibilities.
— Rebecca Wirfs-Brock and Alan McKean, Object Design Indications
You want a public API to accept inputs in multiple forms, but internally you want to normalize objects to a single type of your own devising.
Synopsis
Define an idempotent conversion function, which is then applied to any incoming objects. For example, define a Point() method which yields a Point object when given pairs of integers, two-element arrays, specially formatted strings, or Point objects.
Rationale
When inputs are immediately converted to the needed type (or rejected), we can spend less time worrying about input uncertainty and more time writing business logic.
Example: A conversion function for 2D points
Let's reuse the example of a 2D graphics library. For convenience, client code can specify points on the canvas as two-element arrays; as strings of the form "123:456",
or as instances of a Point class that we've defined. Internally, however, we want to do all of our processing in terms of Point instances.
Clearly we need some kind of conversion method. This method should have a couple of properties:
1. It should be concise, since we'll be calling it a lot.
2. It should be idempotent. That way we won't have to spend time worrying about whether a given input object has already been converted.
We'll adoptthe conventionfound in Ruby core and standard libraries, and define a specially-named "conversion function" for this purpose. It will be named after the
"target" class, Point. Like other conversion functions such as Kernel#Array and Kernel#Pathname, it will be camel-cased, breaking from ordinary Ruby method naming conventions. We'll define this method in a module, so that it can be included into any object which needs to perform conversions.
module Graphics module Conversions
module_function def Point(*args)
case args.first
when Point then args.first
when Array then Point.new(*args.first) when Integer then Point.new(*args)
when String then
Point.new(*args.first.split(':').map(&:to_i)) else
raise TypeError, "Cannot convert #{args.inspect} to Point"
end end end
Point = Struct.new(:x, :y) do def inspect
Point(Point.new(2,3)) # => 2:3
Point([9,7]) # => 9:7
Point(3,5) # => 3:5
Point("8:10") # => 8:10
We proceed to use this conversion function liberally within our library, especially when working with input values form external sources. In any given stanza of code,
if we're uncertain what format the input variables are in, rather than spend time trying to determine the possibilities, we simply surround them with calls to Point(). Then we can shift our attention back to the task at hand, knowing that we'll be dealing with Point objects only.
As an example, here's the beginning of a method which draws a rectangle:
def draw_rect(nw, se)
You might be wondering what's the deal with that module_function call at the top of the Conversions module. This obscurely-named built-in method does two things: First, it marks all following methods as private. Second, it makes the methods available as singleton methods on the module.
By marking the #Point method private, we keep the method from cluttering up the public interfaces of our objects. So for instance if I have a canvas object which includes Conversions, it would make very little sense to call
canvas.Point(1,2)externally to that object. The Conversions module is intended for internal use only. Marking the method private ensures that this distinction is observed.
Of course, we could have accomplished that with a call to private. The other thing module_functiondoes is to copy methods to the Conversions module
singleton. That way, it's possible to access the conversion function without including
Combining conversion protocols and conversion functions
Let's add one more tweak to our Point() conversion function. So far it works well for normalizing a number of pre-determined types into Point instances. But it would be nice if we could also make it open to extension, so that client code can define new conversions to Point objects.
To accomplish this, we'll add two hooks for extension:
1. A call to the standard #to_aryconversion protocol, for both arrays and objects which are not arrays but which are interchangeable with them.
2. A call to alibrary-defined#to_pointprotocol, for objects which know about the Point class and define a custom Point conversion of their own.
def Point(*args) case args.first
when Integer then Point.new(*args)
when String then Point.new(*args.first.split(':').map(&:to_i)) when ->(arg){ arg.respond_to?(:to_point) }
args.first.to_point
when ->(arg){ arg.respond_to?(:to_ary) } Point.new(*args.first.to_ary)
else
raise TypeError, "Cannot convert #{args.inspect} to Point"
end end
# Point class now defines #to_point itself Point = Struct.new(:x, :y) do
def inspect
# A Pair class which can be converted to an Array Pair = Struct.new(:a, :b) do
def to_ary [a, b]
end end
# A class which can convert itself to Point class Flag
def initialize(x, y, flag_color)
@x, @y, @flag_color = x, y, flag_color
Point(Pair.new(23, 32)) # => 23:32 Point(Flag.new(42, 24, :red)) # => 42:24
We've now provided two different points of extension. We are no longer excluding Array-like objects which don't happen to be descended from Array. And client objects can now define an explicit to_point conversion method. In fact, we've even taken advantage of that conversion method ourselves: there is no longer an explicit when Pointcase in our switch statement. Instead, Point objects now respond to
#to_pointby returning themselves.
Lambdas as case conditions
If you don't understand the lambda (->{...}) statements being used as case conditions in the code above, let me explain. As you probably know, case statements use the "threequals" (#===) operator to determine if a condition
matches. Ruby's Proc objects have the threequals defined as an alias to #call. We can demonstrate this:
even = ->(x) { (x % 2) == 0 }
even === 4 # => true
even === 9 # => false
When we combine Proc#=== with case statements, we have a powerful tool for including arbitrary predicate expression among the case conditions
case number when 42
puts "the ultimate answer"
when even puts "even"
else
puts "odd"
end
Conclusion
By defining an idempotent conversion function like Point(), we can keep our public protocols flexible and convenient, while internally normalizing the inputs into a known, consistent type. Using the Ruby convention of naming the conversion function identically to the target class gives a strong hint to users about the
semantics of the method. Combining a conversion function with conversion
protocols like #to_ary or #to_point opens up the conversion function to further extension by client code.