Skip to content

Arguments: positional, keyword, splat and block

Level: 201 · a reader who knows Python's *args and **kwargs

One line: A Ruby parameter list has eight kinds of slot — required, optional, *rest, post-required, key:, key: default, **opts and &blk — plus ... and anonymous forwarding — and a Hash argument never turns into keywords by itself.

The positional part of a Ruby signature is matched by position only: a name in a def is never something a caller can say, so two(b: 2, a: 1) does not fill a and b, it passes one Hash. Keywords are a separate channel, declared with a trailing colon (k: required, d: 4 defaulted, **opts for the rest), and the two channels do not leak into each other (the Ruby 3.0 "keyword separation" (Not machine-checked here.)): kw({k: 1}) is a positional Hash and fails, kw(**{k: 1}) splats it into keywords and works. A required positional may even come after the splat (def f(a, b = 2, *rest, z)), which Python cannot express. The block is its own slot (&blk), and ... forwards all three channels at once; a bare *, ** or & in the parameter list can be forwarded without a name (new in Ruby 3.2 (Not machine-checked here.)). When a call does not fit, the error is an ArgumentError with one of three stable messages.

Verified output of arguments_positional_keyword_and_splat_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

1. required, optional, *rest, post-required:  def f(a, b = 2, *rest, z)
   f(1, 9)                    -> [1, 2, [], 9]
   f(1, 2, 3, 4, 5)           -> [1, 2, [3, 4], 5]
2. introspected:              method(:f).parameters -> [[:req, :a], [:opt, :b], [:rest, :rest], [:req, :z]]
3. keywords:                  def g(a, k:, d: 4, **opts)
   g(1, k: 2)                 -> [1, 2, 4, {}]
   g(1, k: 2, d: 5, x: 6)     -> [1, 2, 5, {x: 6}]
4. a block parameter:         h { |x| x * 2 }       -> 6
5. ... forwards everything:   fwd(1, 2, k: 3) { :b } -> [[1, 2], {k: 3}, :b]
6. anonymous *, ** and &:     anon(1, k: 2) { :b }  -> [[1], {k: 2}, :b]
   its parameters             -> [[:rest, :*], [:keyrest, :**], [:block, :&]]
7. too few positionals:       f(1)                  -> ArgumentError: wrong number of arguments (given 1, expected 2+)
8. a missing keyword:         kw()                  -> ArgumentError: missing keyword: :k
9. an unknown keyword:        kw(k: 1, z: 2)        -> ArgumentError: unknown keyword: :z
10. a Hash is not keywords:   kw({k: 1})            -> ArgumentError: wrong number of arguments (given 1, expected 0; required keyword: k)
    but ** splats it:         kw(**{k: 1})          -> [1, 4]
11. positionals have no names: two(b: 2, a: 1)      -> ArgumentError: wrong number of arguments (given 1, expected 2)
    the Hash lands in one slot: pos(a: 1)           -> {a: 1} (a Hash)
12. arity:                    f -3, g 2, kw 1, two 2, anon -1

Reading the output

Row 1 is the positional channel in full: with two arguments, a and the post-required z are filled first and b keeps its default; with five, b takes the second, z the last, and *rest whatever is left. Row 2 is the same signature as Method#parameters reports it — :req, :opt, :rest, :req — and row 3 adds the keyword channel: :keyreq, :key, :keyrest. Rows 4 to 6 are the block slot and forwarding: &blk reifies the block, ... passes positionals, keywords and the block on untouched, and the anonymous *, **, & do the same with no names to invent (their parameters entries are literally :*, :** and :&).

Rows 7 to 9 are the three messages to recognise: "wrong number of arguments (given 1, expected 2+)", "missing keyword: :k", "unknown keyword: :z". Row 10 is the keyword-separation rule: a braced Hash is one positional argument, so kw({k: 1}) fails with a message that even names the missing keyword, while ** splats it into the keyword channel. Row 11 is its mirror image: positionals have no names, so two(b: 2, a: 1) is one Hash where two arguments were expected, and pos(a: 1) receives that Hash as a. Row 12: arity is the count of required arguments, negative when the method takes optional ones (-3 for f means at least two, -(2+1)); a required keyword counts as one.

Compared with Python

Python's positional and keyword channels overlap: any parameter declared without a marker can be passed either by position or by name, so two(b=2, a=1) works (row 11) — and you need / to forbid it (pos_only). A bare name after *rest is not a post-required positional but keyword-only, which is why the same def f(a, b=2, *rest, z) rejects f(1, 9) in row 1 and needs z=5 — Ruby's post-required positional has no Python spelling. Rows 3 and 10 agree with Ruby: keyword-only after *, **opts for the rest, a dict is one positional argument and ** splats it. Python has no block slot (a callable is an ordinary argument, rows 4 and 5) and no anonymous forwarding: *args, **kwargs must be named to be passed on (row 6). Every failed call is a TypeError; the twin prints only the type, because CPython rewords these messages between releases. There is no arity; inspect.signature gives the parameters and their kinds (rows 2 and 12).

Verified output of arguments_positional_keyword_and_splat_py.py — regenerated by tools/run_examples.py, never hand-typed.

1. required, default, *rest, then a name:     def f(a, b=2, *rest, z)  -- z is keyword-only
   f(1, 9)                    -> TypeError (z was not given)
   f(1, 2, 3, 4, z=5)         -> [1, 2, (3, 4), 5]
2. introspected:              inspect.signature(f)  -> (a, b=2, *rest, z)
3. keyword-only after *:      def g(a, *, k, d=4, **opts)
   g(1, k=2)                  -> [1, 2, 4, {}]
   g(1, k=2, d=5, x=6)        -> [1, 2, 5, {'x': 6}]
4. a callable parameter:      h(lambda x: x * 2)    -> 6
5. *args, **kwargs forward:   fwd(1, 2, k=3)        -> [(1, 2), {'k': 3}] (no block slot)
6. anonymous *, ** and &:     no counterpart: *args and **kwargs must be named to be forwarded
   fwd's parameters           -> (*args, **kwargs)
7. too few positionals:       f(1)                  -> TypeError
8. a missing keyword:         kw()                  -> TypeError
9. an unknown keyword:        kw(k=1, z=2)          -> TypeError
10. a dict is not keywords:   kw({'k': 1})          -> TypeError
    but ** splats it:         kw(**{'k': 1})        -> [1, 4]
11. positionals have names:   two(b=2, a=1)         -> [1, 2]
    unless marked with /:     pos_only(a=1)         -> TypeError; pos(a=1) -> 1
12. no arity; parameter counts: f 4, g 4, kw 2, two 2, fwd 2

Try it

  1. Write order(item, qty = 1, *extras, priority:, note: "") returning [item, qty, extras, priority, note]. Call it four correct ways (only the required parts; with a quantity; with extras; with a note) and three wrong ways (no priority:, an unknown size: keyword, no item), printing each result or ArgumentError message.
  2. Write call_twice(name, ...) that does send(name, ...) two times and returns both results in an array. Check with a target that takes positionals, keywords and a block that all three channels arrive intact both times — ... may follow a leading named parameter (Not machine-checked here.).
Solution to 1

arguments_positional_keyword_and_splat_kata_rb.rb in full — pasted here by tools/run_examples.py from the file CI runs.

# Kata: one method with a required, an optional, a splat, a required keyword
# and a defaulted keyword -- called four good ways and three bad ones.
def order(item, qty = 1, *extras, priority:, note: "")
  [item, qty, extras, priority, note]
end

def attempt
  yield.inspect
rescue ArgumentError => e
  "#{e.class}: #{e.message}"
end

puts "1. order(:tea, priority: :low)                     -> #{attempt { order(:tea, priority: :low) }}"
puts "2. order(:tea, 2, priority: :low)                  -> #{attempt { order(:tea, 2, priority: :low) }}"
puts "3. order(:tea, 2, :milk, :sugar, priority: :high)  -> #{attempt { order(:tea, 2, :milk, :sugar, priority: :high) }}"
puts "4. order(:tea, priority: :low, note: \"decaf\")      -> #{attempt { order(:tea, priority: :low, note: "decaf") }}"
puts "5. order(:tea)                                     -> #{attempt { order(:tea) }}"
puts "6. order(:tea, priority: :low, size: :large)       -> #{attempt { order(:tea, priority: :low, size: :large) }}"
puts "7. order(priority: :low)                           -> #{attempt { order(priority: :low) }}"

Verified output of arguments_positional_keyword_and_splat_kata_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

1. order(:tea, priority: :low)                     -> [:tea, 1, [], :low, ""]
2. order(:tea, 2, priority: :low)                  -> [:tea, 2, [], :low, ""]
3. order(:tea, 2, :milk, :sugar, priority: :high)  -> [:tea, 2, [:milk, :sugar], :high, ""]
4. order(:tea, priority: :low, note: "decaf")      -> [:tea, 1, [], :low, "decaf"]
5. order(:tea)                                     -> ArgumentError: missing keyword: :priority
6. order(:tea, priority: :low, size: :large)       -> ArgumentError: unknown keyword: :size
7. order(priority: :low)                           -> ArgumentError: wrong number of arguments (given 0, expected 1+; required keyword: priority)

See also