Skip to content

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. nil and false are replaced; 0 and "" 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 @flag and computes again — three calls, three computations. Row 7 guards on defined?(@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, so nil stays nil.
  • 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_a and Array(nil) are both [], which is why Array(x).each is a nil-safe loop; Array([1]) leaves an array alone and Array(1..2) expands a range.
  • Row 11: h[:z] is nil for a missing key with no error, fetch takes a default, and fetch with a block computes one from the key.
  • Row 12: p2 = nil or 5 leaves p2 as nil because or binds below =, so it parsed as (p2 = nil) or 5. Use || in expressions and or only 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

  1. Write a Settings class whose verbose reader memoizes a computed boolean that happens to be false. Implement it twice — once with @verbose ||= compute and once with a defined?(@verbose) guard — count how many times the computation runs over three reads, and print both counts.
  2. Take a nested lookup such as h[:a] && h[:a][:b] && h[:a][:b][:c] and rewrite it three ways: with dig, with fetch chains that raise on a missing key, and with &.. Print what each returns for a hash where :b is missing, and for one where :b is nil.
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.

||= version, three reads: computed 3 time(s), value false
defined? version, three reads: computed 1 time(s), value false

See also