Case Equality for Proc Objects in Ruby
The Basics
Let p
be a Ruby proc object and let obj
be an arbitrary object. In
an expression such as
p === obj
the ===
method “invokes the block with obj
as the proc’s parameter
like #call
. This allows a proc object to be the target of a when
clause in a case statement”. (I copied this description from the the
documentation of ===
as a method on Proc
objects.
Let’s start by considering the kind of the case
statements that are
usually used to motivate the behaviour described above.
case 3
when even? then puts 'even'
when odd? then puts 'odd'
else puts 'impossible'
end
But how does this work in practice? How do we construct even?
and
odd?
. How do we use the even?
and odd?
methods on integers that
Ruby already provides?
To understand how Ruby processes the different options in the case expression, we consider the following equivalent expression:
if even? === 3
puts 'even'
elsif odd? === 3
puts 'odd'
else
puts 'impossible'
end
The most important aspect of this transformation is that clauses in
case
expressions are compared using the ===
operator. The
documentation cited at the beginning of the current article means that
this is equivalent to
if even?.call(3)
puts 'even'
elsif odd?.call(3)
puts 'odd'
else
puts 'impossible'
end
Procs, Lamdas, and Methods
What does this imply for even?
and odd?
? Whatever even?
and
odd?
are, they must have a call
method. And we know that proc
objects have a call
method.
Ruby knows two slightly different kinds of proc objects (namely lambdas and procs) that have slightly different propertries (that are not relevant for the current discussion).
Each of the two kinds can be defined in (at least) two different ways.
l1 = lambda { |n| n + 1 }
l2 = -> n { n + 1 }
p1 = Proc.new { |n| n + 1 }
p2 = proc { |n| n + 1 }
l1
and l2
define the same lambda and p1
and p2
define the same
(non-lambda) proc.
Once a proc object is defined, it can be called in (at least) four different ways.
p = -> n { n + 1 }
p.call(1)
p.(1)
p[1]
p === 1
Back to the initial example. How exactly do we construct even?
and
odd?
? The following is a first example.
def even?
->(n) { n.even? }
end
def odd?
->(n) { n.odd? }
end
Here, even?
and odd?
are methods that do not take any
arguments. Instead, they return a lambda that takes a single
argument. This is an important distinction. In this definition,
even?
and odd?
are not proc objects.
We could try to simplify this a bit by directly assigning proc objects to local variables (instead of returning them from method calls).
even? = Proc.new { |n| n.even? }
odd? = Proc.new { |n| n.odd? }
Unfortunately, this does not work. Ruby does allow method names to end with a question mark but it does not allow variable names to end with a question mark.
We could still replace the lambdas returned by even?
and odd?
by
procs.
def even?
Proc.new { |n| n.even? }
end
def odd?
Proc.new { |n| n.odd? }
end
This does not help too much. In the context of the current discussion. Methods returning procs are not too different from methods returning lambdas and the effort for writing procs is the same as the effort for writing lambdas.
Reusing Existing Methods
In order to use an existing method with case
and when
as described
above, it needs to be wrapped in something like a lambda or a
proc. And this needs to be done for each of the branches of the case
expression. This quickly becomes somewhat painful if the number of
branches grows.
zero = -> (n) { n % 4 == 0 }
one = -> (n) { n % 4 == 1 }
two = -> (n) { n % 4 == 2 }
three = -> (n) { n % 4 == 3 }
case 3
when zero then puts 'divisile'
when one then puts 'residue 1'
when two then puts 'residue 2'
when three then puts 'residue 3'
else puts 'impossible'
end
The example above demonstrates that it is not always necessary to use
def
to define methods that return lamdas or procs. We can also use
(e.g.) local varriables. In the first examples, this was only
necessary because that questionmark in even?
and odd?
looks so
good.
Of course, the last example is somewhat contrived. It can be written as
case 3 % 4
when 0 then puts 'divisile'
when 1 then puts 'residue 1'
when 2 then puts 'residue 2'
when 3 then puts 'residue 3'
else puts 'impossible'
end
The lambdas that are assigned to the zero
, one
, two
, three
variables, are created explicitly for each possible residue. Since
Ruby is a very flexible language, there are ways to get rid of this
explicitness.
[[:zero, 0], [:one, 1], [:two, 2], [:three, 3]].each do |(name, residue)|
define_method(name) { -> (n) { n % 4 == residue } }
end
case 3
when zero then puts 'divisile'
when one then puts 'residue 1'
when two then puts 'residue 2'
when three then puts 'residue 3'
else puts 'impossible'
end
Readability
An expression such as
case 3
when even? then puts 'even'
when odd? then puts 'odd'
else puts 'impossible'
end
is very readable. There is no doubt about this. Now, let’s add the methods that return anonymous functions.
def even?
->(n) { n.even? }
end
def odd?
->(n) { n.odd? }
end
case 3
when even? then puts 'even'
when odd? then puts 'odd'
else puts 'impossible'
end
Is this still readable? Since the case
expression did not change,
the answer should still be “yes”.
The price to be paid for the readability of the case
expression is
the unreadability of the definition of the lambda expressions. There
may be special cases where using case equality for proc is a good
idea, but in general, it seems to sacrifices simplicity for a some
sort of elegance. This becomes even more apparent when looking at
another example from above.
[[:zero, 0], [:one, 1], [:two, 2], [:three, 3]].each do |(name, residue)|
define_method(name) { -> (n) { n % 4 == residue } }
end
case 3
when zero then puts 'divisile'
when one then puts 'residue 1'
when two then puts 'residue 2'
when three then puts 'residue 3'
else puts 'impossible'
end
There is a big discrepancy in perceived “complexity” between the apparent
simplicity of the case
expression and the definition of the lambdas used in
the case
expression.
Changeability
Let’s have a look at another example. Suppose that is_prime
is some
method that returns true
if the argument passed in is a prime number
and false
otherwise. (Note that the definition of is_prime
below
is not correct for arbitrary input.)
def is_prime?(n)
[11, 13, 17, 19].include?(n)
end
def small?
-> (n) { n < 10 }
end
def prime?
-> (n) { is_prime?(n) }
end
case 13
when small? then puts 'small'
when prime? then puts 'prime'
else puts 'composite'
end
The reason why the case
expression in this example is easy to read
is that we believe we understand what it does. And Ruby is built in
a way that our believe matches implementation. Now suppose we want to
extend the case
expression so that it returns “negative” for
negtative input. Easy. Just write
case 13
when negative? then puts 'negative'
when small? then puts 'small'
when prime? then puts 'prime'
else puts 'composite'
end
The only part that is missing is that negative?
thing that (as we
remember) must be or return a proc object that accepts a call
method
with one integer argument.