Friday, May 6, 2011

How do I write a Ruby method to handle zero, one, or many inputs?

I've got a Ruby method like the following:

# Retrieve all fruits from basket that are of the specified kind.
def fruits_of_kind(kind)
  basket.select { |f| f.fruit_type == kind.to_s }
end

Right now, you can call this like:

fruits_of_kind(:apple)    # => all apples in basket
fruits_of_kind('banana')  # => all bananas in basket

and so on.

How do I change the method so that it will correctly handle iterable inputs as well as no inputs and nil inputs? For example, I'd like to be able to support:

fruits_of_kind(nil) # => nil
fruits_of_kind(:apple, :banana)    # => all apples and bananas in basket
fruits_of_kind([:apple, 'banana']) # => likewise

Is this possible to do idiomatically? If so, what's the best way to write methods so that they can accept zero, one, or many inputs?

From stackoverflow
  • Use the VARARGS feature of Ruby.

    # Retrieve all fruits from basket that are of the specified kind.
    # notice the * prefix used for method parameter
    def fruits_of_kind(*kind)
      kind.each do |x|
        puts x
      end
    end
    
    fruits_of_kind(:apple, :orange)
    fruits_of_kind()
    fruits_of_kind(nil)
    

    -sasuke

    Kyle Kaitan : Will this handle the case of fruits_of_kind([:apple, 'banana'])?
  • def fruits_of_kind(kind)
        return nil if kind.nil?
    
        result = []
    
        ([] << kind).flatten.each{|k| result << basket.select{|f| f.fruit_type == k.to_s }}
    
        result
    end
    

    The 'splat' operator is probably the best way to go, but there are two things to watch out for: passing in nil or lists. To modify Pesto's solution for the input/output you'd like, you should do something like this:

    def fruits_of_kind(*kinds)
      return nil if kinds.compact.empty? 
    
      basket.select do |fruit|
        kinds.flatten.each do |kind|
          break true if fruit.fruit_type == kind.to_s
        end == true #if no match is found, each returns the whole array, so == true returns false
      end
    end
    

    If you pass in nil, the * converts it to [nil]. If you want to return nil instead of an empty list, you have to compact it (remove nulls) to [], then return nil if it's empty.

    If you pass in a list, like [:apple, 'banana'], the * converts it to [[:apple, 'banana']]. It's a subtle difference, but it's a one-element list containing another list, so you need to flatten kinds before doing the "each" loop. Flattening will convert it to [:apple, 'banana'], like you expect, and give you the results you're looking for.

    EDIT: Even better, thanks to Greg Campbell:

       def fruits_of_kind(basket, kind)
           return nil if kind.nil?
    
           kind_list = ([] << kind).flatten.map{|kind| kind.to_s}
    
           basket.select{|fruit| kind_list.include?(fruit) } 
       end
    

    OR (using splat)

       def fruits_of_kind(*kinds)
           return nil if kinds.compact.empty?
    
           kind_list = kinds.flatten.map{|kind| kind.to_s}
    
           basket.select{|fruit| kind_list.include?(fruit.fruit_type) } 
       end
    
    Kyle Kaitan : I think that's almost what I want, except that I need "result +=", not "result <<".
    Kyle Kaitan : This handles all of the cases I described above correctly and it seems the most idiomatic.
    Chris Doggett : The "<<" just appends the items to the array. It's the same as "+=". I'd honestly either use the second one I wrote (modified from Pesto's), and possibly change your output expectations to expect an empty list ([]) instead of nil. Still need to flatten it, though.
    Greg Campbell : You could make this more efficient (and readable, imo) by doing the following: kind_list = kinds.flatten.map {|kind| kind.to_s}; basket.select {|fruit| kind_list.include?(fruit.fruit_type)} (apologies for the formatting, we really need to be able to format code in comments)
  • You need to use the Ruby splat operator, which wraps all remaining arguments into an Array and passes them in:

    def foo (a, b, *c)
      #do stuff
    end
    
    foo(1, 2) # a = 1, b = 2, c = []
    foo(1, 2, 3, 4, 5) #a = 1, b = 2, c = [3, 4, 5]
    

    In your case, something like this should work:

    def fruits_of_kind(*kinds)
      kinds.flatten!
      basket.select do |fruit|
        kinds.each do |kind|
          break true if fruit.fruit_type == kind.to_s
        end == true #if no match is found, each returns the whole array, so == true returns false
      end
    end
    


    EDIT

    I changed the code to flatten kinds so that you can send in a list. This code will handle entering no kinds at all, but if you want to expressly input nil, add the line kinds = [] if kinds.nil? at the beginning.

    Chris Doggett : Doesn't look like it handles the case where he passes in a list, so you may need to flatten kinds. Otherwise, from your first example, foo(1,2,[3,4,5]) will have c == [[3,4,5]], and the each won't work.
    Chris Doggett : Doesn't handle nil, either.

0 comments:

Post a Comment