• No results found

Define conversion functions

In document Confident Ruby (Page 84-92)

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 adopt the convention [page 61] found 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#Arrayand 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 "#{x}:#{y}" end end end include Graphics

include Graphics::Conversions

Point(Point.new(2,3)) # => 2:3

Point([9,7]) # => 9:7

Point(3,5) # => 3:5

Point("8:10") # => 8:10

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) nw = Point(nw) se = Point(se) # ...

end

Aboutmodule_function

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 Conversions, simply by calling e.g. Conversions.Point(1,2).

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_ary conversion protocol [page 34], for both arrays and objects which are not arrays but which are interchangeable with them.

2. A call to a library-defined #to_point protocol [page 56], 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 "#{x}:#{y}" end def to_point self end end

# 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

@x, @y, @flag_color = x, y, flag_color end def to_point Point.new(@x, @y) end end Point([5,7]) # => 5:7 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

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 Confident Ruby (Page 84-92)