skip to content

In Ruby, why does p a if a = lookup raise NameError when a is new, while x = 1 if false leaves x as nil?

level: middleimportance: nice to knowfreq 22%

answer

  1. parser, not runtime, decides
  2. left to right source order
  3. assignment seen means local
  4. body parsed before the test
  5. parenthesise assignment in a test

basics

~20 s

Ruby decides at parse time, in source order, whether a bare name is a local. In p a if a = lookup the body is parsed first, so a is a method call; x = 1 if false still declares x as nil.

solid answer

~50 s

Ruby's parser, not the running program, decides whether a bare identifier is a local variable or a method call: once it has *parsed* an assignment to a name, later occurrences are locals. A modifier is read left to right, so in `p a if a = lookup` the `a` in `p a` is parsed before the assignment in the test and becomes a method call; at runtime the test runs first and assigns the local, then `p a` calls a nonexistent method `a` and raises `NameError` (undefined local variable or method). Conversely, `x = 1 if false` never executes the assignment, but the parser has seen `x =`, so `x` is a local initialised to `nil`. Fix the first case with the block form `if (a = lookup) then p a end` or by assigning on its own line.

code

ruby · 12 lines
ruby
PERMITS = { "AB-123" => "zone A" }.freeze
def lookup(plate) = PERMITS[plate]

# p permit if permit = lookup("AB-123")
# => NameError: undefined local variable or method 'permit'

if (permit = lookup("AB-123"))
  p permit                      # => "zone A"
end

spare = lookup("ZZ-999") if false
p spare                         # => nil (declared by the parser)

go deeper

for a junior

Recall that a skipped assignment such as x = 1 if false still leaves x defined as nil, and that the body of a modifier cannot use a variable first assigned in its own test.

for a middle

Explain that the parser classifies locals in source order, independent of execution, and trace p a if a = lookup to its NameError step by step.

for a senior

Spot the assign-in-modifier-test pattern in review, explain why automatic modifier rewrites can break them, and use parenthesised assignment in conditions deliberately.

for a principal

Use this as an example of why autocorrecting style cops need exceptions: some block-to-modifier rewrites change semantics, so rollout of such cops needs care and test coverage.

## Locals are decided by the parser In Ruby, a bare identifier such as `a` could be a **local variable** or a **call to a method** named `a` with no arguments. The language cannot tell from the name alone, so the **parser** decides, using a simple rule documented in Ruby's language reference: once the parser has encountered an assignment to a name, later uses of that name in the same scope are local variables. Crucially: - the decision happens **while parsing**, reading the source left to right; - it does **not** depend on whether the assignment ever **executes**; - a local that was declared by the parser but never assigned at runtime reads as `nil`. The reference gives this exact example: ```ruby a = 0 if false # does not assign to a p local_variables # prints [:a] p a # prints nil ``` ## Case 1: the modifier body is parsed before its test A statement modifier is **written** body-first but **executed** test-first. Those two orders disagree, and the parser follows the written one: ```ruby p a if a = 0.zero? # NameError: undefined local variable or method 'a' ``` Step by step: 1. The parser reads `p a`. No assignment to `a` has been seen yet, so this `a` is recorded as a **method call**. 2. The parser reads the test `a = 0.zero?` and now marks `a` as a local — too late for the body. 3. At runtime the test runs first, assigning `true` to the local `a`. 4. The test is truthy, so `p a` runs — and calls the method `a`, which does not exist: `NameError`. The Ruby reference walks through this sequence and notes that the same is true for `unless`. The error message in Ruby 3.4 and later quotes the name with plain single quotes, `'a'`; earlier releases opened the quote with a backtick. The block form does not have this problem, because the test is **written** first: ```ruby if (a = lookup_permit(plate)) p a end ``` ## Case 2: an assignment that never runs still declares the local ```ruby x = 1 if false p x # => nil, not NameError ``` The parser saw `x = 1`, so `x` is a local from that point on. At runtime the modifier skips the assignment, and an unassigned local reads as `nil`. This is why `x` "exists" even though no line ever assigned to it. This also means a block `if`/`unless` and its modifier form are **not always interchangeable**. The Ruby reference says so explicitly — "they are not exact transformations of each other due to parse order" — and RuboCop's `Style/IfUnlessModifier` documents cases it deliberately leaves in block form for that reason, such as a block whose test inspects a variable that the body assigns. ## Assignment inside a condition Assigning in the test (`if a = lookup`) is legal and common for "fetch and check" code, but it is easy to mistake for `==`: | Code | What happens | |---|---| | `if a = 0` | Ruby warns `found '= literal' in conditional, should be ==` (the warning fires when the right side is a literal) | | `if a = lookup` | no warning; the value assigned is the test | | `if (a = lookup)` | same behaviour; the parentheses signal intent | RuboCop's `Lint/AssignmentInCondition` flags assignments in conditions; with its default `AllowSafeAssignment: true`, the parenthesised form is accepted as deliberate. ## How to avoid both traps - Never rely on a modifier's test to introduce a variable that its body uses; assign on the line before, or use the block form. - Remember that "declared" and "assigned" are different: a skipped assignment still creates a `nil` local. - Treat block-to-modifier rewrites (by hand or by autocorrect) as a change that can alter which names are locals. - Parenthesise intentional assignments in conditions. The interview-grade explanation fits in one sentence: Ruby decides local variables at parse time in source order, so a modifier's body cannot see a local first assigned in its own test, and an assignment that is parsed but skipped still leaves a `nil` local behind.

  • How do you rewrite `p a if a = lookup` so it works?
    Put the assignment where the parser meets it before the use: the block form `if (a = lookup) then p a end`, spread over lines in real code, or assign on its own line first and then write `p a if a`. In both, the parser records `a` as a local before it reads `p a`.
  • Why does Ruby warn on `if a = 0` but not on `if a = lookup`?
    The parser warns `found '= literal' in conditional, should be ==` only when the right-hand side is a literal, because assigning a constant value in a test is almost always a typo for `==`. Assigning a method's result is a common fetch-and-test idiom, so it is left alone; RuboCop's `Lint/AssignmentInCondition` still flags it unless you wrap it in parentheses.

saying these in an interview costs you the question

  • Ruby creates a local variable only when an assignment to it actually executes.
  • Because the test runs first, the body of p a if a = lookup can see a.
  • x = 1 if false; p x raises NameError because x was never assigned.
  • A modifier and the equivalent block if are always interchangeable.
  • Ruby warns about every assignment used as a condition.