Search code examples
rubyenumsreduceinject

Rewriting Enum#inject to take a Symbol as a Parameter in Ruby


So, I'm going through The Odin Project's Ruby Path, and in one of the Projects we have to rewrite some of the Enum functions to their basic functionality. Thing is, I want to try to go beyond and recreate those functions as close to the original ones as possible, which brings me to Enum#inject.

I have recreated it using the following code (which is inside the Enumerable module)

def my_inject(initial = first, sym = nil)
  memo = initial

  enum = to_a

  if block_given?
    enum.my_each_with_index do |el, idx|
      next if memo == first && idx.zero?

      memo = yield memo, el
    end
  else
    block = sym.to_proc
    enum.my_each_with_index do |el, idx|
      next if memo == first && idx.zero?

      memo = block.call memo, el
    end
  end
  memo
end

(my_each_with_index being a custom version of each_with_index that should work like so)

This version is almost working fine. Problem is only when I call it with only a Symbol as argument, like in ((5..10).my_inject(:+)), cause it throws a 'my_inject': undefined method 'to_proc' for nil:NilClass (NoMethodError).

I'm guessing this is happening because the symbol is being passed as the initial value, as the first parameter of the function.

I thought about trying to write a bunch of checks (like to check if the function has only one argument and that argument is a symbol), but I wanna know if there is an easier and cleaner way of doing it so.

(Please, bear in mind I've been studying code for no more than 6 months, so I am a very very VERY green at this).

I appreciate the help!


Solution

  • The build-in inject is quite polymorphic, so before trying to implement it from scratch (without looking at the source code) we would need to explore how it behaves in different cases. I skip things that you already know (like using the 1st element as an initial value if not provided explicitly etc), other than that:

    [1,2,3].inject(0, :+, :foo) #=> ArgumentError: wrong number of arguments (given 3, expected 0..2)
    # Max. arity is strict
    
    [1,2,3].inject(0, :+) { 1 } #=> 6
    # If both symbol _and_ block are provided, the former dominates
    
    [1,2,3].inject(:+) { |acc, x| acc } #=> :+
    # With only 1 parameter and a block the former will be treated as an init value, not a proc.
    
    [1,2,3].inject("+") #=> 6
    [1,2,3].inject("+") { |acc, x| acc } #=> "+"
    # Strings work too. This is important, because strings _don't respond to `to_proc`_, so we would need smth. else
    
    [1,2,3].inject #=> LocalJumpError: no block given
    # Ok, local jump error means that we try to yield in the cases where the 2nd parameter is not provided
    
    [1,2,3].inject(nil) #=> TypeError: nil is not a symbol nor a string
    # But if it is provided, we try to do with it something that quacks pretty much like `send`...
    

    With these bits in mind, we can write something like

    module Enum
      # Just for convenience, you don't need it if you implement your own `each`
      include Enumerable
    
      def my_inject(acc = nil, sym = nil)
        # With a single attribute and no block we assume that the init value is in fact nil
        # and what is provided should be "called" on elements somehow
        if acc && !sym && !block_given?
          sym, acc = acc, nil
        end
      
        each do |element|
          if !acc
            # If we are not initialized yet, we just assign an element to our accumulator
            # and proceed
            acc = element
          elsif sym
            # If the "symbol" was provided explicitly (or resolved as such in a single parameter case)
            # we try to call the appropriate method on the accumulator. 
            acc = acc.send(sym, element)
          else
            # Otherwise just try to yield
            acc = yield acc, element
          end
        end
        
        acc
      end
    end
    

    Bear with me, we're almost there :) Just let's check how it quacks:

    class Ary < Array
      include Enum
    end
    
    ary = Ary.new.push(1,2,3)
    
    ary.my_inject #=> LocalJumpError: no block given
    ary.my_inject(0) #=> TypeError: 0 is not a symbol nor a string
    ary.my_inject("+") #=> 6
    ary.my_inject(:+) #=> 6
    ary.my_inject(0, :+) #=> 6
    ary.my_inject(1, :+) #=> 7
    ary.my_inject(1, :+) { 1 } #=> 7
    ary.my_inject(:+) { |acc, x| acc } #=> :+
    

    So, pretty much the same. There might be some other edge cases that my implementation doesn't satisfy, but I leave them to you :)