Since Ruby 2.0, methods can be defined with keyword arguments instead of the
traditional ordinal arguments. I really like them. But theyāre not obvious. I
find myself thinking, maybe too often, āwait, how the hell do these work?ā
This post is a stream of consciousness exploration through the things about
keyword arguments that confuse or confused me or people I know. Wading through
this post might help you emerge with a firmer understanding of how to use them.
I hope it makes sense if you read it. Please let me know. I want to write about
a thing I find confusing in a way that honors that confusion but is also clear
and readable, which maybe isnāt a thing I can do.
Letās start with an example of keyword arguments working as-advertised:
def email(from:, to:, subject:, body:)
"From: #{from}\nTo: #{to}\nSubject: #{subject}\n\n#{body}"
end
Thatās a kind of strange method that takes a few required keyword arguments and
makes a string from them. I like using keyword arguments here because now you
can call the method however you like, as long as you provide all of the
keywords; the order doesnāt matter, and itās clear which one is which.
So, for example, hereās a perfectly valid way to call that method (note the
order has changed):
email(
subject: "Thanks!",
from: "Max",
to: "Susan",
body: "The soup was great!"
)
Weāre able to use required keyword arguments in Ruby 2.1 and forward. What if
your app is using Ruby 2.0.0? Youād still like to reap the clarity benefits of
keyword arguments, but now you must provide a default value for every keyword.
What default makes sense for an email? Iām not sure. I guess you can do this?
def email(from: nil, to: nil, subject: nil, body: nil)
raise ArgumentError if [from, to, subject, body].any?(&:nil?)
"From: #{from}\nTo: #{to}\nSubject: #{subject}\n\n#{body}"
end
Which kind of simulates the behavior of Ruby 2.1 required keyword arguments.
But itās not great, because sometimes nil is actually a value that you want to
be providing. So maybe you do something heavier, like this?
class AbsentArgument
end
def email(from: AbsentArgument.new, to: AbsentArgument.new, subject: AbsentArgument.new, body: AbsentArgument.new)
raise ArgumentError if [from, to, subject, body].any? { |arg| arg.is_a?(AbsentArgument) }
"From: #{from}\nTo: #{to}\nSubject: #{subject}\n\n#{body}"
end
Which is kind of clunky-looking but maybe more explicit?
Letās be happy required keyword arguments are an official thing now and not
worry about that and just hope we can all always use Ruby 2.1 or newer.
Keyword arguments kind of look like hashes. Are they hashes? I donāt know. You
can use hashes with them:
arguments = {
from: "Max",
to: "Beth",
subject: "Thanks!",
body: "Your soup was great!"
}
email(**arguments)
That works. That **
coerces the hash into keyword arguments, kind of like the
*
coerces an array into ordinal arguments:
def sum(a_number, another_number)
a_number + another_number
end
nums = [1, 1]
sum(*nums)
Except, the **
isnāt actually necessary, this works fine too:
arguments = {
from: "Max",
to: "Beth",
subject: "Thanks!",
body: "Your soup was great!"
}
email(arguments)
So I guess they donāt do anything there?
OK so when you are calling a method you can use a pre-existing hash for the
keyword arguments. What about when youāre defining a method? This probably
wonāt work but I just donāt know because it doesnāt feel obvious. Letās try.
Hereās our new example method, which works fine:
def stir_fry(ingredients: [], heat: 100)
heat.times do
ingredients = ingredients.shuffle
end
ingredients
end
stir_fry(ingredients: ['broccoli', 'peppers', 'tofu'], heat: 45)
So letās try to define the method again, but this time letās use a hash.
arguments = {
ingredients: [],
heat: 100
}
def stir_fry(arguments)
heat.times do
ingredients = ingredients.shuffle
end
ingredients
end
stir_fry(ingredients: ['broccoli', 'peppers', 'tofu'], heat: 45)
Do you think it works? It doesnāt work at all. Iām sorry.
Wait, so what even is the **
thing? Letās review *
again; I showed above
how to use it to coerce an array into ordinal arguments when calling a method,
but it can also be used in a method definition to indicate that a method takes
an arbitrary number of arguments:
def sum(*numbers)
total = 0
numbers.each { |num| total += num }
total
end
sum(1, 2, 3)
sum(*[1, 2, 3])
We can do something like that with **
to indicate that we want to catch all
unrecognized keyword arguments into an object:
def stir_fry(ingredients: [], heat: 100, **options)
heat.times do
ingredients = ingredients.shuffle
end
if (sauce = options[:sauce])
ingredients.push(sauce)
end
ingredients
end
stir_fry(ingredients: ['broccoli', 'peppers', 'tofu'], sauce: "teriyaki", heat: 45)
In that example, sauce is an optional keyword that isnāt defined in the method
definition. Normally if we provide sauce, and sauce wasnāt specifically
expected, that will cause an error, so this is kind of a cool way to say: āI
donāt care what keyword values you throw at me! Iāll just make a hash out of
the ones I donāt recognizeā. It doesnāt even care that sauce came in the middle
of the expected keyword arguments. This is pretty similar to the convention in
ordinal method definitions where the last argument is called options and it has
a default value of an empty hash, but when you do that, the order really
matters:
def ordinal_stir_fry(ingredients, heat, options = {})
heat.times do
ingredients = ingredients.shuffle
end
if (sauce = options[:sauce])
ingredients.push(sauce)
end
ingredients
end
ordinal_stir_fry(["potato"], 5, sauce: 'Catsup') # This one works
ordinal_stir_fry(["shoe"], {sauce: 'Water'}, 5) # This one doesn't
What is even happening there? The curly braces become necessary to avoid a
syntax error, and then the method receives the wrong values in the wrong names.
But, looking at it, I think itās clear that something is a little bit off,
because the second parameter looks different from the third; it kind of feels
like the hash belongs at the end, because thatās such a strong convention for
ordinally-defined Ruby methods.
The **options
example is neat but again, itās not obvious. When looking at
it, you donāt know which of the keyword arguments are specifically expected and
which ones will land in the greedy **options
bucket. You have to reference
the method definition, just like with stinky ordinal methods.
Letās look at default values in some more detail. It seems clear; you can
provide a default value, which will be used when the method is called without
that keyword value provided. What happens when you provide nil?
class Fish
attr_reader :breed, :color
def initialize(breed: "Koi", color: "Yellow")
@breed = breed
@color = color
end
end
fish = Fish.new(color: nil)
fish.breed #=> "Koi"
fish.color #=> ????????
What do you feel like it should be? I guess it should be nil, because thatās
the value you provided for that keyword, and yes thatās right, itās nil. That
works for me, but I know itās not obvious because I found myself trying to
defend this behavior to a friend recently, who was sad that it didnāt behave as
his intuition desired, that a nil value would be replaced by the default value.
To console him I attempted to write some code that would satisfy his
expectations, and I came up with this:
class AbsentArgument
end
class Fish
attr_reader :breed, :color
def initialize(breed: AbsentArgument.new, color: AbsentArgument.new)
@breed = validate(breed, default: "Koi")
@color = validate(color, default: "Yellow")
end
private
def validate(value, default:)
if value.is_a?(AbsentArgument) || value.nil?
default
else
value
end
end
end
fish = Fish.new(color: nil)
fish.breed #=> "Koi"
fish.color #=> "Yellow"
So if you want that behavior you basically canāt use Ruby keyword argument
default values, because default values donāt work that way.
Oh, hereās another thing. I thing I only realized this this month, that I had
been doing this:
class Disease
attr_reader :name
def initialize(name: name)
@name = name
end
end
gout = Disease.new(name: "Gout")
gout.name #=> "Gout"
rando = Disease.new
gout.name #=> nil
This was fulfilling the behavior I guess I wanted: when I provide a name, it
should use that name; when I donāt provide a name, the name should be nil. It
was working without error, and Iām not sure where I picked up the pattern of
writing keyword arguments this way, but it actually totally makes no sense! If
I wanted the default value to be nil, why not just write it like this?
class Disease
attr_reader :name
def initialize(name: nil)
@name = name
end
end
What was even happening in that earlier example? Well, when I wasnāt providing
a name value, it was calling the name
method which was only available because
I had added the attr_reader
for name, and that method was returning nil, so
nil was being assigned to the @name
instance variable. I had no idea thatās
what was happening, I just thought that I was writing the boilerplate necessary
to achieve that behavior. That feels kind of dangerous; maybe you donāt realize
that your default values can call methods, and youāre calling a method that
doesnāt exist? For example:
class Furniture
attr_reader :color
def initialize(kind: kind, color: color)
@kind = kind
@color = color
end
def description
"#{@color} #{@kind}"
end
end
couch = Furniture.new(kind: "Couch", color: "Grey")
couch.description #=> "Grey Couch"
You could have tests for this code and ship it to production and never realize
that a bug hides within it. As long as you always provide a kind
keyword
value, youāll never face it and it will work properly, because it will never
attempt to call the kind
method⦠which doesnāt exist.
So, to make it blow up, simply omit the kind keyword value:
Furniture.new(color: "Red")
# undefined local variable or method `kind' for #<Furniture:0x00000101110a38> (NameError)
Sinister!
Happily, Iām noticing now that Ruby 2.2.1 actually warns you when you write
code like this. 2.0.0 does not, which is where Iāve been happily making
this mistake for many months.
The warning:
def candy(flavor: flavor)
end
# warning: circular argument reference - flavor
What about when you combine ordinal arguments with keyword arguments? You can.
Is it obvious how that should work? Not to me. Letās take a look.
def stir_fry(servings = 1, zest, ingredients: [], **options)
dish = (ingredients * servings)
zest.times do
dish = dish.shuffle
end
dish
end
stir_fry(8, ingredients: ["pepper", "seitan"], sauce: "fancy")
What the hell is happening there? Maybe itās clear to you. itās not to me. The
first two arguments are ordinal, and the first one has a default value. So Ruby
compares the arguments we provide when we call the method to the arguments in
the method definition, and sees that we provided what looks like one ordinal
value, and a few keyword values, so the one ordinal value must be zest
,
because servings
has a default value and zest
does not (Ruby here is
smarter than I realized).
It kind of feels like Ruby is going to let us make this method definition more
confusing, for example by moving the keyword arguments before the ordinal
arguments, but it actually wonāt let you. It will raise a syntax error:
# syntax error:
def shenanigans(amp: 11, guitar)
end
# if it were a valid method definition, I guess you would call it like this:
shenanigans(amp: 5, "Fender")
# or, omitting the non-required parameter
shenanigans("Martin")
For me it wasnāt obvious that this wouldnāt be allowed, but Iām glad itās not,
because reading those method calls feels kind of backwards.
Similarly:
# not a syntax error:
def shenanigans(amp, *outfits, **options)
end
shenanigans("Orange", "Leotard", "Leather Jacket", at: Time.now)
# but this is a syntax error:
def shenanigans(amp, style: "flamenco", *outfits, **options)
end
That one also fails because it tries to put some ordinal arguments (the
*outfits
bit) after some keyword arguments.
Well, I think thatās everything I can think of. Good luck out there.