skip to content

A Ruby Thermostat exposes attr_reader :schedule over an internal Array; why can callers still corrupt it, and how do you stop them?

level: seniorimportance: should knowfreq 38%

answer

  1. reader-only is not read-only
  2. same object, not a copy
  3. << bypasses the class's checks
  4. return dup, or dup.freeze
  5. expose operations, not the collection

basics

~20 s

attr_reader returns the stored Array itself, so a caller's schedule << entry mutates the Thermostat's own state. Return a copy from a hand-written reader, freeze what you hand out, or expose narrower methods instead of the collection.

solid answer

~40 s

`attr_reader :schedule` only means outsiders cannot reassign `@schedule`; the reader still returns the very Array the object holds. A caller doing `thermostat.schedule << bad_entry` or `schedule.clear` changes the thermostat's internal state and skips every check the class applies when it adds entries. Fixes, from lightest to strongest: a hand-written reader that returns `@schedule.dup`, so callers mutate a throwaway copy; returning `@schedule.dup.freeze`, so a mutating caller gets `FrozenError` instead of a silent no-op; or not exposing the collection at all and offering methods such as `add_entry`, `each_entry` and `entry_count`. A copy is shallow, so mutable elements inside it are still shared; immutable entries close that gap. Copies cost an allocation per call, which matters only on hot paths.

code

ruby · 16 lines
ruby
class Thermostat
  def initialize = @schedule = []

  def add_entry(hour, degrees)
    raise ArgumentError, "bad hour" unless (0..23).cover?(hour)
    @schedule << [hour, degrees].freeze
  end

  def schedule = @schedule.dup.freeze
  def each_entry(&) = @schedule.each(&)
end

t = Thermostat.new
t.add_entry(7, 21)
t.schedule << [99, -40]   # FrozenError: can't modify frozen Array
t.schedule.size           # => 1

go deeper

for a junior

Recall that a reader returns the same object the instance holds, so a mutable Array or String can still be changed by the caller.

for a middle

Explain the difference between rebinding the variable and mutating the object, and what dup, dup.freeze and a hand-written reader each change.

for a senior

Show how you would design the exposure: copies or frozen snapshots where callers need data, operations where the collection is an internal detail, and immutable entries so shallow copies are enough.

for a principal

Weigh defensive copying, immutable internal state and narrow interfaces as a team policy for domain objects, trading allocation cost and API size against invariants that cannot be broken from outside.

## Reader-only is not read-only A **reader** in Ruby returns a reference to the object stored in the instance variable. Ruby passes and returns references by value: the caller receives a reference to the same Array the `Thermostat` holds, not a snapshot of it. ```ruby class Thermostat attr_reader :schedule def initialize = @schedule = [] def add_entry(hour, degrees) raise ArgumentError, "bad hour" unless (0..23).cover?(hour) @schedule << [hour, degrees] end end t = Thermostat.new t.schedule << [99, -40] # skips add_entry's check t.schedule.clear # wipes the thermostat's state ``` What `attr_reader` without a writer does guarantee: - Outside code cannot rebind `@schedule` to a different object, because there is no `schedule=`. What it does not guarantee: - That the object `@schedule` refers to stays unchanged. Any caller holding the reference can call mutating methods on it. ## Options, lightest first | Approach | What the caller gets | What happens on `<<` | |---|---|---| | `attr_reader :schedule` | the internal Array | internal state changes | | `def schedule = @schedule.dup` | a new Array with the same elements | the copy changes, silently | | `def schedule = @schedule.dup.freeze` | a frozen copy | `FrozenError: can't modify frozen Array` | | no reader; `each_entry`, `entry_count` | only the operations you chose | no collection to mutate | Choosing among them: 1. **Return a copy** when callers legitimately want an Array to sort, filter or pass on, and it is fine for them to change their own copy. 2. **Return a frozen copy** when a caller that tries to mutate the result is almost certainly confused and should fail loudly rather than lose its change silently. 3. **Expose operations** when the collection is an implementation detail. `add_entry` keeps validation in one place, and iteration methods let callers read without ever holding the Array. ## Shallow copies and nested objects `dup` copies the outer Array only. Its elements are the same objects the thermostat holds, so if an entry is itself mutable, a caller can still change it through the copy: - `t.schedule.first[1] = -40` edits the shared entry inside the thermostat when entries are mutable Arrays. - Storing entries as immutable values, for example frozen Arrays or value objects, removes that path. - Freezing the internal Array in `initialize` and replacing it with a new frozen Array on each change gives callers a stable snapshot without copying on every read. ## Strings count too The same applies to any mutable object behind a reader. `attr_reader :label` returns the stored String; a caller running `t.label << " (off)"` changes the thermostat's label in place. Returning a frozen or duplicated String, or storing a frozen one to begin with, prevents it. ## Where it shows up in practice - **Configuration objects** that expose a Hash of settings, where one caller adds a key and every other user of the object sees it. - **Memoized collections** returned from a reader and then sorted in place with `sort!` by a caller, reordering the cached data for everyone. - **Test fixtures** built once and shared, where one example's `<<` leaks into the next example's expectations. In each case the bug appears far from the line that caused it, because the mutation happened through an innocent-looking reader call. ## Costs to weigh - A copy per call allocates, which matters in hot loops; a frozen internal collection replaced on change reads for free. - Frozen return values change the contract: callers that used to append to the result now get `FrozenError`, which is the point but still a breaking change for them. - Methods that expose operations make the class larger but keep its invariants in one place.

  • In Ruby, why is returning @schedule.freeze from the reader worse than returning @schedule.dup.freeze?
    `@schedule.freeze` freezes the thermostat's own Array and returns it, so the next `add_entry` inside the class raises `FrozenError` too. Freezing a duplicate protects callers without taking away the object's ability to change its own state, unless the design deliberately replaces the internal Array on every change.
  • In Ruby, does returning @schedule.dup protect entries that are themselves mutable Arrays?
    No. `dup` copies only the outer Array; each element is the same object the thermostat holds. A caller can still change an entry in place through the copy. Storing frozen or immutable entries is what closes that path.

saying these in an interview costs you the question

  • attr_reader without attr_writer makes the attribute immutable
  • A reader returns a copy of the Array, so callers cannot affect it
  • @schedule.dup also copies every entry inside the Array
  • Freezing the internal Array in the reader has no effect on the class itself
  • Only Arrays and Hashes can leak through a reader, never Strings