On Ruby Keyword Args, Structs, Splat, and Double Splat Operators (Oh My!)

Dissecting an Approach to Solving a Coding Challenge I Encountered in the Wild

What follows is a coding challenge that I recently encountered. During the challenge, I leveraged some Ruby “magic” to make what I consider an elegant solution. What follows is a step-through of the coding challenge iterative tasks. For those impatient, skip ahead to Task 3.

Task 1

Write a function when given an integer returns the following:

  • For number divisible by 5 or that include the numeral 5, render “cats”.
  • For number divisible by 7 or that include the numeral 7, render “boots”.
  • For number that are both of the above, render “boots and cats”.

Note: If you are doing coding challenges, get comfortable with modulo arithmetic; that stuff shows up a lot. Oddly, I rarely use it in my day to day coding. So I wonder why it keeps showing up in interview coding challenges?

Task 2

Starting from 1, generate a list of the first number that matches each of the permutations of the above. (e.g. A: contains 5, but not divisible by 5, nor 7, nor contains 7, B: contains 5, and is divisible by 5, but not 7 nor contains 7).

For this to be feasible, you need to know how many combinations are possible. Note, we are not worried about printing “cats” and “boots” at this point. Instead we’re generating a list of numbers.

Task 3

Generalize the above so that users can provide arbitrary rules for task 2.

Solution

class FirstMatchGenerator
  def initialize(**rules)
    @rules = rules
    @number_of_permutations = 2 ** rules.length
    @tester = Struct.new(*rules.keys, keyword_init: true)
  end

  def call
    results = {}
    integer = 0
    continue = true
    while continue
      integer += 1
      candidate = candidate_for(integer: integer)
      next if results.include?(candidate)

      results[candidate] = integer
      continue =  results.size < @number_of_permutations
    end
    results.values
  end

  private

  def candidate_for(integer:)
    conditions = @rules.each_with_object({}) do |(name, func), cond|
      cond[name] = func.call(integer)
    end
    candidate = @tester.new(**conditions)
  end
end

puts FirstMatchGenerator.new(
  by_5: ->(i) { i % 5 == 0 },
  by_7: ->(i) { i % 7 == 0 }
).call.inspect
# => [1, 5, 7, 35]

Dissecting the Solution

The above solution makes use of Ruby’s keyword args, splat and double splat operator, and the Struct for equality tests.

Let’s look at the instantiation of the class:

FirstMatchGenerator.new(
  by_5: ->(i) { i % 5 == 0 },
  by_7: ->(i) { i % 7 == 0 }
)

In the above, we’re passing the keyword keys of :by_5 and :by_7. The values of those keys are lambdas (e.g. ->(i) { i % 5 == 0 }, and ->(i) { i % 7 == 0 }). If it helps, think about us passing a Ruby Hash as the parameter.

Now let’s example the FirstMatchGenerator#initialize method:

def initialize(**rules)
  @rules = rules
  @number_of_permutations = 2 ** rules.length
  @tester = Struct.new(*rules.keys, keyword_init: true)
end

In the above, the **rules means we accept arbitrarily named keyword args (e.g. :by_5 and :by_7). In fact, this is treated as Hash object in the initialize method.

The number of permutations is two raised to the power of the number of rules. In the example that would be 22 or 4.

And last the @tester is a dynamically created Struct object with attributes that are the given named args via the splat operator (e.g. *rules.keys). And with the keyword_init: true we can instantiate this Struct with keyword args.

The reason for using the Struct is that it implements an equality operator. For two Struct’s, if all of their attributes are identical then the two Struct objects are considered to be “equal” (e.g. a == b).

Now to the FirstMatchGenerator#call method:

def call
  results = {}
  integer = 0
  continue = true
  while continue
    integer += 1
    candidate = candidate_for(integer: integer)
    next if results.include?(candidate)

    results[candidate] = integer
    continue =  results.size < @number_of_permutations
  end
  results.values
end

There’s quite a bit going on. Before the while loop is a setup of variables. Within the while loop we:

  • create a candidate (more on that in a bit)
  • check if we already have encountered that candidate (the Hash#include? method checks the equality of the candidate)
  • remember a new candidate
  • break if we’ve encountered all candidates

After the while loop we return the list of integers.

In my original implementation I did not have a private method but instead in-lined the results.

And last, the FirstMatchGenerator#candidate_for method:

def candidate_for(integer:)
  conditions = @rules.each_with_object({}) do |(name, func), cond|
    cond[name] = func.call(integer)
  end
  candidate = @tester.new(**conditions)
end

The @rules are a Hash with keys that are symbols and values that are lambdas (e.g. { by_5: ->(i) { i % 5 == 0 }, by_7: ->(i) { i % 7 == 0 } }).

Using those rules, the above code calls each of the lambdas to determine the result.

  • Given an integer of 5, we’ll have a conditions Hash of { by_5: true, by_7: false }.
  • Given an integer of 7, we’ll have a conditions Hash of { by_5: false, by_7: true }.
  • Given an integer of 35, we’ll have a conditions Hash of { by_5: true, by_7: true }.

We then use that conditions Hash to instantiate our @tester Struct; which provides us with the nifty equality test.

Conclusion

Did I need the dynamic Struct assignment? No. I could’ve used Hash equality tests.

But in my experience an over-reliance on the Hash object creates later problems. Why? Because a Hash is a schema-less data store. Whereas a Struct has a schema. And can provide more robust debugging information.

My hope in this walk through is to highlight some of the interplay of Ruby idioms.

For myself, I’m long a fan of keyword args and ever growing fan of the humble Struct object.