The letter A styled as Alchemists logo. lchemists Syndication Icon

Putin's War on Ukraine - Watch President Zelenskyy's speech and help Ukraine fight against the senseless cruelty of a dictator!

Published September 1, 2022 Updated September 8, 2022
Cover
Ruby Method Parameters And Arguments

The distinction between method parameters and arguments — as shown in the screenshot above — is probably familiar to most Ruby engineers. If not, here’s a quick explanation on the distinction between the two as pulled from the Marameters gem:

  • Parameters: Represents the expected values to be passed to a method when messaged as defined when the method is implemented. Example: def demo one, two: nil.

  • Arguments: Represents the actual values passed to the method when messaged. Example: demo 1, two: 2.

With the above in mind, this article will dig deeper into how parameters and arguments work in Ruby since there are subtleties that might not be obvious unless you’re doing more complex logic and/or metaprogramming.

Parameters

When it comes to defining parameters for your method and later calling your method with arguments, there is a bit of terminology you should be aware of. I’ve split the terminology into several sections with the last two being less common.

Basic

Basic parameters are those which you are most likely to be aware of and use on a daily basis. Consider the following code:

def demo(one, two = :b, *three, four:, five: :e, **six, &seven) = super

The above leverages the basic parameters you can define for a method in order of precedence. If we inspect the method’s parameters and then use Amazing Print to render the response, things become more clear since we get an array of tuples (i.e. kind and name):

ap method(:demo).parameters

# [
#   [
#     :req,
#     :one
#   ],
#   [
#     :opt,
#     :two
#   ],
#   [
#     :rest,
#     :three
#   ],
#   [
#     :keyreq,
#     :four
#   ],
#   [
#     :key,
#     :five
#   ],
#   [
#     :keyrest,
#     :six
#   ],
#   [
#     :block,
#     :seven
#   ]
# ]

The kinds of parameters — in addition to their names — can be further broken down into three categories:

  • Positionals: Positional parameters are more strict, less descriptive, and require arguments to be passed in a specific order, otherwise you’ll get an ArgumentError. There are three types of positional arguments you can use:

    • req: A required parameter with no default value.

    • opt: An optional parameter that always has a default value and should only be defined after a required positional parameter.

    • rest: Optional parameters — also known as single splats — which can be any number of parameters after your optional positionals. ⚠️ Heavy use of this parameter is considered a code smell because, when unchecked, can get quite large so use with care or avoid altogether.

  • Keywords: Keyword parameters are more descriptive than positional parameters and have a similar breakdown to positional parameters:

    • keyreq: A required parameter with no default value.

    • key: An optional parameter that always has a default value and should only be defined after a required keyword parameter.

    • keyrest: Optional parameters — also known as double splats — which can be any number of parameters that come after your optional keywords. ⚠️ Heavy use of this parameter is considered a code smell because, when unchecked, can get quite large so use with care or avoid altogether.

  • Block: You can only define a single block parameter and must be in the last position. In our case, the block parameter is explicitly named seven (although most people use block as the parameter name). A deeper dive into blocks is out of scope for this article but here’s a quick explanation:

    • Implicit: All methods can implicitly accept a block. Example: demo(:a, four: :d) { "Your implementation goes here." }. Implicit blocks make for an elegant design when coupled with block_given?.

    • Explicit: If you want to explicitly reference a block, then you must name it. Example: demo :a, four: :d, &block. Explicit blocks are great when you need to use the block directly without the need to check if a block has been implicitly defined.

    • Anonymous: Anonymous blocks were first introduced in Ruby 3.1.0 and are great when you need to make the block explicit for passing to another object but don’t need to give it a name. Example: demo(:a, four: :d, &).

Splats

Splats come in three kinds. They are either forwards, bare, or named. To illustrate, the following table provides a visual breakdown of the different kinds of splats:

Kind Method Parameters

Forwards

def demo(...) = super

[[:rest, :*], [:keyrest, :**], [:block, :&]]

Bare

def demo(*, **, &) = super

[[:rest], [:keyrest], [:block, :&]]

Named

def demo(*positionals, **keywords, &block) = super

[[:rest, :positionals], [:keyrest, :keywords], [:block, :block]]

We’ll dive into the details of each next.

Forwards

Argument forwarding was first introduced in Ruby 2.7.0 and later enhanced with leading arguments in Ruby 3.0.0. The syntax only requires three dots (ellipses) to use. Example:

def demo(...) = super

ap method(:demo).parameters

# [
#   [
#     :rest,
#     :*
#   ],
#   [
#     :keyrest,
#     :**
#   ],
#   [
#     :block,
#     :&
#   ]
# ]

What’s important to highlight with an argument forwarding parameter is that it resolves to the following:

  • Uses :rest (single splat) as the kind and :* as the name.

  • Uses :keyrest (double splat) as the kind and :** as the name.

  • Uses :block as the kind and :& as the name (i.e. anonymous block).

The names of these parameters are important because they allow you to pattern match which also allows you to dynamically distinguish between a method that is using argument forwarding or splats.

Bare

Here’s an example of bare splats:

def demo(*, **) = super

ap method(:demo).parameters

# [
#   [
#     :rest
#   ],
#   [
#     :keyrest
#   ]
# ]

Bare splats never have a name, you only get kind information. They are also of no use to the method that defines them because — hence the no name — you can’t get a handle on them. They do have a couple of benefits, though:

  • You can use super to pass the splatted arguments up to the super method for further processing.

  • They can be used to allow any positional and/or keyword argument to be consumed into a black hole so-to-speak. This can be handy when coupled with the Null Object Pattern.

Named

In contrast to bare — and even forwarded — splats, we have named splats. Example:

def demo(*positionals, **keywords) = super

ap method(:demo).parameters

# [
#   [
#     :rest,
#     :positionals
#   ],
#   [
#     :keyrest,
#     :keywords
#   ]
# ]

As you can see, we get kind and name information which means we can make use of them within the method like any other argument.

The difference between forwards, bare and named splats is important to know when inspecting a method’s behavior or dynamically building a list of arguments for messaging.

Post

The post parameter is more obscure, rare, and a bit awkward when you see it in the wild. A post parameter is a required positional parameter that comes after an optional positional parameter. Example:

def demo(one, two = 2, three) = super

ap method(:demo).parameters

# [
#   [
#     :req,
#     :one
#   ],
#   [
#     :opt,
#     :two
#   ],
#   [
#     :req,
#     :three
#   ]
# ]

In very rare cases, post parameters can be useful but, in general, suffer from the following:

  • They are harder to read, surprising, and very uncommon to see in actual code.

  • You will get a RuboCop Style/OptionalArguments error if attempting to use this parameter.

  • You can’t use a single splat positional argument after a post parameter is defined. Although, you can use required, optional, and double splat keyword parameters as well as a block.

  • Performance-wise, Ruby can’t optimize post parameters like it can for required and optional parameters.

No Keywords

This keyword parameter — though, rarely used — comes in handy when you don’t want to support any keywords. Example:

def demo(**nil) = "Keywords are not allowed!"

puts method(:demo).parameters.inspect  # [[:nokey]]

Generally, you combine the nokey parameter with positional parameters. Example:

def demo(one, two = 2, **nil) = [one, two].each(&:inspect)

ap method(:demo).parameters

# [
#   [
#     :req,
#     :one
#   ],
#   [
#     :opt,
#     :two
#   ],
#   [
#     :nokey
#   ]
# ]

Once the nokey parameter is defined, you can’t provide any required or optional parameters (blocks are always allowed, though) as the following syntax is invalid:

def demo(**nil, one:, two: 2) = "Invalid."

Now that we are on the same page in terms of parameters, let’s move on to arguments.

Arguments

To understand arguments, we’ll need to a more…​introspectful…​implementation:

module Operation
  def self.demo(one, two = :b, *three, four:, five: :e, **six, &seven)
    puts <<~ARGUMENTS
      1 (reg):      #{one.inspect}
      2 (opt):      #{two.inspect}
      3 (rest):     #{three.inspect}
      4 (keyreq):   #{four.inspect}
      5 (key):      #{five.inspect}
      6 (keyrest):  #{six.inspect}
      7 (block):    #{seven.inspect}
    ARGUMENTS
  end
end

Keep this implementation in mind or scroll back, when needed, to reread since this implementation will be referred to repeatedly from this point.

Basic

With the above implementation, the minimal set of arguments we could use are:

Operation.demo :a, four: :d

# 1 (reg):      :a
# 2 (opt):      :b
# 3 (rest):     []
# 4 (keyreq):   :d
# 5 (key):      :e
# 6 (keyrest):  {}
# 7 (block):    nil

All seven arguments — a few of which are either nil or empty since they are optional — are printed to the console. This output can be further explained as follows:

  1. req: a: This positional argument was required so we had to pass in a value (i.e. :a).

  2. opt: b: This positional argument was optional so the default value of :b was answered back.

  3. rest: []: This positional argument was optional too but since we gave it nothing, we got an empty array due to single splats always resolving to an array.

  4. keyreq: d: This keyword argument was required so we had to pass in a value (i.e. :d).

  5. key: e: This keyword argument was optional so the default value of :e was answered back.

  6. keyrest: {}: This keyword argument was optional too but since we gave it nothing, we got an empty hash due to double splats always resolving to a hash.

  7. block: nil: We didn’t supply a block so we got back nil in this case since blocks are optional since they can be implicitly or explicitly used.

To contrast the minimal set of arguments passed to the .demo method, we can also pass in a maximum — loosely speaking — set of arguments. Example:

function = proc { "test" }
Operation.demo :a, :b, :y, :z, four: :d, y: 10, z: 20, &function

# 1 (reg):      :a
# 2 (opt):      :b
# 3 (rest):     [:y, :z]
# 4 (keyreq):   :d
# 5 (key):      :e
# 6 (keyrest):  {:y=>10, :z=>20}
# 7 (block):    #<Proc:0x0000000109679ad0 /snippet:32>

This time we have a lot more arguments passed in and printed out to the console. We can break this down further highlighting the differences:

  1. req: a

  2. opt: b

  3. rest: [:y, :z]: Any number of optional positional arguments could have been supplied here but only :y and :z were used in this case.

  4. keyreq: d

  5. key: e

  6. keyrest: {:y ⇒ 10, :z ⇒ 20}: Any number of optional keyword arguments could have been supplied here but only y: 10 and z: 20 were used in this case.

  7. block: #<Proc:0x000000010d1cbc78>: Since we passed in an explicit block, you can see it’s pass through and printed out as well.

With the basic use of arguments in mind, we can now expand into more advanced usage.

Splats

Splats — as mentioned earlier — are split into two categories:

  • Single (array): Can be bare (i.e. *) or named (example: *positionals).

  • Double (hash): Can be bare (i.e. **) or named (example: **keywords).

You’ll want to splat your arguments when you need to destruct your arrays or hashes into a list of arguments or can’t directly pass them to a method but need to dynamically build up the arguments instead.

With the .demo method implementation from earlier and using our knowledge of positional, keyword, and block parameters; we can pass single and double splats along with a block to this method as follows:

Operation.demo(*%i[a b y z], **{four: :d, y: 10, z: 20}, &function)

# 1 (reg):      :a
# 2 (opt):      :b
# 3 (rest):     [:y, :z]
# 4 (keyreq):   :d
# 5 (key):      :e
# 6 (keyrest):  {:y=>10, :z=>20}
# 7 (block):    #<Proc:0x0000000119418d58 /snippet:32>

Notice the above’s output is identical to our earlier example (except for the new Proc instance) where we passed in a maximum set of arguments. What’s different is we’ve categorized the positional, keyword, and block arguments. Single and double splats makes this easier. To take this a step further — and assuming the argument list was dynamically assigned to local variables — the code then becomes more descriptive:

function = proc { "test" }
positionals = %i[a b y z]
keywords = {four: :d, y: 10, z: 20}

Operation.demo(*positionals, **keywords, &function)

# 1 (reg):      :a
# 2 (opt):      :b
# 3 (rest):     [:y, :z]
# 4 (keyreq):   :d
# 5 (key):      :e
# 6 (keyrest):  {:y=>10, :z=>20}
# 7 (block):    #<Proc:0x0000000109c59810 /snippet:32>

Keep in mind that single and double splats must be used when destructuring arrays and hashes for messaging purposes. For example, the following doesn’t work because you have to explicitly use single or double splats with your arguments:

Operation.demo(positionals, keywords, &function)

#  missing keyword: :four (ArgumentError)

We’ll learn more about splats in message delegation next.

Message Delegation

At this point, we are now primed to discuss message delegation which is where parameter and argument compatibility gets most interesting. To set the stage, we’ll expand upon our earlier implementation by using the following snippet of code:

#! /usr/bin/env ruby
# frozen_string_literal: true

# Save as `snippet`, then `chmod 755 snippet`, and run as `./snippet`.

require "bundler/inline"

gemfile true do
  source "https://rubygems.org"

  gem "amazing_print"
  gem "debug"
  gem "dry-monads"
end

include Dry::Monads[:result]

function = proc { "test" }

module Operation
  def self.demo(one, two = :b, *three, four:, five: :e, **six, &seven)
    puts <<~ARGUMENTS
      1 (reg):      #{one.inspect}
      2 (opt):      #{two.inspect}
      3 (rest):     #{three.inspect}
      4 (keyreq):   #{four.inspect}
      5 (key):      #{five.inspect}
      6 (keyrest):  #{six.inspect}
      7 (block):    #{seven.inspect}
    ARGUMENTS
  end
end

class Exampler
  def initialize operation, method
    @operation = operation
    @method = method
  end

  def first_example(...)
    operation.public_send(method, ...)
  end

  def second_example *positionals, **keywords, &block
    operation.public_send method, *positionals, **keywords, &block
  end

  def third_example arguments
    positionals, keywords, block = arguments
    operation.public_send method, *positionals, **keywords, &block
  end

  def fourth_example result
    result.fmap do |positionals, keywords, block|
      operation.public_send method, *positionals, **keywords, &block
    end
  end

  private

  attr_reader :operation, :method
end

The major changes are that:

  • The above is written as a Bundler Inline script so you can tinker with the code locally.

  • Monads are used. Don’t worry about needing to know how to use monads. You only need to care about the destructuring of arguments in the #fourth_example method. I’ll explain, shortly.

Now we can focus on the #*_example methods in terms of argument forwarding, splats, and destructured arguments.

Argument Forwarding

Argument forwarding makes passing of arguments less of a hassle in some cases. To see argument forwarding in action, here’s how make use of the above code snippet:

exampler = Exampler.new Operation, :demo

exampler.first_example :a, :b, :y, :z, four: :d, y: 10, z: 20, &function

# 1 (reg):      :a
# 2 (opt):      :b
# 3 (rest):     [:y, :z]
# 4 (keyreq):   :d
# 5 (key):      :e
# 6 (keyrest):  {:y=>10, :z=>20}
# 7 (block):    #<Proc:0x0000000109675980 /snippet:32>

By now, the output should come as no surprise since all we’ve done is forward the arguments from #first_example to .demo by adding a leading argument to specify the :demo method when using #public_send. This is a quite elegant.

Splats

Should argument forwarding not be desired or needed, we can always fall back to using splats:

exampler = Exampler.new Operation, :demo

exampler.second_example :a, :b, :y, :z, four: :d, y: 10, z: 20, &function

# 1 (reg):      a
# 2 (opt):      b
# 3 (rest):     [:y, :z]
# 4 (keyreq):   d
# 5 (key):      e
# 6 (keyrest):  {:y=>10, :z=>20}
# 7 (block):    #<Proc:0x000000010b7cbee0 /snippet:18>

Again, no change in output as we get the same desired result. The subtle distinction is that you must use splats in your parameters and continue to use splats when delegating the arguments, otherwise you’ll end up with nested arrays and/or hashes. Here are a few examples of the errors you’ll get when the code is modified without splats:

# No parameter splats.
def second_example positionals, keywords, &block
  operation.public_send method, *positionals, **keywords, &block
end

# Yields:  wrong number of arguments (given 5, expected 2) (ArgumentError)

# No argument splats.
def second_example *positionals, **keywords, &block
  operation.public_send method, positionals, keywords, &block
end

# Yields: `demo': missing keyword: :four (ArgumentError)

Destructuring

Last, but not least, is argument destructuring. Both the third and fourth example methods tackle this in similar ways by wrapping of all arguments within a single array. Consider the following:

exampler = Exampler.new Operation, :demo

exampler.third_example [%i[a b y z], {four: :d, five: :e, y: 10, z: 20}, function]

# 1 (reg):      :a
# 2 (opt):      :b
# 3 (rest):     [:y, :z]
# 4 (keyreq):   :d
# 5 (key):      :e
# 6 (keyrest):  {:y=>10, :z=>20}
# 7 (block):    #<Proc:0x00000001091916a8 /snippet:32>

In this case, the single array argument is composed of positional, keyword, and block elements which are destructured into positionals, keywords, block local variables so they can be passed on using a single splat, double splat, and ampersand. There is no way to dynamically use *, **, or & unless you evaluate the expression as a string. Example:

def third_example arguments
  positionals, keywords, block = arguments
  instance_eval "operation.#{method} *positionals, **keywords, &block", __FILE__, __LINE__
end

Instance evaluation would be an unnecessary — and less performant — so prefixing the local variables with *, **, and & is better. A similar pattern is used when passing a monad as an argument to the fourth method:

exampler = Exampler.new Operation, :demo

exampler.fourth_example Success([%i[a b y z], {four: :d, five: :e, y: 10, z: 20}, function])

# 1 (reg):      a
# 2 (opt):      b
# 3 (rest):     [:y, :z]
# 4 (keyreq):   d
# 5 (key):      e
# 6 (keyrest):  {:y=>10, :z=>20}
# 7 (block):    #<Proc:0x0000000109ba35b0 /snippet:18>

The key difference is that we can use the block parameters to automatically destructure into positional, keyword, and block arguments needed for message passing. 🎉

Sadly, we can’t use argument forwarding in block parameters. It would be nice but is currently not possible.

Marameters

At the start of this article, I alluded to the Marameters gem so will now call attention to it. Specifically, the .categorize method. The following modifies our earlier code snippet by adding a fifth method. Assuming Marameters is installed locally, here’s how you can leverage Marameters in the same manner as demonstrated earlier:

class Exampler
  def initialize operation, method, marameters: Marameters
    @operation = operation
    @method = method
    @marameters = marameters
  end

  # The rest of the implementation is truncated for brevity.

  def fifth_example arguments
    marameters.categorize(operation.method(method).parameters, arguments)
              .then do |splat|
                operation.public_send method, *splat.positionals, **splat.keywords, &splat.block
              end
  end

  private

  attr_reader :operation, :method, :marameters
end

Notice the Marameters dependency is injected so marameters.categorize can be used to categorize positional, keyword, and block arguments for passing to the method. Using everything we’ve discussed above, Marameters will ensure the arguments align as required by the method’s parameters. Example:

exampler = Exampler.new Operation, :demo
exampler.fifth_example [:a, :b, %i[y z], {four: :d}, nil, {y: 10, z: 20}, function]

# 1 (reg):      :a
# 2 (opt):      :b
# 3 (rest):     [:y, :z]
# 4 (keyreq):   :d
# 5 (key):      :e
# 6 (keyrest):  {:y=>10, :z=>20}
# 7 (block):    #<Proc:0x0000000109370b40 /snippet:32>

As you can see, the output is the same as all of our earlier examples. No change and no surprise there. However — and this is important to be compatible with Method#parameters — we use a single array argument which means all elements of the array must be in the right position to match the equivalent positions of the method’s parameters.

The Marameters gem can be handy when you want to build an array of arguments for forwarding to a method. Definitely check out the gem’s documentation for more details.

Conclusion

During the course of this article we’ve learned how to parse a methods’s parameters, along with the kinds of parameters you can use in a method signature, and how the arguments passed to the method are parsed based on the defined parameters. We’ve also learned, briefly, how to leverage the Marameters gem to dynamically build a list of method arguments. Hopefully, with this new knowledge, you’ll be able to leverage better use of parameters and arguments in your own implementations too. Enjoy!