Can a Ruby method accept either a block OR an argument?
Asked Answered
G

3

6

I'm doing the lessons on The Odin Project and now I have to write myself a new #count method (with another name) that behaves like the normal one from the Enumerable module.

The documentation on count says the following (http://ruby-doc.org/core-2.4.0/Enumerable.html#method-i-count):

count → int
count(item) → int
count { |obj| block } → int

Returns the number of items in enum through enumeration. If an argument is given, the number of items in enum that are equal to item are counted. If a block is given, it counts the number of elements yielding a true value.

I think I can write all of these as separate methods, but I was mostly wondering if one method definition can combine the last two uses of count - with item and with the block. Naturally, I'm wondering if all three can be combined in one definition, but I'm mostly interested in the last two. So far I can't seem to find a possible answer.

The documentation page has these examples:

ary = [1, 2, 4, 2]
ary.count               #=> 4
ary.count(2)            #=> 2
ary.count{ |x| x%2==0 } #=> 3
Gramophone answered 8/2, 2017 at 17:20 Comment(0)
P
6

Sure it's possible. All you have to do is check if an argument is given and also check if a block is given.

def call_me(arg=nil)
  puts "arg given" unless arg.nil?
  puts "block given" if block_given?
end

call_me(1)
# => arg given
call_me { "foo" }
# => block given
call_me(1) { "foo" }
# => arg given
#    block given

Or:

def call_me(arg=nil, &block)
  puts "arg given" unless arg.nil?
  puts "block given" unless block.nil?
end

The latter is useful because it converts the block to a Proc (named block) that you can then reuse, as below.

You could implement your own count method like this:

module Enumerable
  def my_count(*args, &block)
    return size if args.empty? && block.nil?
    raise ArgumentError, "wrong number of arguments (given #{args.size}, expected 1)" if args.size > 1

    counter = block.nil? ? ->(i) { i == args[0] } : block
    sum {|i| counter.call(i) ? 1 : 0 }
  end
end

arr = [1,2,3,4,5]
p arr.my_count # => 5
p arr.my_count(2) # => 1
p arr.my_count(&:even?) # => 2
p arr.my_count(2, 3) # => ArgumentError: wrong number of arguments (given 2, expected 1)

See it on repl.it: https://repl.it/@jrunning/YellowishPricklyPenguin-1

Playwright answered 8/2, 2017 at 17:44 Comment(3)
Thank you for your answer! I eventually ended up doing something similar and it worked. I'll post my solution below. Just a minor question though - in your first example shouldn't the third line be "puts "block given" if block_given?", with if, not unless? I tried something similar to your last example, but couldn't figure out the "block.nil?" part and I always ended up with an argument error.Gramophone
Yes, I meant if, not unless. Good catch. I can't explain your ArgumentError without seeing your code, however.Playwright
I just meant an argument error for wrong number of arguments because &block is explicitly declared as an argument and I couldn't use it without a block, as I didn't think of the block.nil? part.Gramophone
K
2

Yes, it is possible to do this by making the parameters optional (blocks are always optional anyway) and checking whether a positional argument or a block argument was passed.

This is a bit messy, though. Most Ruby implementations get around this, by implementing the methods in question with privileged access to the private internals of the implementation, which makes it much easier to check whether arguments were passed or not. E.g. both JRuby and IronRuby have ways to bind multiple overloaded Java / CLI methods to a single Ruby method based on the number and the types of arguments, which makes it possible to implement those three "modes" of count as three simple overloads of the same method. Here's the example of count from IronRuby, and this is count from JRuby.

Ruby, however, doesn't support overloading, so you have to implement it manually, which can be a bit awkward. Something like this:

module Enumerable
  def count(item = (item_not_given = true; nil))
    item_given = !item_not_given
    warn 'given block not used' if block_given? && item_given

    return count(&item.method(:==)) if item_given
    return inject(0) {|acc, el| if yield el then acc + 1 else acc end } if block_given?
    count(&:itself)
  end
end

As you can see, it is a bit awkward. Why don't I simply use nil as a default argument for the optional item parameter? Well, because nil is a valid argument, and I wouldn't be able to distinguish between someone passing no argument and someone passing nil as an argument.

For comparison, here is how count is implemented in Rubinius:

def count(item = undefined)
  seq = 0
  if !undefined.equal?(item)
    each do
      element = Rubinius.single_block_arg
      seq += 1 if item == element
    end
  elsif block_given?
    each { |element| seq += 1 if yield(element) }
  else
    each { seq += 1 }
  end
  seq
end

Where I (ab)use the fact that the default argument for an optional parameter is an arbitrary Ruby expression with side-effects such as setting variables, Rubinius uses a special undefined object that is provided by the Rubinius runtime and is equal? only to itself.

Karonkaross answered 8/2, 2017 at 18:57 Comment(1)
Thank you, I find your last example easiest to understand. I'll have to get back to the others after some time again, but they seem helpful, too. :)Gramophone
G
0

Thank you for your help! Just before I came to check if there are any answers I came up with the following solution. It can be definitely improved, and I'll try to shorten it a bit, but I prefer to first post it here as I came up with it, it might be helpful for other newbies like me. In the code below I'm using a #my_each method that I that works the same as the normal #each.

def my_count(arg=nil)
    sum = 0
    if block_given? && arg == nil
        self.my_each do |elem|
            if yield(elem)
                sum += 1
            end
        end
    elsif !block_given? && arg != nil
        self.my_each do |elem|
            if arg == elem
                sum += 1
            end
        end
    else
        self.my_each do |elem|
            sum += 1
        end
    end
    sum
end

I also found these two links helpful: A method with an optional parameter and http://augustl.com/blog/2008/procs_blocks_and_anonymous_functions/ (which reminded me that a method can yield a block even if it's not defined as an argument such as &block). I saw Jorg has commented in the first link's discussion, too.

Gramophone answered 9/2, 2017 at 12:30 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.