Matthew Lindfield Seager

Matthew Lindfield Seager

Digging in to Ruby Method Definitions

Today I learned that parentheses are optional in Ruby, even when defining methods, not just when you’re calling them.

Somehow that got me digging into positional arguments, named arguments and blocks so here’s a comprehensive (but pathological) example of defining a method without parentheses but with all 7 possible argument types:

def arguments   required_positional,
                optional_positional=2,
                *other_positionals,
                another_required_positional,
                required_keyword:,
                optional_keyword: 7,
                another_required_keyword:,
                **other_keywords,
                &block
  puts "required_positional = #{required_positional}"
  puts "optional_positional = #{optional_positional}"
  puts "other_positionals = #{other_positionals}"
  puts "another_required_positional = #{another_required_positional}"
  puts "required_keyword = #{required_keyword}"
  puts "optional_keyword = #{optional_keyword}"
  puts "another_required_keyword = #{another_required_keyword}"
  puts "other_keywords = #{other_keywords}"
  3.times(&block)
end

arguments   1, 2.0, 3, 4, 5, other_b: 10, another_required_keyword: 8,
            other_a: 9, required_keyword: 6, other_c: 11 do
  print 'foo '
end

# required_positional = 1
# optional_positional = 2.0
# other_positionals = [3, 4]
# another_required_positional = 5
# required_keyword = 6
# optional_keyword = 7
# another_required_keyword = 8
# other_keywords = {:other_b=>10, :other_a=>9, :other_c=>11}
# foo foo foo

Observation 1

You may have noticed that I passed 2.0 (as a Float) to the optional_positional argument, which defaults to 2 (as an Integer). I did this because *other_positionals only gets left over arguments. If I omit the 2.0 (hoping for optional_positional to be set to it’s default value of 2) then optional_positional greedily grabs the first spare argument (3, in the example below), “stealing” it from the other_positionals array:

arguments   1, 3, 4, 5, other_b: 10, another_required_keyword: 8,
            other_a: 9, required_keyword: 6, other_c: 11 do
  print 'bar '
end

# required_positional = 1
# optional_positional = 3
# other_positionals = [4]
# another_required_positional = 5
# required_keyword = 6
# optional_keyword = 7
# another_required_keyword = 8
# other_keywords = {:other_b=>10, :other_a=>9, :other_c=>11}
# bar bar bar

Observation 2

The Ruby docs state that (emphasis mine):

Prefixing an argument with * causes any remaining arguments to be converted to an Array.
The array argument must be the last positional argument, it must appear before any keyword arguments.

As you can see in the examples above, that’s not the behaviour we’re seeing. I added an additional (required) positional argument after the array argument and Ruby happily accepted it. I’m not sure if that’s a language bug or a documentation bug but given that it’s very un-idiomatic Ruby you will hopefully never see (or write!) this in the wild. Just because you can, doesn’t mean you should! :)

Observation 3

This is basically just a variation of the previous two observations but I’ll point it out anyway… the second required positional argument (another_required_positional) takes precedence over optional_positional and *other_positionals. This makes sense (once you accept the undocumented ability to add additional required positional arguments) but I thought I’d mention it anyway. If we only provide two positional arguments they are consumed by the 1st and 4th required arguments, leaving the 2nd at its default value and the 3rd (array) empty:

arguments   1, 5, other_b: 10, another_required_keyword: 8,
            other_a: 9, required_keyword: 6, other_c: 11 do
  print 'baz '
end

# required_positional = 1
# optional_positional = 2
# other_positionals = []
# another_required_positional = 5
# required_keyword = 6
# optional_keyword = 7
# another_required_keyword = 8
# other_keywords = {:other_b=>10, :other_a=>9, :other_c=>11}
# baz baz baz

Observation 4

In case you were wondering, the undocumented behaviour for positional arguments does not hold true for keyword arguments. Adding an additional keyword argument after the keyword hash argument, **other_keywords, causes a syntax error:

def faulty_keyword_arguments(   required_keyword:, optional_keyword: 'b',
                                **other_keywords, another_required_keyword:)
# syntax error, unexpected tLABEL, expecting & or '&'
# ...ords, another_required_keyword:)
# ...                               ^

As the message suggests, the only thing allowed here is a block argument (starting with an ampersand).

That being said, other than the **other_keywords keyword hash needing to be last in the keyword arguments, there are no restrictions on the order of optional and required keyword arguments. As you can see in the intial example, another_required_keyword is defined after optional_keyword. They do not need to be grouped together like positional arguments do.

Observation 5

You may be wondering if the old idiom of passing a hash after positional arguments works. This continues to work, but only if you don’t use any keyword arguments in the method definition:

def final_hash a, b=2, *c, &block
  puts "a = #{a}"
  puts "b = #{b}"
  puts "c = #{c}"
end

final_hash(1, 2, 3, 4, 5, six: 6, seven: 7, eight: 8)

# a = 1
# b = 2
# c = [3, 4, 5, {:six=>6, :seven=>7, :eight=>8}]

In the example above there are only 6 arguments. The last three items are a single hash - there are implicit hash braces around them. This means all the elements of the hash must be grouped together at the end. If you try to include a positional argument amongst the hash items you’ll get an error about a missing hash rocket =>.

Observation 6a

If all you do with the block is call it with yield, you don’t need to explicity name the block in the method definition:

def inline_implicit_block a, b=2, *c
  puts "a = #{a}"
  puts "b = #{b}"
  puts "c = #{c}"
  yield self
  yield self
  yield self
end

Observation 6b

If you omit parentheses around the method arguments when calling the method, you can’t use the single line block syntax { print 'foo ' }. This is why the explicit &block examples above have the multiline do and end block syntax. Including the braces lets us use the single line syntax:

inline_implicit_block(1, 2, 3, 4, five: 5, six: 6) { print 'qux '}

# a = 1
# b = 2
# c = [3, 4, {:five=>5, :six=>6}]
# qux qux qux