DEV Community

Cover image for How to Use Ruby Case Statements with === / Higher Order Lambdas / Pattern Matching
Andy Maleh
Andy Maleh

Posted on

How to Use Ruby Case Statements with === / Higher Order Lambdas / Pattern Matching

Happy New Year!

In this blog post, I will go over how to use the Ruby case statement with Class implicit is_a? comparisons via === , higher order lambdas, and the new Ruby 3 pattern matching.

I just had to refactor some code in my new project YASL (Yet Another Serialization Library), which was originally in this form:

def dump_ruby_basic_data_type_data(object)
  case object
  when Time
    object.to_datetime.marshal_dump
  when Date
    object.marshal_dump
  when Complex, Rational, Regexp, Symbol, BigDecimal
    object.to_s
  when Set
    object.to_a.uniq.map {|element| dump_structure(element) unless unserializable?(element)}
  when Range
    [object.begin, object.end, object.exclude_end?]
  when Array
    object.map {|element| dump_structure(element) unless unserializable?(element)}
  when Hash
    object.reject do |key, value|
      [key, value].detect {|element| unserializable?(element)}
    end.map do |pair|
      pair.map {|element| dump_structure(element)}
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

That code relies on the Class === method, which tests if object.is_a?(SomeClass) by default.

Although the code is pretty concise and readable, there is one big issue in it, mainly that BigDecimal isn't loaded by default in Ruby, and in some older versions of Ruby, Set isn't loaded either. As such, that code would work in most cases, but bomb in cases in which comparison reaches BigDecimal while not loaded via require 'bigdecimal' or reaches Set while not loaded via require 'set'. Unfortunately, pre-loading BigDecimal and Set could raise the memory footprint of the library for no important reason, so it is not desirable as a solution.

To circumvent this problem, I ended up relying on higher order lambdas (in Ruby versions prior to Ruby 3) to achieve readable code without reliance on unwieldy if..elsif statements, albeit less pretty:

def dump_ruby_basic_data_type_data(object)
  class_ancestors_names_include = lambda do |*class_names|
    lambda do |object|
      class_names.reduce(false) do |result, class_name|
        result || object.class.ancestors.map(&:name).include?(class_name)
      end
    end
  end

  case object
  when class_ancestors_names_include.call('Time')
    object.to_datetime.marshal_dump
  when class_ancestors_names_include.call('Date')
    object.marshal_dump
  when class_ancestors_names_include.call('Complex', 'Rational', 'Regexp', 'Symbol', 'BigDecimal')
    object.to_s
  when class_ancestors_names_include.call('Set')
    object.to_a.uniq.map {|element| dump_structure(element) unless unserializable?(element)}
  when class_ancestors_names_include.call('Range')
    [object.begin, object.end, object.exclude_end?]
  when class_ancestors_names_include.call('Array')
    object.map {|element| dump_structure(element) unless unserializable?(element)}
  when class_ancestors_names_include.call('Hash')
    object.reject do |key, value|
      [key, value].detect {|element| unserializable?(element)}
    end.map do |pair|
      pair.map {|element| dump_structure(element)}
    end
  end
end    
Enter fullscreen mode Exit fullscreen mode

The reason that works is because Proc objects produced from lambdas have === implemented as simply call(object). This ensures that a Proc object is first called with the class name(s) as strings (not actual loaded classes), returning another Proc ready to do the comparison on the particular object being tested. Unfortunately, it is not very pretty due to the logic-unrelated lower-level .call methods. Thankfully, more recent versions of Ruby allows dropping them while keeping the . only for a more concise version:

def dump_ruby_basic_data_type_data(object)
  class_ancestors_names_include = lambda do |*class_names|
    lambda do |object|
      class_names.reduce(false) do |result, class_name|
        result || object.class.ancestors.map(&:name).include?(class_name)
      end
    end
  end

  case object
  when class_ancestors_names_include.('Time')
    object.to_datetime.marshal_dump
  when class_ancestors_names_include.('Date')
    object.marshal_dump
  when class_ancestors_names_include.('Complex', 'Rational', 'Regexp', 'Symbol', 'BigDecimal')
    object.to_s
  when class_ancestors_names_include.('Set')
    object.to_a.uniq.map {|element| dump_structure(element) unless unserializable?(element)}
  when class_ancestors_names_include.('Range')
    [object.begin, object.end, object.exclude_end?]
  when class_ancestors_names_include.('Array')
    object.map {|element| dump_structure(element) unless unserializable?(element)}
  when class_ancestors_names_include.('Hash')
    object.reject do |key, value|
      [key, value].detect {|element| unserializable?(element)}
    end.map do |pair|
      pair.map {|element| dump_structure(element)}
    end
  end
end    
Enter fullscreen mode Exit fullscreen mode

This is prettier and more readable, but still a bit awkward. Can we drop the dot (.) entirely? Sure. Just switch parentheses to square brackets, and you could drop the dot (.) in newish versions of Ruby, resulting in more readable code:

def dump_ruby_basic_data_type_data(object)
  class_ancestors_names_include = lambda do |*class_names|
    lambda do |object|
      class_names.reduce(false) do |result, class_name|
        result || object.class.ancestors.map(&:name).include?(class_name)
      end
    end
  end

  case object
  when class_ancestors_names_include['Time']
    object.to_datetime.marshal_dump
  when class_ancestors_names_include['Date']
    object.marshal_dump
  when class_ancestors_names_include['Complex', 'Rational', 'Regexp', 'Symbol', 'BigDecimal']
    object.to_s
  when class_ancestors_names_include['Set']
    object.to_a.uniq.map {|element| dump_structure(element) unless unserializable?(element)}
  when class_ancestors_names_include['Range']
    [object.begin, object.end, object.exclude_end?]
  when class_ancestors_names_include['Array']
    object.map {|element| dump_structure(element) unless unserializable?(element)}
  when class_ancestors_names_include['Hash']
    object.reject do |key, value|
      [key, value].detect {|element| unserializable?(element)}
    end.map do |pair|
      pair.map {|element| dump_structure(element)}
    end
  end
end    
Enter fullscreen mode Exit fullscreen mode

That said, in the newly released Ruby 3, one could just rely on Array Pattern Matching via case in instead of case when to avoid higher order lambdas altogether:

def dump_ruby_basic_data_type_data(object)
  case object.class.ancestors.map(&:name)
  in [*, 'Time', *]
    object.to_datetime.marshal_dump
  in [*, 'Date', *]
    object.marshal_dump
  in [*, 'Complex', *] | [*, 'Rational', *] | [*, 'Regexp', *] | [*, 'Symbol', *] | [*, 'BigDecimal', *]
    object.to_s
  in [*, 'Set', *]
    object.to_a.uniq.map {|element| dump_structure(element) unless unserializable?(element)}
  in [*, 'Range', *]
    [object.begin, object.end, object.exclude_end?]
  in [*, 'Array', *]
    object.map {|element| dump_structure(element) unless unserializable?(element)}
  in [*, 'Hash', *]
    object.reject do |key, value|
      [key, value].detect {|element| unserializable?(element)}
    end.map do |pair|
      pair.map {|element| dump_structure(element)}
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

That takes away the need to use higher order lambdas, which are a more complicated construct that is better avoided when possible. That said, YASL (Yet Another Serialization Library) still needs to support older Ruby versions, so I am stuck with higher order lambdas for now.

In summary, case statements provide multiple ways to test objects through:

Have a Happy 2021!

Top comments (0)