x ||= v assigns when x is nil or false¶
Level: 201 · anyone who has written @cache ||= compute
One line: x ||= v is x || (x = v): it assigns when x is nil or false and leaves every other value alone, so it memoizes a false result by recomputing it every call — while Python's x = x or v replaces 0 and "" as well, and functools.cache remembers a False without complaint.
Ruby has exactly two false values, nil and false, and || returns its left operand whenever that operand is not one of them. x ||= v is sugar for x || (x = v), so the assignment happens only for those two values; 0, "" and [] are all truthy and stay put. That makes ||= the idiom for defaults and for memoization — @cache ||= compute runs compute once and returns the stored value ever after — with one hole: a stored false (or nil) looks exactly like "not computed yet" and is recomputed on every call. The defined?(@ivar) guard closes the hole. The program below runs each of those, then the cousins: &&=, the (h[:k] ||= []) << x grouping idiom, nil.to_a and Array(), fetch with a default, and || against or.
Verified output of or_equals_and_nil_guards_rb.rb — regenerated by tools/run_examples.py, never hand-typed.
The ||= operator, value by value
1. x = nil; x ||= 5 5
2. x = false; x ||= 5 (false is replaced too) 5
3. x = 0; x ||= 5 (0 is truthy: kept) 0
4. x = ""; x ||= 5 ("" is truthy: kept) ""
Memoization with ||=
5. @cache ||= compute, called 3 times: ran 1 time(s)
6. @flag ||= (a false result), called 3 times: ran 3 time(s) <- the trap
7. defined?(@right) guard instead, 3 calls: ran 1 time(s)
The cousins
8. a = 1; a &&= a + 1 / b = nil; b &&= b + 1 2 / nil
9. (h[:k] ||= []) << 1, then << 2 {k: [1, 2]}
10. nil.to_a / Array(nil) / Array([1]) / Array(1..2) [] / [] / [1] / [1, 2]
11. h[:z] / h.fetch(:z, 0) / h.fetch(:z) { |k| "no #{k}" } nil / 0 / "no z"
12. p1 = nil || 5 / p2 = nil or 5 5 / nil (or binds below =)
Reading the output¶
- Rows 1–4 are the operator on four values.
nilandfalseare replaced;0and""are kept because they are truthy in Ruby — the row a Python programmer expects to come out the other way. - Row 5 is the memoization idiom working: three calls, one computation.
- Row 6 is the trap: the computed value is
false, so every call sees a false@flagand computes again — three calls, three computations. Row 7 guards ondefined?(@right)instead, which asks whether the variable has been assigned, not what it holds, and computes once. - Row 8:
&&=is the mirror image; it assigns only when the variable is truthy, sonilstaysnil. - Row 9 is the grouping idiom:
(h[:k] ||= [])creates the array on the first access and returns the existing one after that, so<<appends to the same array both times. - Row 10:
nil.to_aandArray(nil)are both[], which is whyArray(x).eachis a nil-safe loop;Array([1])leaves an array alone andArray(1..2)expands a range. - Row 11:
h[:z]isnilfor a missing key with no error,fetchtakes a default, andfetchwith a block computes one from the key. - Row 12:
p2 = nil or 5leavesp2asnilbecauseorbinds below=, so it parsed as(p2 = nil) or 5. Use||in expressions andoronly for control flow.
Compared with Python¶
Python has no ||= and no &&=. The nearest idiom, x = x or 5, uses Python's or, which — like Ruby's — returns an operand rather than a boolean, but Python's falsy set is larger: None, False, 0, 0.0, "", [], {} and every empty container. So rows 3 and 4 flip: 0 and "" are replaced, which is the classic Python bug when a count of zero or an empty string is a legitimate value. The fix is x if x is not None else 5, or a walrus in an expression. Memoization goes through functools.cache on a function or functools.cached_property on a method, and neither has Ruby's hole: they store the result, False included, rather than testing it for truth (rows 6 and 7). dict.setdefault is (h[:k] ||= []), dict.get is fetch with a default, and there is no list(None) — it raises — so the nil-safe loop idiom is x or []. Row 12 is the same expression twice because Python's or has ordinary precedence.
Verified output of or_equals_and_nil_guards_py.py — regenerated by tools/run_examples.py, never hand-typed.
The `x = x or v` idiom, value by value
1. x = None; x = x or 5 5
2. x = False; x = x or 5 (False is replaced) 5
3. x = 0; x = x or 5 (0 is falsy: replaced!) 5
4. x = ""; x = x or 5 ("" is falsy: replaced!) 5
Memoization with functools.cache
5. @functools.cache compute(), called 3 times: ran 1 time(s)
6. @functools.cache of a False result, 3 calls: ran 1 time(s) <- no trap: it caches the value
7. @cached_property of a False result, 3 reads: ran 1 time(s)
The cousins
8. a = 1; a = a and a + 1 / b = None; b = b and b + 1 2 / None
9. h.setdefault("k", []).append(1), then 2 {'k': [1, 2]}
10. list(None) / (None or []) / list([1]) / list(range(1, 3)) TypeError / [] / [1] / [1, 2]
11. h["z"] / h.get("z", 0) / h.get("z") or "no z" KeyError / 0 / 'no z'
12. p1 = None or 5 / p2 = None or 5 5 / 5 (or has ordinary precedence)
Try it¶
- Write a
Settingsclass whoseverbosereader memoizes a computed boolean that happens to befalse. Implement it twice — once with@verbose ||= computeand once with adefined?(@verbose)guard — count how many times the computation runs over three reads, and print both counts. - Take a nested lookup such as
h[:a] && h[:a][:b] && h[:a][:b][:c]and rewrite it three ways: withdig, withfetchchains that raise on a missing key, and with&.. Print what each returns for a hash where:bis missing, and for one where:bisnil.
Solution to 1
or_equals_and_nil_guards_kata_rb.rb in full — pasted here by tools/run_examples.py from the file CI runs.
# Kata: memoize a computed boolean correctly.
# `@verbose ||= compute` recomputes whenever the cached value is false, because
# `||=` cannot tell "false" from "never computed". Guard on `defined?` instead.
class Settings
attr_reader :runs
def initialize
@runs = 0
end
def verbose_with_or_equals
@verbose_a ||= compute_verbose
end
def verbose_with_defined
return @verbose_b if defined?(@verbose_b)
@verbose_b = compute_verbose
end
private
def compute_verbose
@runs += 1
false # the setting is off, and that answer should be remembered
end
end
s = Settings.new
3.times { s.verbose_with_or_equals }
puts "||= version, three reads: computed #{s.runs} time(s), value #{s.verbose_with_or_equals.inspect}"
s = Settings.new
3.times { s.verbose_with_defined }
puts "defined? version, three reads: computed #{s.runs} time(s), value #{s.verbose_with_defined.inspect}"
Verified output of or_equals_and_nil_guards_kata_rb.rb — regenerated by tools/run_examples.py, never hand-typed.
See also¶
and/orbind below=— why row 12'sp2 = nil or 5leftp2asnil- nil, false and truthiness — the two false values that
||=tests for - Hashes and default values —
Hash.new { |h, k| h[k] = [] }does row 9's job for every key at once - Twenty-three Ruby traps for a Python programmer —
0and""are truthy, as one line among twenty-three - Twenty-three Python traps for a Ruby programmer —
[]and0are falsy, the row that makesx = x or 5a bug &.skips the call when the receiver is nil —receiver&.method(args)returnsnilwithout calling anything