Skip to content

Default arguments are evaluated on every call

Level: 201 · a Python programmer who has met the mutable-default trap

One line: A Ruby default is an expression that runs each time the method is called without that argument, so def f(a = []) hands out a fresh array on every call, a default may use an earlier parameter, and it may even count the calls.

A default in a Ruby def is not a value attached to the method; it is code that belongs to the method body and runs, in the method's own scope, whenever the caller leaves that argument out. Three things follow. A [] or {} default is a new object on each call, so nothing accumulates between calls. A default can read the parameters to its left — def rect(w, h = w * 2) — because those have already been bound when it runs (and it cannot read one to its right, which is not bound yet). And a default can have side effects: a counter in a default advances once per call that uses it, and an explicit argument skips the expression entirely.

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

1. def add_item(item, list = [])
   add_item(:a), add_item(:b), add_item(:c) -> [:a], [:b], [:c]
2. where the default lives:       method(:add_item).parameters -> [[:req, :item], [:opt, :list]]
                                  (an expression in the method body, not a stored object)
3. the nil-sentinel idiom:        add_item_sentinel(:a), (:b)  -> [:a], [:b] (works, unnecessary)
4. a default may use an earlier parameter:
   rect(3), rect(3, 4)            -> [3, 6], [3, 4]
   chain(1), chain(1, 5)          -> [1, 2, 3], [1, 5, 6]
5. but not a later one:           later (a = b, b = 1)   -> NameError at call time: undefined local variable or method 'b' for main
6. a default that counts calls:   tick, tick, tick       -> 1, 2, 3
   tick(10), tick                 -> 10, 4 (an explicit argument skips the default)
7. when the default runs:         side     -> (default evaluated now) then 1
                                  side(2)  -> then 2
                                  side     -> (default evaluated now) then 1

Reading the output

Row 1 is the whole point: three calls to add_item without a list give [:a], [:b], [:c] — three arrays. Row 2 shows there is nothing to share: Method#parameters reports [:opt, :list] and no value, because the default is an expression, not an object stored anywhere. Row 3 is the list = nil; list ||= [] idiom a Python reader may reach for out of habit; it works, and it is unnecessary.

Row 4: rect(3) computes h from w, and chain(1) evaluates its defaults left to right, each seeing the last. Row 5 is the boundary: def later(a = b, b = 1) defines fine, but calling it raises NameError because b is not bound yet when a's default runs — Ruby fails at call time, not at definition time. Row 6 makes the timing visible: tick's default increments a counter, so three calls give 1, 2, 3; tick(10) skips the default, and the next bare call gives 4. Row 7 shows when the expression runs — during the call, printed inline before the result, and not at all for side(2).

Compared with Python

Python evaluates a default exactly once, when the def statement runs, and stores the resulting object on the function. Row 1 is the consequence every Python programmer learns the hard way: one list, shared by every call, so the three calls return ['a', 'b', 'c'] three times. Row 2 shows where it lives — add_item.__defaults__ — and row 3 the standard fix, a None sentinel replaced inside the body, which is exactly what Ruby does for you. Rows 4 and 5 fail at definition time with a NameError: when the default expression runs, no parameter exists yet, neither earlier nor later. Row 6: the counter advances once, at def, so tick() returns 1 forever; row 7 prints "(default evaluated now)" before the def finishes and never again. The Python page on this trap is the reverse hub in this library's last chapter, linked below.

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

1. def add_item(item, lst=[])
   add_item('a'), add_item('b'), add_item('c') -> ['a', 'b', 'c'] ['a', 'b', 'c'] ['a', 'b', 'c']
2. where the default lives:       add_item.__defaults__ -> (['a', 'b', 'c'],)
                                  (one object, stored on the function when def ran)
3. the None-sentinel fix:         add_item_sentinel('a'), ('b') -> ['a'] ['b'] (the idiom)
4. a default may use an earlier parameter?
   def rect(w, h=w * 2)           -> NameError at def time
   def chain(a, b=a + 1, c=b + 1) -> NameError at def time
5. nor a later one:               def later(a=b, b=1)    -> NameError at def time
6. a default that counts calls:   tick(), tick(), tick() -> 1 1 1
   tick(10), tick()               -> 10 1 (the default was evaluated once, at def)
7. when the default runs:         def side(...) -> (default evaluated now) at def time
                                  side()   -> 1 (nothing evaluated)
                                  side(2)  -> 2
                                  side()   -> 1

Try it

  1. Write append_log(msg, log = []). Call it twice without a log and show, with equal?, that the two results are different arrays. Then pass one array to both calls and show it accumulates. Finally write next_id(id = ($seq += 1)) and show that next_id(99) leaves the counter alone.
  2. Write window(lo, hi = lo + 10, step = (hi - lo) / 5) and print window(0), window(0, 20) and window(0, 20, 1). Then swap the last two parameters (step before hi) and explain, from the printed NameError, why the order matters.
Solution to 1

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

# Kata: three calls, three separate arrays; the default is used only when
# the argument is omitted; and a default that hands out sequence numbers.
def append_log(msg, log = [])
  log << msg
end

first = append_log(:a)
second = append_log(:b)
puts "1. append_log(:a), append_log(:b) -> #{first.inspect}, #{second.inspect}"
puts "   first.equal?(second)           -> #{first.equal?(second)} (two arrays, not one shared default)"
shared = []
append_log(:a, shared)
append_log(:b, shared)
puts "2. the same array passed twice    -> #{shared.inspect} (the default is skipped, so it accumulates)"

$seq = 0
def next_id(id = ($seq += 1)) = id
puts "3. next_id, next_id               -> #{next_id}, #{next_id}"
puts "   next_id(99), next_id           -> #{next_id(99)}, #{next_id} (an explicit id leaves the counter alone)"

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

1. append_log(:a), append_log(:b) -> [:a], [:b]
   first.equal?(second)           -> false (two arrays, not one shared default)
2. the same array passed twice    -> [:a, :b] (the default is skipped, so it accumulates)
3. next_id, next_id               -> 1, 2
   next_id(99), next_id           -> 99, 3 (an explicit id leaves the counter alone)

See also