In Progress
Unit 1, Lesson 21
In Progress

Keyword Arguments

Video transcript & code

One of the nice things about working on an app of my own is that I don't have to worry about backwards compatibility. Lately I've been taking advantage of this fact to start exploiting more features of the very latest version of Ruby, which as I'm making this episode is version 2.1.

One feature that I've started using a lot is Ruby's relatively recent support for true keyword arguments. Now it's possible you're already using these, and know all about them. But in case you aren't, here's a quick introduction.

Here's a method that uses "old-style" keyword arguments. As its last argument, it receives an argument named "options" which defaults to an empty hash.

We have to write code to extract each option we care about from this hash. First there's an option for what kind of patty the customer wants, which defaults to beef. We use the Hash#fetch method to get the value or provide a default if it is not present.

(By the way, if any of the uses of #fetch here are unfamiliar to you, you might want to pause and go watch episodes 8, 12, and 15 and then come back.)

Customers must specify if they want cheese or not, so that option is extracted with without any default. This line will raise a KeyError if no :cheese option is specified.

Next this method processes some optional toppings. Onions are normally included on burgers from this shop, so the default for that option is true. Bacon has to be specifically requested, so that option defaults to false if it's not specified.

When we call this method, we are able to ignore all that fancy option processing. Because Ruby lets us omit the braces around a Hash argument if it's the last argument passed to a method, we can easily specify our burger preferences as keys and values.

def order_burger(customer_name, options={})
  has_cheese = options.fetch(:cheese)
  toppings = []
  toppings << :onions if options.fetch(:onions) { true }
  toppings << :bacon  if options.fetch(:bacon)  { false  }
  patty_type = options.fetch(:patty)  { :beef }

  print "That's one #{patty_type} #{has_cheese && "cheese"}burger"
  print " with #{toppings.join(', ')}"
  print " for #{customer_name}.\n"
end

order_burger("Grimm", cheese: true, bacon: true, onions: false)
# >> That's one beef cheeseburger with bacon for Grimm.

Despite all the effort we went to handle keyword-style arguments, though, there's still a slight deficiency in how this method handles options. If we accidentally misspell an argument, the mistake is silently ignored.

order_burger("Grimm", cheese: true, bacon: true, onion: false)
# >> That's one beef cheeseburger with onions, bacon for Grimm.

In order to check that only known options are specified, we need to do a little more. We change all of our #fetch calls to #delete. Then, once all the known options are processed, we add a conditional to check if there are any options left in the hash. If there are, we raise a descriptive ArgumentError.

def order_burger(customer_name, options={})
  patty_type = options.delete(:patty)  { :beef }
  has_cheese = options.delete(:cheese)
  toppings = []
  toppings << :onions if options.delete(:onions) { true }
  toppings << :bacon  if options.delete(:bacon)  { false  }
  if options.any?
    raise ArgumentError, "Unrecognized option(s): #{options}"
  end

  print "That's one #{patty_type} #{has_cheese && "cheese"}burger"
  print " with #{toppings.join(', ')}"
  print " for #{customer_name}.\n"
end

order_burger("Grimm", cheese: true, bacon: true, onion: false)
# ~> -:8:in `order_burger': Unrecognized option(s): {:onion=>false} (ArgumentError)
# ~>    from -:16:in `<main>'

So far we've described the pseudo-keyword arguments that Ruby has always had. We can see that while they look indistinguishable from "real" keyword args from the caller's point of view, on the callee side they take a lot of work to handle.

Let's transform these options into true keyword arguments.

We get rid of the options hash. We specify the :patty keyword. exactly as it would appear in a call to the method, with the default value as its value. Next we specify the cheese keyword, but because it is a required option, we don't supply a default for it. We just leave the colon with nothing following it.

We follow this with the onions and bacon options, including their respective defaults.

Now we can get rid of a lot of the option-extracting code from before. We remove the patty-type and cheese lines completely. The toppings code gets a lot simpler as well. The code to check for unrecognized options gets removed completely. Finally, we update the rest of the method to use variable names as they appear in the keyword argument list.

def order_burger(customer_name, patty: :beef, cheese:, onions: true, bacon: false)
  toppings = []
  toppings << :onions if onions
  toppings << :bacon  if bacon

  print "That's one #{patty} #{cheese ? "cheese" : ""}burger"
  print " with #{toppings.join(', ')}"
  print " for #{customer_name}.\n"
end

We can call this method exactly as we did before. With just a "cheese" option, it supplies the defaults for patty type and toppings.

order_burger("Grimm", cheese: true)
# >> That's one beef cheeseburger with onions for Grimm.

If we omit the cheese keyword, it complains to us.

order_burger("Grimm")
# ~> -:12:in `<main>': missing keyword: cheese (ArgumentError)

We can override defaults if we want.

order_burger("Grimm", patty: "veggie", cheese: false)
# >> That's one veggie burger with onions for Grimm.

If we misspell an option, it lets us know.

order_burger("Grimm", cheese: false, onion: false)
# ~> -:11:in `<main>': unknown keyword: onion (ArgumentError)

Using keywords in method calls–whether the real thing, or simulating them with options hashes–can make them more self-documenting, because you don't have to remember which argument position corresponds to what. They also make code easier to change, because adding new arguments to a method doesn't break old code by changing the argument order and arity.

It's been interesting seeing the effect that Ruby's new keyword arguments have had on my coding style as I've started to use them more. I'm finding I'm a lot more inclined to specify new methods as taking descriptive keywords instead of positional parameters. I always knew about the benefits of keyword arguments, but now that they are so easy to use I don't hesitate to choose them over positional params.

In the next episode we'll look at some more advanced uses of keyword arguments. But this is enough for now. Happy hacking!

Responses