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¶
- Write
append_log(msg, log = []). Call it twice without a log and show, withequal?, that the two results are different arrays. Then pass one array to both calls and show it accumulates. Finally writenext_id(id = ($seq += 1))and show thatnext_id(99)leaves the counter alone. - Write
window(lo, hi = lo + 10, step = (hi - lo) / 5)and printwindow(0),window(0, 20)andwindow(0, 20, 1). Then swap the last two parameters (stepbeforehi) and explain, from the printedNameError, 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¶
- Arguments: positional, keyword, splat and block — where the optional slot sits among the other seven
- Closures capture variables — the other place Python binds early and Ruby binds late: the loop-variable trap and its
i=idefault fix - Gotchas for Ruby programmers in Python — the mutable default as the first row of the reverse hub
- Variables are references — why appending to a shared default is visible to every caller
- Rust: Optional function arguments ↗ — a language with no default arguments at all:
Optionand builders instead - Python tutorial: default argument values ↗ — the official statement that a default is evaluated once