VaVa
VaVa

Reputation: 410

Why is Ruby's Array#shift used in this task?

I'm going through this annoying tutorial on Rubymonk and it asks me to do the following:

Write for me three methods - calculate, add and subtract. The tests should all pass. Take a look at the hint if you have trouble! And as a little extra hint: remember that you can use something.is_a?(Hash) or another_thing.is_a?(String) to check an object's type.

I couldn't even understand what they ask me to do, so I just decided to see the solution and and work my way into a decent understanding of the task.

Here is the solution:

def add(*numbers)
  numbers.inject(0) { |sum, number| sum + number }  
end

def subtract(*numbers)
  current_result = numbers.shift
  numbers.inject(current_result) { |current_result, number| current_result - number }  
end

def calculate(*arguments)
  # if the last argument is a Hash, extract it 
  # otherwise create an empty Hash
  options = arguments[-1].is_a?(Hash) ? arguments.pop : {}
  options[:add] = true if options.empty?
  return add(*arguments) if options[:add]
  return subtract(*arguments) if options[:subtract]
end

I don't understand many things, but the one thing that baffles me is the shift method: current_result = numbers.shift. Why is it there? I mean, I understand what it does, but what's its job in this particular piece of code?

Btw, if someone goes to the trouble to break this code down for me I would be endlessly and eternally thankful.

The task is at the bottom of the following page: https://rubymonk.com/learning/books/1-ruby-primer/chapters/19-ruby-methods/lessons/69-new-lesson#solution3899

Upvotes: 1

Views: 341

Answers (3)

Wand Maker
Wand Maker

Reputation: 18762

Explanation of subtract method

Lets say your input was [2,3,4,5].

In your mind, you can think that you need a program which does this

result = 2 # This is first element of array
result = result - 3  # This is second element of array
result = result - 4  # This is third element of array
result = result - 5  # This is fourth element of array

So that finally, you end up with

result = -10

shift allows you to take the first element from the array, so that you can use inject on remaining elements with first element shifted of array acting as accumulator value.

The above explanation will make sense if you are aware of how inject method works.

Same approach could have been taken for add method as well.

def add(*numbers)
  current_result = numbers.shift
  numbers.inject(current_result) { |current_result, number| current_result + number }  
end

However, the authors of the add code used the more orthodox version of inject in their implementation as adding 0 to another number will not have any impact.

If authors used their add method and replaced + with -, they would have ended up with

def subtract(*numbers)
  numbers.inject(0) { |result, number| result - number }  
end

However, subtract([2,3,4,5]) would have provided output of -14 (0-2-3-4-5 = -14) instead of -10 (2-3-4-5 = -10).

To fix the above code, one will have to get rid of default parameter 0 in both add and subtract, and write something like below

def subtract(*numbers)
  numbers.inject { |result, number| result - number }  
end

def add(*numbers)
  numbers.inject { |result, number| result + number }  
end

We will now get right result of -10 for subtract([2,3,4,5])

The above code works because as documented here (and also expressed in other answers)

The argument to inject is actually optional. If a default value is not passed in as an argument, the first time the block executes, the first argument will be set to the first element of the enumerable and the second argument will be set to the second element of the enumerable.

In summary, authors of Rubymonk could have avoided the confusion, or may be they were trying to teach the reader one more Ruby trick by using shift

Explanation of calculate method

An easy to understand version of calculate can be something like below:

def calculate(*arguments, options)
  return add(*arguments) if options[:add]
  return subtract(*arguments) if options[:subtract]
end

p calculate(1,2,3,4,{add: true}) # Adds the numbers, outputs 10
p calculate(1,2,3,4,{subtract: true}) # Subtracts the numbers, outputs -8

Ruby will assign last parameter of function call to options and rest of elements as an array to arguments. If we called calculate with only one parameter, that will be the value of options.

p calculate({add: true})  # Outputs 0
p calculate(1) # Will return error as 1 is not a Hash and can't be assigned to 'options'

Author of code wanted to make options an optional argument, so that below code worked as well.

p calculate(1,2,3,4) # Assume add, and output 10

That will require a change in the simpler version of calculate such that instead of explicitly declaring options as the argument to calculate, we can use the last element of arguments array as options if it is of type Hash. We will thus end up implementing the calculate as shown on the Rubymonk.

Upvotes: 2

Cary Swoveland
Cary Swoveland

Reputation: 110685

add(*numbers)

Let's start by invoking:

def add(*numbers)
  numbers.inject(0) { |sum, number| sum + number }  
end

like this:

add(1,2,3)    #=> 6

or like this:

add(*[1,2,3]) #=> 6

The two are equivalent. The latter shows you what the operator "splat" does.

This results in:

numbers      #=> [1,2,3]

so Ruby sends Enumerable#inject (aka reduce) to numbers:

[1,2,3].inject(0) { |sum, number| sum + number }

inject first initializes the "memo" sum to inject's argument (if, as here, it has one) and then passes the first element of the "receiver" [1,2,3] into the block and assigns it to the block variable number:

sum          #=> 0
number       #=> 1

Ruby then computes:

sum + number #=> 0 + 1 => 1

which becomes the new value of the memo. Next, inject passes 2 into the block and computes:

sum          #=> 1
number       #=> 2
sum + number #=> 3

so (the memo) sum is now 3.

Lastly,

sum          #=> 3
number       #=> 3
sum + number #=> 6

As all elements of the receiver have been passed into the block, inject returns the value of the memo:

sum          #=> 6

If you examine the doc for inject you'll see that if the method has no argument Ruby assigns the first element of the receiver (here 1) to the memo (sum) and then carries on as above starting with the second element of the receiver (2). As expected, this produces the same answer:

def add(*numbers)
  numbers.inject { |sum, number| sum + number }  
end

add(1,2,3) #=> 6

So why include the argument zero? Often we will want add() (i.e., add(*[])) to return zero. I will leave it to you to investigate what happens here with each of the two forms of inject. What conclusion can you draw?

As @Stefan points out in his answer, you can simply this to:

def add(*numbers)
  numbers.inject :+
end

which is how you'd normally see it written.

If, however, numbers may be an empty array, you'd want to provide an initial value of zero for the memo:

def add(*numbers)
  numbers.inject 0, :+
end

add(*[]) #=> 0

subtract(*numbers)

def subtract(*numbers)
  current_result = numbers.shift
  numbers.inject(current_result) { |current_result, number|
    current_result - number }  
end

This is similar to the method add, with a small twist. We need the first value of the memo (here current_result) to be the first element of the receiver. There are two ways we could do that.

The first way is like this:

def subtract(*numbers)
  numbers[1..-1].inject(numbers.first) { |current_result, number|
    current_result - number }  
end

numbers = [6,2,3]    
subtract(*numbers) #=> 1 

Let

first_number = numbers.first   #=> 6
all_but_first = numbers[1..-1] #=> [2,3]

then:

numbers[1..-1].inject(numbers.first) { ... }

is:

all_but_first.inject(first_number) { ... }
  #=> [2,3].inject(6) { ... }

Instead, the author chose to write:

first_number = numbers.shift   #=> 6
numbers                        #=> [2,3]

numbers.inject(first_number) { ... }
  #=> [2,3].inject(6) { ... }

which may be a bit niftier, but the choice is yours.

The second way is to use inject without an argument:

def subtract(*numbers)
  numbers.inject { |current_result, number| current_result - number }  
end

numbers = [6,2,3]    
subtract(*numbers) #=> 1 

You can see why this works by reviewing the doc for inject.

Moreover, similar to :add, you can write:

def subtract(*numbers)
  numbers.inject :-  
end

Lastly, subtract requires numbers to have at least one element, so we might write:

def subtract(*numbers)
  raise ArgumentError, "argument cannot be an empty array" if numbers.empty?
  numbers.inject :-  
end

calculate(*arguments)

We see that calculate expects to be invoked in one of the following ways:

calculate(6,2,3,{ :add=>true })      #=> 11 
calculate(6,2,3,{ :add=>7 })         #=> 11  
calculate(6,2,3,{ :subtract=>true }) #=> 1 
calculate(6,2,3,{ :subtract=>7 })    #=> 1 
calculate(6,2,3)                     #=> 11 

If the hash has a key :add with a "truthy" value (anything other than false or nil, we are to add; if the hash has a key :subtract with a "truthy" value (anything other than false or nil, we are to subtract. If the last element is not a hash (calculate(6,2,3)), add is assumed.

Note:

calculate(6,2,3,{ :add=>false })     #=> nil
calculate(6,2,3,{ :subtract=>nil })  #=> nil

Let's write the method like this:

def calculate(*arguments)
  options =
  if arguments.last.is_a?(Hash) # or if arguments.last.class==Hash 
    arguments.pop
  else
    {}
  end 

  if (options.empty? || options[:add])
    add *arguments
  elsif options[:subtract]
    subtract *arguments
  else
    nil
  end
end

calculate(6,2,3,{ :add=>true })      #=> 11 
calculate(6,2,3,{ :add=>7 })         #=> 11  
calculate(6,2,3,{ :subtract=>true }) #=> 1 
calculate(6,2,3,{ :subtract=>7 })    #=> 1 
calculate(6,2,3)                     #=> 11 
calculate(6,2,3,{ :add=>false })     #=> nil
calculate(6,2,3,{ :subtract=>nil })  #=> nil

Note the return keyword is not needed (nor is it needed in the original code). It seems very odd that a hash would be used to signify the type of operation to be performed. It would make more sense to invoke the method:

calculate(6,2,3,:add)                #=> 11 
calculate(6,2,3)                     #=> 11 
calculate(6,2,3,:subtract)           #=> 1 

We can implement that as follows:

def calculate(*arguments)
  operation =
  case arguments.last
  when :add
    arguments.pop
    :add
  when :subtract
    arguments.pop
    :subtract
  else
    :add
  end

  case operation
  when :add
    add *arguments
  else
    subtract *arguments
  end
end

Better:

def calculate(*arguments, op=:add)
  case op
  when :subtract
    subtract *arguments
  else
    add *arguments
  end
end

calculate(6,2,3,:add)                #=> 11 
calculate(6,2,3)                     #=> 11 
calculate(6,2,3,:subtract)           #=> 1 

I am overwhelmed by your offer to be "endlessly and eternally thankful", but if you appreciate my efforts for a few minutes, that is sufficient.

Upvotes: 4

Stefan
Stefan

Reputation: 114178

current_result = numbers.shift. Why is it there? I mean, I understand what it does, but what's its job in this particular piece of code?

The line removes the first element from the numbers array and assigns it to current_result. Afterwards, current_result - number is calculated for each number in the remaining numbers array.

Example with logging:

numbers = [20, 3, 2]
current_result = numbers.shift

current_result #=> 20
numbers        #=> [3, 2]

numbers.inject(current_result) do |current_result, number|
  (current_result - number).tap do |result|
    puts "#{current_result} - #{number} = #{result}"
  end
end

Output:

20 - 3 = 17
17 - 2 = 15

15 would be the method's return value.

However, removing the first element is not necessary; inject does this by default:

If you do not explicitly specify an initial value for memo, then the first element of collection is used as the initial value of memo.

Thus, the subtract method can be simplified:

def subtract(*numbers)
  numbers.inject { |current_result, number| current_result - number }  
end

subtract(20, 3, 2) #=> 15

You can also provide a method symbol:

def subtract(*numbers)
  numbers.inject(:-)
end

Same works for add:

def add(*numbers)
  numbers.inject(:+)
end

Upvotes: 3

Related Questions