How to Use Ruby Case Statements with === / Higher Order Lambdas / Pattern Matching
Tags: ruby
Published: 2021-01-01 04:40:36

Happy New Year!

In this [blog post](https://andymaleh.blogspot.com/2020/12/how-to-use-ruby-case-statements-with.html), 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](https://rubyreferences.github.io/rubyref/language/pattern-matching.html).

I just had to refactor some code in my new project [YASL](https://andymaleh.blogspot.com/2020/12/yet-another-serialization-library.html) (Yet Another Serialization Library), which was originally in this form:

```ruby
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
```

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:

```ruby
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    
```

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:

```ruby
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    
```

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:

```ruby
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    
```

That said, in the newly released [Ruby 3](https://www.ruby-lang.org/en/news/2020/12/25/ruby-3-0-0-released/), one could just rely on Array [Pattern Matching](https://rubyreferences.github.io/rubyref/language/pattern-matching.html) via `case in` instead of `case when` to avoid higher order lambdas altogether:

```ruby
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
```

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](https://github.com/AndyObtiva/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:

- `case..when`  [`===` method](https://ruby-doc.org/core-3.0.0/Module.html#method-i-3D-3D-3D) invocations on when statement objects
- `case..when` [`Proc` implicit `call` invocations](https://ruby-doc.org/core-3.0.0/Proc.html#method-i-3D-3D-3D), which can be augmented with higher order lambdas
- `case..in` [Pattern Matching](https://rubyreferences.github.io/rubyref/language/pattern-matching.html) in [Ruby 3](https://www.ruby-lang.org/en/news/2020/12/25/ruby-3-0-0-released/)

Have a Happy 2021!