Ruby Mini-Pattern: Using Blocks To Handle Special Cases
When implementing a generic processing method that needs to handle a special case (for example, in a parser), use blocks to elegantly handle these cases instead of writing the code inline.
Applicability
This mini-pattern is specific to Ruby, and while my example deals with parsing text, the technique could be useful in other situations.
Motivation
Recently I needed to create Ruby objects based on output from a command-line program. The output was generally well-structured, but not completely. As a result of these here-and-there inconsistencies, my parser turned into a real mess. Here's a simplified version of the text to be parsed:
# Text describing a "Foo" object
Length : 20
Width : 10
# Text describing a "Bar" object
Status : OK
Widget Notifier : ON
Channel/Device : 0,1
And here's the classes I want to create from that text:
# Easy case
class Foo
attr_accessor :length, :width
end
# Little bit harder
class Bar
attr_accessor :status, :notifier, :channel, :device
end
Foo is the easiest possible case; you simply take each line and split around the " : ". The left side matches the attribute name, the right side matches its value. But what about Bar? Two issues complicate things: the names don't match right, and one line has two attributes. What to do now?
Implementation
The best way to handle mapping text names to attribute names is with a hash. Sometimes you can get away with applying transforms to the text -- in the Foo example, just downcase the name -- but in real life you usually have too many exceptions to the rule. The hash for Foo would look like this:
# Map Foo text keys => attributes
map = {
"Length" => :length=,
'Width' => :width=
}
# (This code is in a separate parser class...)
# Parser can now assign key/val pair to
# attribute like so:
foo_instance.send(map[key], val)
The last line calls the attribute setter for the key (length, width), and passes along the value.
Now for Bar. Rather than map "Channel/Device" to a symbol, map it to a block instead:
# Map Bar text keys => attributes
map = {
"Status" => :status=,
"Widget Notifier" => :notifier=,
"Channel/Device" => lambda do |bar, text|
channel, device = text.split(/,/)
bar.channel = channel
bar.device = device
end
# Parser now checks for mapping type
case (map[key])
when Proc: map[key].call(item, val) # <--- Call the proc
when Symbol: item.send(map[key], val)
end
The lambda creates an object of class Proc, and when the parser sees a Proc for a key, it just calls the Proc. This lets the parser remain highly generic, and cleanly packages the special-case handling in our key/attribute map.
Credit
The idea for this technique was based on a discussion with Chad Fowler and Ara Howard at the Boulder/Denver Ruby User's Group. While this isn't rocket science -- in fact it's pretty common stuff for Ruby and other languages with closures -- it's a handy trick which is worth documenting.
Full Source Code
The full example file is presented below.
sample_foo_text = <<end
Length : 20
Width : 10
end
sample_bar_text = <<end
Status : OK
Widget Notifier : ON
Channel/Device : 0,1
end
class Foo
attr_accessor :length, :width
def self.create_from_text(text)
map = {
"Length" => :length=,
'Width' => :width=
}
Parser.create_from_text(Foo, map, text)
end
end
class Bar
attr_accessor :status, :notifier, :channel, :device
def self.create_from_text(text)
map = {
"Status" => :status=,
"Widget Notifier" => :notifier=,
"Channel/Device" => lambda do |bar, text|
channel, device = text.split(/,/)
bar.channel = channel
bar.device = device
end
}
Parser.create_from_text(Bar, map, text)
end
end
# One parser class can create several Foo/Bar/etc.
# classes given a class and text.
#
class Parser
def self.create_from_text(item_class, map, text)
item = item_class.new
text.each_line do |line|
key, val = line.split(/:/)
key.strip!
val.strip!
case (map[key])
when Proc: map[key].call(item, val)
when Symbol: item.send(map[key], val)
end
end
item
end
end
foo = Foo.create_from_text(sample_foo_text)
puts "Foo:"
puts foo.length
puts foo.width
bar = Bar.create_from_text(sample_bar_text)
puts "Bar:"
puts bar.status
puts bar.notifier
puts bar.channel
puts bar.device

