A Ruby Thermostat exposes attr_reader :schedule over an internal Array; why can callers still corrupt it, and how do you stop them?
answer
- reader-only is not read-only
- same object, not a copy
- << bypasses the class's checks
- return dup, or dup.freeze
- expose operations, not the collection
basics
~20 sattr_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 linesclass 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 # => 1go deeper
Recall that a reader returns the same object the instance holds, so a mutable Array or String can still be changed by the caller.
Explain the difference between rebinding the variable and mutating the object, and what dup, dup.freeze and a hand-written reader each change.
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.
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