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.
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 aconditions
Hash
of{ by_5: true, by_7: false }
. - Given an
integer
of 7, we’ll have aconditions
Hash
of{ by_5: false, by_7: true }
. - Given an
integer
of 35, we’ll have aconditions
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.