Noel Rappin Writes Here

Better Know A Ruby Thing #4: Keyword Arguments

Posted on February 22, 2024


Last time on Better Know A Ruby Thing, we covered positional arguments, and now we’re going to move on to keyword arguments. I really did think this was going to be shorter than the last one, and then I got to the conversion between keyword and positional arguments, and then… well, it’s not shorter.

(I know I said the next newsletter was going to be Conway’s Law, that’s coming, but this one moved along faster…)


A brief commercial announcement:

If you like this and want to see more of it in your mailbox, you can sign up at http://buttondown.email/noelrap. If you really like this and would like to support it financially, thanks, and you can sign up at:

Subscription fees go toward covering Buttondown’s costs, once we pass that, they’ll go toward delivering audio and video extras for subscribers.

Also, you can buy Programming Ruby 3.3 in ebook from Pragmatic or in print from Amazon.

Thanks! We now return to our post, already in progress…


How Keyword Arguments Work

Ruby is unusual in that it allows you to specify method arguments as being callable either by position or by keyword but not both. In Python, for example, any argument can be called by position or keyword, the caller can chose either way. In Ruby, the method definition determines whether an argument is positional or keyword and the caller has to match. With one exception that we’ll get to in a bit.

In a method definition, you declare keyword arguments by putting a colon after the argument name. Inside the method, the argument is usable as a local variable.

def basic_method(name:, address:, country:)
  p "#{name}, #{address}, #{country}"
end

Keyword arguments allow information about the method to be visible when you call the method. When calling the method, you use the keyword: value syntax in the method call:

> basic_method(name: "Clark", address: "Smallvile", country: "USA")
=> "Clark, Smallvile, USA"

> basic_method(country: "USA", name: "Lois", address: "Metropolis")
=> "Lois, Metropolis, USA"

As you can see from those calls, the order of the keyword arguments doesn’t matter. In practice, I think it’s unusual to see the arguments actually be called in a different order. Often that’s because, like this method, the arguments are in a logical order, often it’s just because programmers like being consistent.

Anyway, as written above, all the keyword arguments are required, and failing to include all of them in a method call results in an ArgumentError:

> basic_method(name: "Bruce", address: "Gotham")
(irb):1:in `basic_method': missing keyword: :country (ArgumentError)

You can make a keyword argument optional by including a value after the colon in the method definition.

def basic_method(name:, address:, country: "USA")
  p "#{name}, #{address}, #{country}"
end
> basic_method(name: "Bruce", address: "Gotham")
=> "Bruce, Gotham, USA"

Since the order of keyword arguments doesn’t matter, the meaning of keyword arguments when some have defaults and some don’t is much more straightforward than for positional arguments – if the keyword argument is present in the call, then that value is used, if not, then the default is used. If there’s no default then it’s an ArgumentError.

In Ruby 3.2 and up, the value part can be left off the call if the value is a local variable or no-argument method in the current binding

name = "Doreen"
address = "NYC"
basic_method(name:, address:)

In this case, Ruby sees the keyword argument name without a value, and looks for name in the current binding, finding the value ”Doreen” which it then uses as the value for the call. If there’s no available local variable, Ruby looks for a method of the same name which takes no arguments.

You can combine positional arguments and keyword arguments in the same method. In the method definition, the positional arguments have to be listed first, with the keyword arguments second:

def this_is_okay(a, b, c, d:, e:, f:)

def this_is_not(a, d:, b, e:, c, f:)

The second version would, I think, be unambiguous in practice, so I don’t think there’s a reason for this beyond making the parser easier. I also don’t think there’s anybody that actually wants this to be legal, so nobody’s pushing for it.

I find I don’t mix positional and keyword arguments a lot in practice – either I have methods with only one or two arguments that I do positionally or I have methods that have a lot of arguments and I use keyword. I guess the use case for mixing is where you have one or two required arguments and then a variety of optional arguments, so like convert_object(object, style: :verbose, localization: :us, round: :up) or something like that.

You can actually take advantage of the way Ruby evaluates default values left to right to create a method that will take ether keyword or positional arguments:

def dual_interface(_name = nil, _state = nil, name: _name, state: _state)
  p "#{name}: #{state}"
end

> dual_interface("Noel", "IL")
=> "Noel: IL"
> dual_interface(name: "Noel", state: "IL")
=> "Noel: IL"

The dual_interface method uses throwaway names for the positional arguments, then those throwaway names become the default value for the keyword argument, allowing the method to be called either way. (If you use both, the keyword value will win over the positional value). I can see a case where this might be useful? Or confusing? Use-fusing?

Keyword Arguments and Hashes

In the same way that positional arguments can be converted back and forth with Array objects, keyword arguments can be converted back and forth with Hash objects.

On the method definition side, you can specify an arbitrary amount of key/value arguments with a double splat, or **, prefixing the argument name:

def a_double_splat(name:, **options)
  p options
end

a_double_splat(name: "Peter", height: 72, eyes: "Blue")
> => {:height=>72, :eyes=>"Blue"}

Again, because the order doesn’t matter, this logically a little cleaner than positional arguments… anything that isn’t explicitly listed in the argument list goes into the hash. If you use normal keyword syntax then the resulting hash has symbol keys, but you can also use arbitrary key/value pairs – in this example we wind up with a String key..

a_double_splat(name: "Fred", "height" => 72)
=> {"height"=>72}

On the calling side, any hash can be unrolled into component keyword arguments by using **.

def basic_method(name:, address:, country: "USA")
  p "#{name}, #{address}, #{country}"
end

ww = {name: "Diana", address: "Themyscira"}
basic_method(**ww)

The double splatted part does not have to be all the keyword arguments, it is completely valid to do this:

chicago = {address: "Chicago", country: "USA"}
basic_method(name: "Noel", **chicago)

There are a couple of oddities around double splats.

First off, the value to the right of the ** in the method call can be something other than a Hash. The object must respond to the method to_hash, Ruby will call to_hash and use the resulting hash as the ** target:

class User
  def initialize(first_name, last_name, address)
    @first_name = first_name
    @last_name = last_name
    @address = address
  end

  def to_hash
    {name: "#{@first_name} #{@last_name}",
     address: @address, country: "USA"}
  end
end

> user = User.new("Steve", "Rogers", "Brooklyn")
> > basic_method(**user)
=> "Steve Rogers, Brooklyn, USA"

In this case, even though user isn’t a Hash, the to_hash method converts it to one for the purposes of the method call.

A quick search of the Ruby standard library shows that to_hash is implemented by ENV, JSON::Ext::Generator::State, JSON::GenericObject, Net:HTTPHeader, and YAML::DBM – in other words, not exactly things you’d encounter on a regular basis. (For example, not Struct, and not Data). Struct and Data implement to_h, which is similar, but is not to_hash – the latter is what is called by the Ruby interpreter for converting **. (Which reminds me that conversion methods would be a pretty good Better Know topic).

I probably should have mentioned this in the Better Know for positional arguments, but you can do the same thing with single splat * and to_a:

class User
  def to_a
    [@first_name, @last_name, @address]
  end
end

def array_method(first, last, address)
  p "#{first} #{last} #{address}"
end
> array_method(*user)
=> "Steve Rogers Brooklyn"

As with the single splat, you can use an anonymous ** to forward arguments:

def anon_splat(**)
  another_method(**)
end

You can’t use the ** as the right side of an assignment, and unlike the anonymous single splat, I don’t think there’s any kind of fancy deference assignment syntax that let’s you sneakily work around it.

When Is A Keyword Argument Not A Keyword Argument

A weird thing about Ruby is that the behavior of key/value pairs in a method call changes based on whether the method defines keyword arguments or not.

We have seen that a double splat in the method definition causes that argument to capture any key/value pairs that are otherwise uncultured:

def a_double_splat(name:, **options)
  p options
end

a_double_splat(name: "Peter", height: 72, eyes: "Blue")
> => {:height=>72, :eyes=>"Blue"}

What’s weird is that if the method does not define any keyword arguments and does not include a double splat, then any key/value arguments passed at the end of the list of positional arguments will be rolled into a hash and passed as the last positional argument:

def implicit_double_splat(name, options = {})
  p options
end

implicit_double_splat("Peter", height: 72, eyes: "Blue")
=> {:height=>72, :eyes=>"Blue"}

You can consider this to be a shortcut for actually declaring the last argument as a Hash literal. In other words, these two are the same:

> implicit_double_splat("Peter", height: 72, eyes: "Blue")

> implicit_double_splat("Peter", {height: 72, eyes: "Blue"})

This conversion happens even if the last positional argument is a single splat:

def implicit_splat(*options)
  p options
end

implicit_splat("Peter", height: 72, eyes: "Blue")
=> ["Peter", {:height=>72, :eyes=>"Blue"}]

Notice how the resulting single splat array has two elements, the positional argument and the hash containing the key/value pairs, again as though they were passed as a single Hash.

If you include a double splat, the key/value pairs go there instead:

def both_splats(*args, **options)
  p options
end

both_splats("Peter", height: 72, eyes: "Blue")
=> {:height=>72, :eyes=>"Blue"}

If you don’t want this behavior for some reason, you can define the method with the special argument **nil, which will throw an error if any keyword arguments are passed to the method.

def no_splats_allowed(*args, **nil)
  p options
end

no_splats_allowed("Peter", height: 72, eyes: "Blue")
no keywords accepted (ArgumentError)

This behavior raises the immediate question “why the heck is this even a thing at all”.

Why the Heck, Indeed?

I don’t really know this for sure, but I suspect the short answer to why this behavior still exists is:

a) It’s left over from before Ruby had keyword arguments

b) It was considered more desirable to leave some of the old behavior in place rather than break a staggering amount of code.

Before Ruby 2.0, Ruby didn’t have keyword arguments. Until that point Ruby only had the fake keyword argument behavior that we just saw. You would pass arbitrary key value pairs at the end of an argument list, and Ruby would roll them up into a single hash object that you needed to declare as positional argument.

If you squinted, this looked like keyword arguments, and using this for arbitrary options was a very common case – the Rails codebase did/does this all over:

def render(template, options = {})
  ## stuff
end

And you’d then call it with something like:

render("view.erb", :verbose => true, :pretty_print => true)

In the method options would be the hash {:verbose => true, :pretty_print => true} – again, just skipping the need to wrap the last argument in curly braces in the method call.

Sometimes Ruby devs would try to claim this was “just as good” as keyword arguments, which it wasn’t – it was much harder to specify a required list of arguments, for example. The implicit conversion from key/value pairs was confusing, and in my experience was not particularly well documented.

All the keyword and double-splat syntax was put in place in Ruby 2.0 and Ruby 2.1, but Ruby 2.x was much more flexible in blurring the relationship between keyword and positional arguments.

I normally find it confusing to talk about multiple versions of Ruby at once, but in this case, I think it’s interesting exactly what parts of this keyword/positional combination were considered more complicated than useful and thus discarded.

  • In Ruby 2.x, if a literal hash was passed as the last argument of a method, call(x, {a: 1, b:2}) it would implicitly behave as though it was preceded with a double-splat; that is, it would unroll and match keyword arguments def call(x, a:, b:). In Ruby 3.0, you must explicitly double-splat for this to happen, call(x, **{a: 1, b:2}).

  • In Ruby 2.x, if the last arguments in a method call were key/value pairs, and the method call was missing a positional argument, the key/value pairs would be converted to a hash and passed as the missing positional argument. In other words, def call(arg1, arg2) and then call("x", a: 1, b: 2) and would result in arg2 being set to {a: 1, b: 2}, because arg2 was a missing positional argument in the call. This would happen even if there was a matching keyword argument, as in def call(arg1, arg2, a: nil, b: nil), which would have the same result, with a and b both remaining nil. In Ruby 3.x, you must explicitly wrap the key/value pairs as hash literal to get them to be used as a missing positional argument, call("x", {a: 1, b: 2}). This seems all to the good to me, the Ruby 2.x behavior was confusing.

  • This case is so weird I’m hard-pressed to see how it happened in actual code. If the method definition took positional and keyword arguments, def weird(a, b:, c:) and a hash was passed that had both symbol and non-symbol keys weird({"b" => 1, b: 2, c: 3})… then the hash would be split and the symbol keys would be matched to keywords, and the non symbol keys would be passed to the last positional argument, so a would be set to {"b" => 1}. This behavior is baffling, honestly. Ruby 3.x does not split the hash in this case.

All told, the conversion behavior was confusing. The cases were a little unusual, but caused some hard to see problems, and worse, problems that were hard to explain. It also wasn’t great for the parser. There were a bunch of ambiguous cases, especially where a method had both optional positional arguments and keyword arguments. The official announcement has all the details, in case you thought this was insufficiently long.

In practice the biggest issue between 2.7 and 3.0 – I say after updating a metric oodle of sites – was that passthrough methods like this one

def outer(*args, &block)
  inner(*args, &block)
end

Suddenly would have their behavior change because the keyword arguments would no longer get passed to the *args splat, so they all needed to be rewritten as:

def outer(*args, **kwargs, &block)
  inner(*args, **kwargs, &block)
end

Or, using the forward syntax introduced at the same time:

def outer(...)
  inner(...)
end

Anyway, the case that was allowed to continue in Ruby 3.0 – where a method doesn’t define keyword arguments and keyword arguments in the call are rolled up into the last positional arguments – that case isn’t ambiguous, and I assume without reading through the entire debate that the feeling was that taking that behavior away would break large amounts of existing code to no real purpose.

Keyword arguments and blocks

As far as I can tell, and at this point, thankfully, keyword arguments in blocks work exactly the same way as keyword arguments in methods, with the exception that you can’t use an anonymous double-splat ** as a pass-through.

I’m not 100% sure about this, it’s a clear case where it’d be nice if Ruby had an official full documentation of the syntax.

Anyway, it’s not common for blocks to explicitly take keyword arguments, but they can:

def method_taking_key_block(name)
  yield(name: name)
end
=> :method_taking_key_block
method_taking_key_block("fred") { |name:| name.upcase }
=> "FRED"

And, when I say that blocks behave like methods here, that also means that key/value pairs yielded to blocks will roll into a positional argument if there is one:

def method_taking_block
	yield(a: 1, b: 2)
end
> method_taking_block { |x| p x }
=> {:a=>1, :b=>2}

Hot Takes

My main hot take about keyword arguments is the same as my take about positional arguments – there’s every chance that your code would be clearer if you used more keyword arguments.

Beyond that… One thing that crossed my mind is that I kind of wonder what would have happened if Ruby had adopted Python syntax for methods.

In Python, you define only one kind of argument:

def basic_method(name, address, country = "USA"):
	print(name, address, country)

But you can refer to the arguments either way:

basic_method("Noel", "Chicago")

basic_method(name="Noel", address="Chicago")

To me, this has a couple of advantages over the Ruby syntax, mainly that it’s simpler, mostly on the definition side, but also somewhat on the caller side, and I kind of wonder if this was considered for Ruby.

Absent that, I think I wish that Ruby 3.0 had been bolder and removed all cases where keyword arguments can get converted to positional arguments. I have had cases where the Ruby 3.0 behavior is confusing – typically a downstream gem adds a keyword argument and splat behavior changes. Like a lot of changes I’d advocate for in Ruby, I think the current behavior is hard to explain.

Tune in next time

We’ll cover block arguments, and I’ll try to keep it under 2000 words. No promises.



Comments

comments powered by Disqus



Copyright 2024 Noel Rappin

All opinions and thoughts expressed or shared in this article or post are my own and are independent of and should not be attributed to my current employer, Chime Financial, Inc., or its subsidiaries.