skip to content

Inside a Ruby Thermostat method, why does target = 22 leave the target unchanged even though attr_accessor :target exists?

level: juniorimportance: must knowfreq 76%

answer

  1. the parser decides, not the runtime
  2. bare name = value is a local
  3. writers need an explicit receiver
  4. self.target = 22
  5. target += 1 hits nil

basics

~10 s

Ruby parses a bare target = 22 as assignment to a new local variable, not a call to target=. Writer methods always need a receiver, so inside the class write self.target = 22.

solid answer

~40 s

When the parser sees `name = value` with no receiver, it creates a local variable named `name`; it never considers a method called `name=`. So inside `Thermostat#boost`, `target = 22` sets a local that vanishes when the method returns, and `@target` is untouched. To call the writer, give it a receiver: `self.target = 22`. From that line on, a bare `target` in the same method also means the local, not the reader. `target += 1` is worse: it expands to `target = target + 1`, the parser has already made `target` a local, and it reads `nil`, raising `NoMethodError` for `+` on `nil`. Assigning `@target` directly works too, but it bypasses any validation a hand-written `target=` performs.

code

ruby · 15 lines
ruby
class Thermostat
  attr_reader :target

  def initialize = @target = 20

  def target=(degrees)
    raise ArgumentError, "out of range" unless (5..30).cover?(degrees)
    @target = degrees
  end

  def boost_wrong  = (target = 22)        # local only
  def boost_right  = (self.target = 22)   # runs the validating writer
  def boost_bypass = (@target = 99)       # skips validation
  def bump         = (target += 1)        # NoMethodError: undefined method '+' for nil
end

go deeper

for a junior

Recall the rule: inside the class, a writer needs self. in front, otherwise the assignment creates a local variable.

for a middle

Explain that the parser, not the runtime, decides local versus method, why target += 1 raises NoMethodError on nil, and how a local shadows the reader for the rest of the method.

for a senior

Show how you keep this out of production: tests asserting object state after each mutator, Lint/UselessAssignment in CI, and routing changes through validating writers rather than raw @target assignment.

for a principal

Frame the choice between writers and direct instance variable assignment as a team convention that decides where validation lives and how much of it internal code may bypass.

## The silent failure A `Thermostat` class has a target temperature and a method meant to raise it: ```ruby class Thermostat attr_accessor :target def initialize = @target = 20 def boost target = 22 # looks like a writer call; it is not end end t = Thermostat.new t.boost t.target # => 20 ``` Nothing raises and the thermostat keeps its old target. The only hint comes at parse time: under `ruby -w`, Ruby prints `warning: assigned but unused variable - target` when nothing in the method reads the local afterwards. ## Why the parser picks a local Ruby decides what a bare identifier means **while parsing**, before any code runs: 1. `name = value` with no receiver is always **local variable assignment**. The parser creates the local `name` for the rest of the scope. 2. A bare `name` that has not been assigned earlier in the scope is treated as a **method call** on `self`. 3. A bare `name` after an assignment to it is treated as the **local variable**. The Ruby documentation on assignment states the rule directly: when using method assignment you must always have a receiver; without one, Ruby assumes you are assigning to a local variable. So `target = 22` creates a local and `self.target = 22` calls `target=`. ## Three ways to write it, three outcomes | Statement inside `boost` | What it does | |---|---| | `target = 22` | creates a local `target`; `@target` unchanged | | `self.target = 22` | calls `target=`, running any logic in it | | `@target = 22` | assigns the variable directly, skipping `target=` | The third form is fine when the writer is a plain generated one. When `target=` is hand-written to validate or convert, assigning `@target` directly sidesteps that logic, so the class can put itself into a state its own writer would reject. ## The += variant `target += 1` is shorthand for `target = target + 1`. Because the statement contains an assignment to `target`, the parser makes `target` a local for the whole statement, and the right-hand side reads that local, which is still `nil`: - The result is `NoMethodError` with `undefined method '+' for nil`. - The message mentions `nil`, not the missing receiver, which is why this one confuses people. - `self.target += 1` works: it calls the reader, adds one, and calls the writer. ## Shadowing after the mistake Once a method contains `target = something`, every later bare `target` in that method is the local, not the reader. Code below the assignment that expected to read the object's state reads the local instead, so one wrong line can make the rest of a method use a different value than the object holds. ## Reading does not need self The asymmetry trips people up: the **reader** works without a receiver, the **writer** does not. - `target` alone, with no local of that name in scope, calls the reader on `self`. - `self.target` also calls the reader; it is legal, just noisier. - `target = value` alone is never a method call, whatever methods the class has. The reason is that an assignment without a receiver must be able to introduce a local variable; if Ruby looked for a `name=` method first, adding such a method to a class would silently change the meaning of every local variable of that name in its methods. ## A quick diagnostic When a value set inside a method does not stick: 1. Look for a bare `name = value` where a writer was intended. 2. Check whether a later bare `name` in the same method reads that local instead of the reader. 3. Check for `name += ...` or `name ||= ...` without `self.`, which fail or misbehave for the same reason. ## Catching it - **Tests** that check the object's state after calling the method expose it at once. - **Warnings:** `ruby -w` reports `assigned but unused variable - target` for a local that is never read. - **Linters** flag an assignment to a local that is never read: RuboCop's `Lint/UselessAssignment` reports `target = 22` when nothing reads `target` afterwards. - **Habit:** inside a class, write `self.name = value` whenever you mean the writer, and reserve bare `name = value` for genuine locals.

  • In Ruby, why does a bare target read the reader method in one method but the local variable in another?
    The parser decides per scope. If the method has assigned to `target` on an earlier line, even inside a branch that never ran, `target` is a local from there on and reads `nil` if unassigned. Otherwise a bare `target` is a method call on `self`, which reaches the reader.
  • In Ruby, when is assigning @target directly inside Thermostat preferable to self.target = ?
    In `initialize` or internal code where the value is already known to be valid and the writer does nothing extra, direct assignment is simple and clear. When `target=` validates, converts or triggers side effects, go through `self.target =` so every change passes the same checks.

It is like writing a new thermostat setting on a sticky note in your own hand instead of pressing the button on the thermostat: the note says 22, the room stays at 20, and the note goes in the bin when you leave.

saying these in an interview costs you the question

  • target = 22 inside the class calls the target= writer
  • Ruby raises an error when a local shadows a writer method
  • self. is only needed when the writer is public
  • target += 1 increments the attribute through the reader and writer
  • Assigning @target always runs the logic in target=