ICICLES OF THOUGHT

Ramblings on Web Development and Software Architecture

Posted  5 years ago

Tags

Extending the ruby case statement for fun and profit
This post has not been updated in quite some time and the content here may be out of date or not reflect my current my recommedation in the matter.

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.