Skip to content

Assignment destructures, and blocks auto-splat

Level: 201 · a Python programmer who unpacks tuples and wants Ruby's rules for the edges

One line: a, b = 1, 2, a, *rest = list and (a, b), c = pair, 3 read like Python — but Ruby fills a missing value with nil and drops an extra one where Python raises ValueError, a String or a Hash on the right stays whole where Python unpacks any iterable, and a block with two parameters splits an Array argument on its own, which a lambda refuses.

Multiple assignment has one mechanism: the right-hand side is collected into an Array — a bare list of values becomes one, an Array stays one, and any other object is asked for to_ary and, failing that, wrapped as a one-element Array — and that Array is then distributed over the targets. Distribution is forgiving: a target with nothing left gets nil, values with no target are dropped, one * target gathers what the others leave, parentheses nest a target list, and a trailing comma (first, = arr) makes a one-target list that takes the first element. Because the whole thing is an expression, it has a value: the collected Array. The same distribution runs when a block receives a single Array and declares more than one parameter — each { |k, v| } over pairs, each_with_index { |(x, y), i| } — which is what makes Hash#each and Hash#map feel like two-argument calls. Blocks and procs auto-splat; lambdas and methods do not, so ->(x, y) { }.call([1, 2]) is an ArgumentError.

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

 1. a, b = 1, 2                                              [1, 2]
 2. a, b = b, a  (swap)                                      [2, 1]
 3. a, *b = [1, 2, 3]; *c, d = [1, 2, 3]                     [[1, [2, 3]], [[1, 2], 3]]
 4. a, *b, c = 1, 2, 3, 4                                    [1, [2, 3], 4]
 5. (a, b), c = [1, 2], 3  (nested)                          [1, 2, 3]
 6. first, = [1, 2]  (trailing comma: take the first)        1
 7. a, b = [1]  (too few: nil)                               [1, nil]
 8. a, b = 1, 2, 3  (too many: dropped)                      [1, 2]
 9. a = 1, 2  (one target: an Array)                         [1, 2]
10. a, b = "xy"  (a String does not splat)                   ["xy", nil]
11. a, b = {x: 1, y: 2}  (nor does a Hash)                   [{x: 1, y: 2}, nil]
12. a, b = nil                                               [nil, nil]
13. v = (a, b = 1, 2)  (the assignment is an expression)     [1, 2]
14. pairs.map { |x, y| x + y }  (a block auto-splats)        [3, 7]
15. pairs.each_with_index.map { |(x, y), i| }                ["0:3", "1:7"]
16. {a: 1, b: 2}.map { |k, v| "#{k}#{v}" }                   ["a1", "b2"]
17. ->(x, y) {}.call([1, 2]); proc { |x, y| }.call([1, 2]); m([1, 2]) ["ArgumentError: wrong number of arguments (given 1, expected 2)", [1, 2], ArgumentError]
18. a, b = obj with to_ary; c, d = a Struct (no to_ary)      [[7, 8], [#<struct Point x=1, y=2>, nil]]
19. def two = (return 1, 2); two, then a, b = two            [[1, 2], [1, 2]]

Reading the output

Rows 1–5 are the forms that match Python exactly: parallel assignment, the swap, * at the start or the end, * in the middle, and nesting with parentheses.

Rows 6–8 are the forgiving distribution. first, = [1, 2] takes 1; a, b = [1] leaves b as nil; a, b = 1, 2, 3 drops the 3. None of them is an error.

Rows 9–12 are the collection step. One target with two values gets the Array [1, 2]. A String, a Hash and nil have no to_ary, so each is wrapped as one value and the second target is nil — a String does not split into characters and a Hash does not split into pairs.

Row 13: the assignment is an expression whose value is the collected Array.

Rows 14–16 are auto-splat. map { |x, y| } over [[1, 2], [3, 4]] receives one Array per call and spreads it; |(x, y), i| nests the spread inside each_with_index's pair; Hash#map yields [key, value] and |k, v| spreads it.

Row 17: a lambda called with one Array and two parameters raises wrong number of arguments (given 1, expected 2); a proc spreads it; a method is as strict as the lambda.

Row 18 is the protocol: an object that defines to_ary destructures; a Struct, which defines to_a but not to_ary, is wrapped whole and d is nil — splat it explicitly (c, d = *point) when you want its members.

Row 19: return 1, 2 returns an Array, so a method has multiple return values only in the sense that the caller destructures one Array.

Compared with Python

The twin asks unpacking the same nineteen questions. Rows 1–5 agree, with *rest a list. Then the rules part. Python's unpacking is exact: first, = [1, 2], a, b = [1] and a, b = 1, 2, 3 are all ValueError (rows 6–8), and the way to take a first element is first, *_ = seq. Collection follows the iterator protocol, not to_ary: a = 1, 2 builds a tuple (row 9), a str unpacks into characters and a dict into its keys (rows 10–11), and None is a TypeError because it does not iterate (row 12). Assignment is a statement with no value, so v = (a, b = 1, 2) does not even compile (row 13). for x, y in pairs and for i, (x, y) in enumerate(pairs) are the auto-splat, spelled as unpacking in the loop header (rows 14–16). A lambda — or any function — never spreads a single argument: calling it with a list is a TypeError, tuple parameters (lambda (x, y):) are a SyntaxError since Python 3 removed them (Not machine-checked here.), and itertools.starmap is the spreading call (row 17). The protocol behind rows 18 and 19 is __iter__ and the tuple.

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

 1. a, b = 1, 2                                              [1, 2]
 2. a, b = b, a  (swap)                                      [2, 1]
 3. a, *b = [1, 2, 3]; *c, d = [1, 2, 3]  (rest: a list)     [[1, [2, 3]], [[1, 2], 3]]
 4. a, *b, c = 1, 2, 3, 4                                    [1, [2, 3], 4]
 5. (a, b), c = [1, 2], 3  (nested)                          [1, 2, 3]
 6. first, = [1, 2]; first, = [1]; first, *_ = [1, 2]        ValueError, 1, 1
 7. a, b = [1]  (too few)                                    ValueError
 8. a, b = 1, 2, 3  (too many)                               ValueError
 9. a = 1, 2  (one target: a tuple)                          (1, 2)
10. a, b = "xy"  (any iterable unpacks)                      ['x', 'y']
11. a, b = {'x': 1, 'y': 2}  (a dict unpacks its keys)       ['x', 'y']
12. a, b = None                                              TypeError
13. compile("v = (a, b = 1, 2)")  (a statement: no value)    SyntaxError
14. [x + y for x, y in pairs]  (for unpacks)                 [3, 7]
15. for i, (x, y) in enumerate(pairs)                        ['0:3', '1:7']
16. [f'{k}{v}' for k, v in d.items()]                        ['a1', 'b2']
17. lambda x, y with [1, 2]; lambda (x, y); starmap          [TypeError, SyntaxError, [3, 7]]
18. a, b = obj with __iter__; c, d = obj without             [[7, 8], TypeError]
19. def two(): return 1, 2; two(), then a, b = two()         [(1, 2), [1, 2]]

Try it

  1. Parse records like "ada 36 london", "bob 41" and "cy 29 paris texas" with name, age, *rest = line.split, printing a missing city as (unknown) and any extra fields separately; then destructure the whole list with first, *middle, last, and the pairs that minmax, divmod and partition return.
  2. Iterate a Hash with each_with_index { |(k, v), i| } printing i: k=v, then again with each.with_index(1), and then write the block as |k, v, i| without the parentheses and explain what k, v and i hold — row 15 and the rule "one Array, more than one parameter" are the hint.
Solution to 1

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

# Kata: destructure "name age city ..." records with a splat, tolerating missing
# and extra fields -- a missing field is nil, extra fields land in the splat --
# then destructure the pairs that divmod, minmax and partition return.

lines = ["ada 36 london", "bob 41", "cy 29 paris texas"]
lines.each do |line|
  name, age, *rest = line.split
  city = rest.first || "(unknown)"
  extra = rest.drop(1)
  printf("%-4s age %-4s city %-10s extra %s\n", name, age.inspect, city, extra.inspect)
end

first, *middle, last = lines
puts "first #{first.inspect}, middle #{middle.inspect}, last #{last.inspect}"

lo, hi = [5, 3, 9].minmax
q, r = 17.divmod(5)
evens, odds = (1..6).partition(&:even?)
puts "minmax #{[lo, hi].inspect}, divmod #{[q, r].inspect}, partition #{[evens, odds].inspect}"

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

ada  age "36" city london     extra []
bob  age "41" city (unknown)  extra []
cy   age "29" city paris      extra ["texas"]
first "ada 36 london", middle ["bob 41"], last "cy 29 paris texas"
minmax [3, 9], divmod [3, 2], partition [[2, 4, 6], [1, 3, 5]]

See also