35

I'm playing with Ruby on Rails and I'm trying to create a method with optional parameters. Apparently there are many ways to do it. I trying naming the optional parameters as hashes, and without defining them. The output is different. Take a look:

# This functions works fine!
def my_info(name, options = {})
    age = options[:age] || 27
    weight = options[:weight] || 160
    city = options[:city] || "New York"
    puts "My name is #{name}, my age is #{age}, my weight is #{weight} and I live in {city}"
end


my_info "Bill"
-> My name is Bill, my age is 27, my weight is 160 and I live in New York
-> OK!

my_info "Bill", age: 28
-> My name is Bill, my age is 28, my weight is 160 and I live in New York
-> OK!

my_info "Bill", weight: 200
-> My name is Bill, my age is 27, my weight is 200 and I live in New York
-> OK!

my_info "Bill", city: "Scottsdale"
-> My name is Bill, my age is 27, my weight is 160 and I live in Scottsdale
-> OK!

my_info "Bill", age: 99, weight: 300, city: "Sao Paulo"
-> My name is Bill, my age is 99, my weight is 300 and I live in Sao Paulo
-> OK!

****************************

# This functions doesn't work when I don't pass all the parameters
def my_info2(name, options = {age: 27, weight: 160, city: "New York"})
    age = options[:age]
    weight = options[:weight]
    city = options[:city]
    puts "My name is #{name}, my age is #{age}, my weight is #{weight} and I live in #{city}"
end

my_info2 "Bill"
-> My name is Bill, my age is 27, my weight is 160 and I live in New York
-> OK!

my_info2 "Bill", age: 28
-> My name is Bill, my age is 28, my weight is  and I live in 
-> NOT OK! Where is my weight and the city??

my_info2 "Bill", weight: 200
-> My name is Bill, my age is , my weight is 200 and I live in 
-> NOT OK! Where is my age and the city??

my_info2 "Bill", city: "Scottsdale"
-> My name is Bill, my age is , my weight is  and I live in Scottsdale
-> NOT OK! Where is my age and my weight?

my_info2 "Bill", age: 99, weight: 300, city: "Sao Paulo"
-> My name is Bill, my age is 99, my weight is 300 and I live in Sao Paulo
-> OK!

What's wrong with the second approach for optional parameters? The second method only works if I don't pass any optional parameter or if I pass them all.

What am I missing?

Marlin Pierce
  • 9,931
  • 4
  • 30
  • 52
Guillermo Guerini
  • 997
  • 2
  • 12
  • 22

11 Answers11

59

The way optional arguments work in ruby is that you specify an equal sign, and if no argument is passed then what you specified is used. So, if no second argument is passed in the second example, then

{age: 27, weight: 160, city: "New York"}

is used. If you do use the hash syntax after the first argument, then that exact hash is passed.

The best you can do is

def my_info2(name, options = {})
  options = {age: 27, weight: 160, city: "New York"}.merge(options)
...
Marlin Pierce
  • 9,931
  • 4
  • 30
  • 52
  • This is the solution for my problem. After reading @texasbruce's explanation about overriding the original hash, it makes sense to merge the "default" hash with the passes hash. Thanks! – Guillermo Guerini Apr 19 '12 at 18:42
  • 3
    since `options` is going to be re-assigned you might as well update it in-place: `options.reverse_update(age: 27, weight: 160, city: "New York")` – tokland Apr 19 '12 at 18:51
17

The problem is the default value of options is the entire Hash in the second version you posted. So, the default value, the entire Hash, gets overridden. That's why passing nothing works, because this activates the default value which is the Hash and entering all of them also works, because it is overwriting the default value with a Hash of identical keys.

I highly suggest using an Array to capture all additional objects that are at the end of your method call.

def my_info(name, *args)
  options = args.extract_options!
  age = options[:age] || 27
end

I learned this trick from reading through the source for Rails. However, note that this only works if you include ActiveSupport. Or, if you don't want the overhead of the entire ActiveSupport gem, just use the two methods added to Hash and Array that make this possible.

rails / activesupport / lib / active_support / core_ext / array / extract_options.rb

So when you call your method, call it much like you would any other Rails helper method with additional options.

my_info "Ned Stark", "Winter is coming", :city => "Winterfell"
Charles Caldwell
  • 16,649
  • 4
  • 40
  • 47
  • 1
    This is an awesome pattern, thank you. When you combine this with chained scopes that fail gracefully when nil via ```scoped``` you can do some powerful queries. – Kelsey Hannan Apr 02 '15 at 11:03
9

If you want to default the values in your options hash, you want to merge the defaults in your function. If you put the defaults in the default parameter itself, it'll be over-written:

def my_info(name, options = {})
  options.reverse_merge!(age: 27, weight: 160, city: "New York")

  ...
end
Winfield
  • 18,985
  • 3
  • 52
  • 65
5

In second approach, when you say,

my_info2 "Bill", age: 28

It will pass {age: 28}, and entire original default hash {age: 27, weight: 160, city: "New York"} will be overridden. That's why it does not show properly.

SwiftMango
  • 15,092
  • 13
  • 71
  • 136
4

You can also define method signatures with keyword arguments (New since, Ruby 2.0, since this question is old):

def my_info2(name, age: 27, weight: 160, city: "New York", **rest_of_options)
    p [name, age, weight, city, rest_of_options]
end

my_info2('Joe Lightweight', weight: 120, age: 24, favorite_food: 'crackers')

This allows for the following:

  • Optional parameters (:weight and :age)
  • Default values
  • Arbitrary order of parameters
  • Extra values collected in a hash using double splat (:favorite_food collected in rest_of_options)
Henry Tseng
  • 3,263
  • 1
  • 19
  • 20
3

For the default values in your hash you should use this

def some_method(required_1, required_2, options={})
  defaults = {
    :option_1 => "option 1 default",
    :option_2 => "option 2 default",
    :option_3 => "option 3 default",
    :option_4 => "option 4 default"
  }
  options = defaults.merge(options)

  # Do something awesome!
end
Christian
  • 1,258
  • 10
  • 11
  • 2
    If you use merge() instead of merge!() the way you show here, it produces and discards a new hash instead of modifying the existing options hash. – Winfield Apr 19 '12 at 18:30
3

To answer the question of "why?": the way you're calling your function,

my_info "Bill", age: 99, weight: 300, city: "Sao Paulo"

is actually doing

my_info "Bill", {:age => 99, :weight => 300, :city => "Sao Paulo"}

Notice you are passing two parameters, "Bill" and a hash object, which will cause the default hash value you've provided in my_info2 to be completely ignored.

You should use the default value approach that the other answerers have mentioned.

Abe Voelker
  • 30,124
  • 14
  • 81
  • 98
3

#fetch is your friend!

class Example
  attr_reader :age
  def my_info(name, options = {})
    @age = options.fetch(:age, 27)
    self
  end
end

person = Example.new.my_info("Fred")
puts person.age #27
Jesse Wolgamott
  • 40,197
  • 4
  • 83
  • 109
1

I don't see anything wrong with using an or operator to set defaults. Here's a real life example (note, uses rails' image_tag method):

file:

def gravatar_for(user, options = {} )   
    height = options[:height] || 90
    width = options[:width] || 90
    alt = options[:alt] || user.name + "'s gravatar"

    gravatar_address = 'http://1.gravatar.com/avatar/'

    clean_email = user.email.strip.downcase 
    hash = Digest::MD5.hexdigest(clean_email)

    image_tag gravatar_address + hash, height: height, width: width, alt: alt 
end

console:

2.0.0-p247 :049 > gravatar_for(user)
 => "<img alt=\"jim&#39;s gravatar\" height=\"90\" src=\"http://1.gravatar.com/avatar/<hash>\" width=\"90\" />" 
2.0.0-p247 :049 > gravatar_for(user, height: 123456, width: 654321)
 => "<img alt=\"jim&#39;s gravatar\" height=\"123456\" src=\"http://1.gravatar.com/avatar/<hash>\" width=\"654321\" />" 
2.0.0-p247 :049 > gravatar_for(user, height: 123456, width: 654321, alt: %[dogs, cats, mice])
 => "<img alt=\"dogs cats mice\" height=\"123456\" src=\"http://1.gravatar.com/avatar/<hash>\" width=\"654321\" />" 

It feels similar to using the initialize method when calling a class.

Starkers
  • 10,273
  • 21
  • 95
  • 158
1

Why not just use nil?

def method(required_arg, option1 = nil, option2 = nil)
  ...
end
Crashalot
  • 33,605
  • 61
  • 269
  • 439
LLL
  • 1,085
  • 8
  • 16
  • a hash makes a better default if you plan to access it as a hash. option1[:undefined_key] returns nil if it's an empty hash, but raises an exception if nil. – jay Feb 19 '15 at 06:31
0

There is a method_missing method on ActiveRecord models that you can override to have your class dynamically respond to calls directly. Here's a nice blog post on it.

RonanOD
  • 876
  • 1
  • 9
  • 19