Tighter Ruby Methods with Functional-style Pattern Matching, Using the Case Gem 145
Ruby doesn’t have overloaded methods, which are methods with the same name, but different signatures when you consider the argument lists and return values. This would be somewhat challenging to support in a dynamic language with very flexible options for method argument handling.
You can “simulate” overloading by parsing the argument list and taking different paths of execution based on the structure you find. This post discusses how pattern matching, a hallmark of functional programming, gives you powerful options.
First, let’s look at a typical example that handles the arguments in an ad hoc fashion. Consider the following Person
class. You can pass three arguments to the initializer, the first_name
, the last_name
, and the age
. Or, you can pass a hash using the keys :first_name
, :last_name
, and :age
.
require "rubygems"
require "spec"
class Person
attr_reader :first_name, :last_name, :age
def initialize *args
arg = args[0]
if arg.kind_of? Hash # 1
@first_name = arg[:first_name]
@last_name = arg[:last_name]
@age = arg[:age]
else
@first_name = args[0]
@last_name = args[1]
@age = args[2]
end
end
end
describe "Person#initialize" do
it "should accept a hash with key-value pairs for the attributes" do
person = Person.new :first_name => "Dean", :last_name => "Wampler", :age => 39
person.first_name.should == "Dean"
person.last_name.should == "Wampler"
person.age.should == 39
end
it "should accept a first name, last name, and age arguments" do
person = Person.new "Dean", "Wampler", 39
person.first_name.should == "Dean"
person.last_name.should == "Wampler"
person.age.should == 39
end
end
The condition on the # 1
comment line checks to see if the first argument is a Hash
. If so, the attribute’s values are extracted from it. Otherwise, it is assumed that three arguments were specified in a particular order. They are passed to #initialize
in a three-element array. The two rspec examples exercise these behaviors. For simplicity, we ignore some more general cases, as well as error handling.
Another approach that is more flexible is to use duck typing, instead. For example, we could replace the line with the # 1
comment with this line:
if arg.respond_to? :has_key?
There aren’t many objects that respond to #has_key?
, so we’re highly confident that we can use [symbol]
to extract the values from the hash.
This implementation is fairly straightforward. You’ve probably written code like this yourself. However, it could get complicated for more involved cases.
Pattern Matching, a Functional Programming Approach
Most programming languages today have switch
or case
statements of some sort and most have support for regular expression matching. However, in functional programming languages, pattern matching is so important and pervasive that these languages offer very powerful and convenient support for pattern matching.
Fortunately, we can get powerful pattern matching, typical of functional languages, in Ruby using the Case gem that is part of the MenTaLguY’s Omnibus Concurrency library. Omnibus
provides support for the hot Actor model of concurrency, which Erlang has made famous. However, it would be a shame to restrict the use of the Case
gem to parsing Actor messages. It’s much more general purpose than that.
Let’s rework our example using the Case gem.
require "rubygems"
require "spec"
require "case"
class Person
attr_reader :first_name, :last_name, :age
def initialize *args
case args
when Case[Hash] # 1
arg = args[0]
@first_name = arg[:first_name]
@last_name = arg[:last_name]
@age = arg[:age]
else
@first_name = args[0]
@last_name = args[1]
@age = args[2]
end
end
end
describe "Person#initialize" do
it "should accept a first name, last name, and age arguments" do
person = Person.new "Dean", "Wampler", 39
person.first_name.should == "Dean"
person.last_name.should == "Wampler"
person.age.should == 39
end
it "should accept a has with :first_name => fn, :last_name => ln, and :age => age" do
person = Person.new :first_name => "Dean", :last_name => "Wampler", :age => 39
person.first_name.should == "Dean"
person.last_name.should == "Wampler"
person.age.should == 39
end
end
We require the case
gem, which puts the #===
method on steroids. In the when
statement in #initialize
, the expression when Case[Hash]
matches on a one-element array where the element is a Hash
. We extract the key-value pairs as before. The else
clause assumes we have an array for the arguments.
So far, this is isn’t very impressive, but all we did was to reproduce the original behavior. Let’s extend the example to really exploit some of the neat features of the Case
gem’s pattern matching. First, let’s narrow the allowed array values.
require "rubygems"
require "spec"
require "case"
class Person
attr_reader :first_name, :last_name, :age
def initialize *args
case args
when Case[Hash] # 1
arg = args[0]
@first_name = arg[:first_name]
@last_name = arg[:last_name]
@age = arg[:age]
when Case[String, String, Integer]
@first_name = args[0]
@last_name = args[1]
@age = args[2]
else
raise "Invalid arguments: #{args}"
end
end
end
describe "Person#initialize" do
it "should accept a first name, last name, and age arguments" do
person = Person.new "Dean", "Wampler", 39
person.first_name.should == "Dean"
person.last_name.should == "Wampler"
person.age.should == 39
end
it "should accept a has with :first_name => fn, :last_name => ln, and :age => age" do
person = Person.new :first_name => "Dean", :last_name => "Wampler", :age => 39
person.first_name.should == "Dean"
person.last_name.should == "Wampler"
person.age.should == 39
end
it "should not accept an array unless it is a [String, String, Integer]" do
lambda { person = Person.new "Dean", "Wampler", "39" }.should raise_error(Exception)
end
end
The new expression when Case[String, String, Integer]
only matches a three-element array where the first two arguments are strings and the third argument is an integer, which are the types we want. If you use an array with a different number of arguments or the arguments have different types, this when
clause won’t match. Instead, you’ll get the default else
clause, which raises an exception. We added another rspec example to test this condition, where the user’s age was specified as a string instead of as an integer. Of course, you could decide to attempt a conversion of this argument, to make your code more “forgiving” of user mistakes.
Similarly, what happens if the method supports default values some of the parameters. As written, we can’t support that option, but let’s look at a slight variation of Person#initialize
, where a hash of values is not supported, to see what would happen.
require "rubygems"
require "spec"
require "case"
class Person
attr_reader :first_name, :last_name, :age
def initialize first_name = "Bob", last_name = "Martin", age = 29
case [first_name, last_name, age]
when Case[String, String, Integer]
@first_name = first_name
@last_name = last_name
@age = age
else
raise "Invalid arguments: #{first_name}, #{last_name}, #{age}"
end
end
end
def check person, expected_fn, expected_ln, expected_age
person.first_name.should == expected_fn
person.last_name.should == expected_ln
person.age.should == expected_age
end
describe "Person#initialize" do
it "should require a first name (string), last name (string), and age (integer) arguments" do
person = Person.new "Dean", "Wampler", 39
check person, "Dean", "Wampler", 39
end
it "should accept the defaults for all parameters" do
person = Person.new
check person, "Bob", "Martin", 29
end
it "should accept the defaults for the last name and age parameters" do
person = Person.new "Dean"
check person, "Dean", "Martin", 29
end
it "should accept the defaults for the age parameter" do
person = Person.new "Dean", "Wampler"
check person, "Dean", "Wampler", 29
end
it "should not accept the first name as a symbol" do
lambda { person = Person.new :Dean, "Wampler", "39" }.should raise_error(Exception)
end
it "should not accept the last name as a symbol" do
end
it "should not accept the age as a string" do
lambda { person = Person.new "Dean", "Wampler", "39" }.should raise_error(Exception)
end
end
We match on all three arguments as an array, asserting they are of the correct type. As you might expect, #initialize
always gets three parameters passed to it, including when default values are used.
Let’s return to our original example, where the object can be constructed with a hash or a list of arguments. There are two more things (at least …) that we can do. First, we’re not yet validating the types of the values in the hash. Second, we can use the Case
gem to impose constraints on the values, such as requiring non-empty name strings and a positive age.
require "rubygems"
require "spec"
require "case"
class Person
attr_reader :first_name, :last_name, :age
def initialize *args
case args
when Case[Hash]
arg = args[0]
@first_name = arg[:first_name]
@last_name = arg[:last_name]
@age = arg[:age]
when Case[String, String, Integer]
@first_name = args[0]
@last_name = args[1]
@age = args[2]
else
raise "Invalid arguments: #{args}"
end
validate_name @first_name, "first_name"
validate_name @last_name, "last_name"
validate_age
end
protected
def validate_name name, field_name
case name
when Case::All[String, Case.guard {|s| s.length > 0 }]
else
raise "Invalid #{field_name}: #{first_name}"
end
end
def validate_age
case @age
when Case::All[Integer, Case.guard {|n| n > 0 }]
else
raise "Invalid age: #{@age}"
end
end
end
describe "Person#initialize" do
it "should accept a first name, last name, and age arguments" do
person = Person.new "Dean", "Wampler", 39
person.first_name.should == "Dean"
person.last_name.should == "Wampler"
person.age.should == 39
end
it "should accept a has with :first_name => fn, :last_name => ln, and :age => age" do
person = Person.new :first_name => "Dean", :last_name => "Wampler", :age => 39
person.first_name.should == "Dean"
person.last_name.should == "Wampler"
person.age.should == 39
end
it "should not accept an array unless it is a [String, String, Integer]" do
lambda { person = Person.new "Dean", "Wampler", "39" }.should raise_error(Exception)
end
it "should not accept a first name that is a zero-length string" do
lambda { person = Person.new "", "Wampler", 39 }.should raise_error(Exception)
end
it "should not accept a first name that is not a string" do
lambda { person = Person.new :Dean, "Wampler", 39 }.should raise_error(Exception)
end
it "should not accept a last name that is a zero-length string" do
lambda { person = Person.new "Dean", "", 39 }.should raise_error(Exception)
end
it "should not accept a last name that is not a string" do
lambda { person = Person.new :Dean, :Wampler, 39 }.should raise_error(Exception)
end
it "should not accept an age that is less than or equal to zero" do
lambda { person = Person.new "Dean", "Wampler", -1 }.should raise_error(Exception)
lambda { person = Person.new "Dean", "Wampler", 0 }.should raise_error(Exception)
end
it "should not accept an age that is not an integer" do
lambda { person = Person.new :Dean, :Wampler, "39" }.should raise_error(Exception)
end
end
We have added validate_name
and validate_age
methods that are invoked at the end of #initialize
. In validate_name
, the one when
clause requires “all” the conditions to be true, that the name is a string and that it has a non-zero length. Similarly, validate_age
has a when
clause that requires age
to be a positive integer.
Final Thoughts
So, how valuable is this? The code is certainly longer, but it specifies and enforces expected behavior more precisely. The rspec examples verify the enforcement. It smells a little of static typing, which is good or bad, depending on your point of view. ;)
Personally, I think the conditional checks are a good way to add robustness in small ways to libraries that will grow and evolve for a long time. The checks document the required behavior for code readers, like new team members, but of course, they should really get that information from the tests. ;) (However, it would be nice to extract the information into the rdocs
.)
For small, short-lived projects, I might not worry about the conditional checks as much (but how many times have those “short-lived projects” refused to die?).
You can read more about Omnibus
and Case
in this InfoQ interview with MenTaLguY. I didn’t discuss using the Actor model of concurrency, for which these gems were designed. For an example of Actors using Omnibus, see my Better Ruby through Functional Programming presentation or the Confreak’s video of an earlier version of the presentation I gave at last year’s RubyConf.