• No results found

Define conversion functions

In document Avdi Grimm - Confident Ruby.pdf (Page 80-88)

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.

In document Avdi Grimm - Confident Ruby.pdf (Page 80-88)