I have often found that, despite being one of the most powerful constructs of the language, ruby case statement is often undervalued in everyday code.
This is especially true when the people involved are coming from C/Java background because the switch statements in those languages are very restrictive in comparision to ruby. While ruby's case statement still leaves quite a bit to be desired after a taste of pattern matching in languages like elixir and scala, nevertheless, with a little effort we can adapt ruby's case statement to handle variety of use cases very expressively.
Under the hood of case statement
Behind the scenes, ruby uses the case equality method (===) for matching in case statements. So our primary appraoch for adding custom behavior to case statement would be through overriden behavior implemented in this method.
Proc as a match target
An especially convenient aspect of proc is that it's case equality method results in invocation of the proc itself - this comes in handy for quickly matching conditions:
def palindrome?(word)
case word
when proc{|w| w.reverse == w }
true
else
false
end
end
More expressive code with custom matchers
Custom matchers can often help us make our code more expressive and closer to intent.
DivisibilityMatcher = Struct.new(:divisor) do
def ===(target)
target % divisor == 0
end
end
def divisible_by(num)
DivisibilityMatcher.new(num)
end
case 18
when divisible_by(5)
puts "divisible by 5"
when divisible_by(3)
puts "divisible by 3"
when divisible_by(2)
puts "divisible by 2"
end
=> divisible by 3
Matchers for membership evaluation
A simple use case might be to extend case to handle membership evaluation:
MembershipEvaluationMatcher = Struct.new(:collection) do
def ===(target)
collection.include? target
end
end
def member_of(collection)
MembershipEvaluationMatcher.new(collection)
end
case 1
when member_of(%w[a b c])
puts 'case 1'
when member_of([1,2,3])
puts 'case 2'
end
=> case 2
Matcher composition
Advantage of having matcher objects dedicatedly handling only the responsibility of matching is that we can compose them in ways that make sense from the perspective of match making.
Let us extend our divisibility check to illustrate the above. You may have noticed that our checks don't fall through the conditions -- so while divisibility with 3 is reported, divisibility with 2 is not. Let us check both using a matcher.
CompositeDivisibilityMatcher = Struct.new(:matchers) do
def ===(target)
combine_results matchers.map{|m| m === target }
end
def combine_results match_results
false
end
end
class AllDivisibilityMatcher < CompositeDivisibilityMatcher
def combine_results match_results
match_results.reduce(:&)
end
end
class AnyDivisibilityMatcher < CompositeDivisibilityMatcher
def combine_results match_results
match_results.reduce(:|)
end
end
DivisibilityMatcher = Struct.new(:divisor) do
def ===(target)
target % divisor == 0
end
def &(matcher)
AllDivisibilityMatcher.new([self, matcher])
end
def |(matcher)
AnyDivisibilityMatcher.new([self, matcher])
end
end
def divisible_by(num)
DivisibilityMatcher.new(num)
end
(1..100).each do |num|
case num
when divisible_by(3) & divisible_by(5)
puts 'FizzBuzz'
when divisible_by(3)
puts 'Fizz'
when divisible_by(5)
puts 'Buzz'
else
puts num
end
end
We can also take advantage of proc currying reduce some verbosity here.
is_divisible = proc {|a,b| a % b == 0 }
is_divisible_by_all = proc {|arr, a| arr.map(&is_divisible.curry[a]).reduce(:&) }
is_divisible_by_any = proc {|arr, a| arr.map(&is_divisible.curry[a]).reduce(:|) }
case 18
when is_divisible_by_all.curry[[2, 3]]
puts 'div by 2 and 3'
when is_divisible_by_any.curry[[2, 3]]
puts 'div by 2 or 3'
end
=> div by 2 and 3
Matching against incomplete data structures
We can extend this concept to match against analogous data structures that are incompletely specified. In the example below we match against a list where missing values are denoted by :_ . The case statement will ignore these missing items when matching.
module Matchers
ListMatcher = Struct.new(:matchable_list) do
def ===(list)
return false unless list.length == matchable_list.length
list.each_with_index do |item, idx|
case matchable_list[idx]
when :_, item
next
else
return false
end
end
true
end
end
end
def matches array
Matchers::ListMatcher.new array
end
case [1,2,3]
when matches([1, :_ , 2, 3])
puts "case 1"
when matches([4, 5, 6])
puts "case 2"
when matches([:_, 1, 2])
puts "case 3"
when matches([1, :_ , 3])
puts "case 4"
end
# Outputs: case 4
Retrieving the missing data items
If we are matching against incomplete data structures perhaps it would be nice to extract the missing items as well. This requires a bit more boilerplate though.
module Matchers
Matchable = Struct.new(:target) do
def matches
@matches ||= []
end
end
ListMatcher = Struct.new(:matchable_list) do
def ===(list)
return false unless list.target.length == matchable_list.length
match_results = []
list.target.each_with_index do |item, idx|
case matchable_list[idx]
when :_
match_results << item
next
when item
next
else
return false
end
end
list.matches << match_results
true
end
end
end
def matches array
Matchers::ListMatcher.new array
end
matchable = Matchers::Matchable.new([1,2,3])
case matchable
when matches([1, :_ , 2, 3])
puts "case 1"
when matches([4, 5, 6])
puts "case 2"
when matches([:_, 1, 2])
puts "case 3"
when matches([1, :_ , 3])
puts "case 4"
end
puts matchable.matches
=> [[2]]
Running the matchable through the case statements multiple times will keep appending the match results to the matches.
Thus we have explored a variety of use cases where the case construct can, perhaps unexpectedly, be applied to our advantage.
This concludes on our exploration of extensibility of the ruby case equality and I hope that it helps you write more expressive and elegant code using matchers.