skip to content

In Ruby, what does `@rate ||= fetch_rate` actually do, and why does it call fetch_rate again when the result was nil or false?

level: middleimportance: must knowfreq 66%

answer

  1. expands like a || a = b
  2. only nil and false trigger it
  3. no assignment when already truthy
  4. Hash.new(0) and ||= skip the store
  5. &&= assigns only when truthy

basics

~20 s

a ||= b behaves like a || a = b: it assigns only when a is nil or false. So memoizing a method that returns nil or false never caches, and the work reruns on every call; use a presence check instead.

solid answer

~50 s

`@rate ||= fetch_rate` reads `@rate`; if it is truthy, that value is the result and nothing is assigned. Only when it is `nil` or `false` does Ruby call `fetch_rate` and assign it. That makes it a memoization idiom, with one trap: if `fetch_rate` legitimately returns `nil` or `false`, the ivar is falsy again on the next call, so the expensive work repeats every time. Guard with `return @rate if defined?(@rate)` before assigning, or use a Hash cache with `fetch(key) { cache[key] = compute }`. The expansion is `a || a = b`, not `a = a || b`, which matters for setters and hash elements: `h = Hash.new(0); h[:tips] ||= 5` stores nothing, because `h[:tips]` already returns the truthy default `0`. `a &&= b` is the mirror image: it assigns only when `a` is truthy, as in `name &&= name.strip`.

code

ruby · 24 lines
ruby
class TipCalculator
  def initialize
    @lookups = 0
  end

  def service_charge
    return @service_charge if defined?(@service_charge)

    @service_charge = lookup_charge
  end

  attr_reader :lookups

  private

  def lookup_charge
    @lookups += 1
    nil   # this restaurant adds no service charge
  end
end

calc = TipCalculator.new
3.times { calc.service_charge }
calc.lookups   # => 1; with @service_charge ||= lookup_charge it would be 3

go deeper

for a junior

Recall that x ||= y assigns y only when x is nil or false, and that it is the usual way to set a default or cache a value.

for a middle

Explain the a || a = b expansion and show why a Hash default or a setter behaves differently from a = a || b.

for a senior

Catch memoization of nil or false results in review, replace it with a defined? guard or a Hash cache, and remember that ||= is not atomic across threads.

for a principal

Agree a team pattern for caching possibly-falsy results so expensive calls are not silently repeated on every request.

`||=` is one of the most common Ruby idioms and one of the most commonly misdescribed. The interview wants three things: the precise expansion, the falsy-value trap, and the consequences for setters and hashes. ## What ||= expands to Ruby's documentation says `a ||= b` behaves **like `a || a = b`**, not like `a = a || b`. Step by step: 1. Evaluate `a` (for `@rate`, read the instance variable; for `h[:k]`, call `h.[](:k)`; for `obj.x`, call the reader). 2. If the value is truthy, that value is the result; **no assignment happens**. 3. If it is `nil` or `false`, evaluate `b` and assign it, calling `[]=` or the setter where relevant. The difference from `a = a || b` shows up whenever "assign the same value back" is not a no-op: - **Hash with a default.** `h = Hash.new(0)` returns `0` for missing keys. `h[:tips] ||= 5` reads `0`, which is truthy, and so never calls `[]=`: `h` stays `{}`. - **Setters.** `order.note ||= "none"` calls the `note=` setter only when the current note is falsy, so a setter with side effects (logging, dirty tracking) is not triggered on every line. - **Undefined locals.** `count ||= 0` works even when `count` was never assigned, because the parser sees an assignment to `count` and treats it as a local variable that starts as `nil`. ## The falsy-value memoization trap The idiom is widely used for lazy caching: ```ruby def exchange_rate @exchange_rate ||= fetch_exchange_rate end ``` It works as long as the computed value is truthy. If `fetch_exchange_rate` returns `nil` (say, the currency is not supported) or `false`, then `@exchange_rate` is still falsy on the next call, and the fetch runs again - every time. A slow HTTP call or database query that was supposed to run once now runs on every request, and nothing errors. The same happens with a boolean flag: `@eligible ||= expensive_check` recomputes whenever the answer is `false`. ## Caching falsy results correctly | Technique | Caches `nil`/`false`? | Notes | |---|---|---| | `@x \|\|= compute` | no | fine only when results are always truthy | | `return @x if defined?(@x)` then `@x = compute` | yes | `defined?(@x)` is truthy once the ivar has been assigned, even to `nil` | | `@cache.fetch(key) { @cache[key] = compute(key) }` | yes | `fetch` returns a stored `nil` without running the block | | `@cache.key?(key) ? @cache[key] : (@cache[key] = compute(key))` | yes | explicit presence test | Choosing among them is a matter of shape: a single value per object suits the `defined?` guard, per-argument results suit a Hash keyed by the argument. ## &&=, the mirror image `a &&= b` behaves like `a && a = b`: it assigns only when `a` is **truthy**. It is handy for normalising a value only if it is present: ```ruby name &&= name.strip # nil stays nil; " Ana " becomes "Ana" ``` ## Other details worth knowing - The value of the whole expression is the final value of `a`, so `x = (@rate ||= fetch_rate)` works. - `||=` combines with safe navigation: `order&.note ||= "none"` does nothing when `order` is `nil`. - `||=` is **not atomic**. Two threads can both see `nil` and both run the computation; when that matters, synchronise the initialisation. - Only `nil` and `false` count as falsy. `0`, `""` and `[]` are truthy, so `@count ||= 0` never resets a count of zero. ## A note on instance variables and warnings Reading an instance variable that was never assigned returns `nil` without raising, which is exactly why `@x ||= value` works on the first call: the read yields `nil`, the right-hand side runs, and the ivar is created. The same property makes the `defined?(@x)` guard reliable - it distinguishes "never assigned" from "assigned `nil`", which `||=` cannot. ## Summary 1. `a ||= b` means "if `a` is falsy, assign `b`", evaluated as `a || a = b`. 2. No assignment happens when `a` is already truthy - including hash defaults. 3. Memoizing a method that can return `nil` or `false` with `||=` silently recomputes; use `defined?` or a Hash cache. 4. `&&=` assigns only when the current value is truthy.

  • In Ruby, why does `count ||= 0` not raise NameError when count was never assigned?
    The parser sees an assignment to `count` in that statement, so it treats `count` as a local variable from that point, initialised to `nil`. Reading it yields `nil`, which is falsy, so `0` is assigned. A plain read of an unassigned name would instead be treated as a method call and raise `NameError`.
  • Is `@total ||= compute_total` thread-safe in Ruby?
    No. The read and the assignment are separate steps, so two threads can both see `nil` and both run `compute_total`; the second assignment wins. That is harmless for idempotent work but wrong for side effects such as charging a card, where initialisation needs a lock.
  • In Ruby, what does `a &&= b` do when a is nil?
    Nothing is assigned and `b` is not evaluated: `&&=` behaves like `a && a = b`, so a falsy `a` short-circuits and remains `nil`. It is useful for transforming a value only when present, such as `name &&= name.strip`.

saying these in an interview costs you the question

  • a ||= b is exactly a = a || b, so it always assigns.
  • @x ||= compute caches whatever compute returns, including nil.
  • 0 and empty strings are falsy, so @count ||= 0 resets a zero count.
  • h[:k] ||= v on Hash.new(0) stores v because the key is missing.
  • &&= assigns only when the variable is nil.