An object stores a mutable collection and exposes it through an accessor. Across the languages you know, what mechanisms exist to stop a caller from mutating that internal state through the returned reference, and what does each mechanism cost?
answer
- alias = second lever on one invariant
- borrow / snapshot / view / nothing
- view is live, copy is stale
- freeze is shallow and silent
- best fix: don't hand out the collection
basics
~10 sRust forbids mutation through a shared borrow at compile time. Java's List.copyOf snapshots, while Collections.unmodifiableList is a live view. C#'s ReadOnlyCollection wraps a still-mutable list. Python and JavaScript enforce nothing; Object.freeze is shallow.
solid answer
~50 sHanding back the field itself gives the caller a second lever on state the object governs — that part is shared background. What differs is the enforcement family. - **Statically prevented aliasing — Rust.** An accessor returning `&Vec<T>` cannot be written through, and the compiler also blocks the owner from mutating while the borrow is alive. Cost: lifetimes appear in the signature and no caller may keep a long-lived handle. - **Snapshot on egress — Java `List.copyOf`, C# `ImmutableArray.CreateRange`, Python `tuple(...)`.** Caller and owner diverge afterwards; cost is O(n) per call and a stale view. - **Wrapper view — Java `Collections.unmodifiableList`, C# `ReadOnlyCollection<T>`, Python `MappingProxyType`.** O(1), but *live*: the caller observes the owner's later writes, and in C# an `IReadOnlyList<T>` can be downcast back to `List<T>`, which Java's wrapper at least denies. - **Nothing — Python lists, JavaScript arrays.** `Object.freeze` is opt-in, one level deep, and silent outside strict mode.
code
rust · 7 linesimpl Basket {
fn items(&self) -> &Vec<String> { &self.items }
}
let b = Basket::new();
b.items().push("x".to_string());
// error[E0596]: cannot borrow data behind a `&` reference as mutablego deeper
Be able to say what an alias is and that returning the internal collection lets the caller change the object's state without asking. Name one concrete protection from a language you use.
Distinguish a snapshot from a live read-only view and state which one a given API in your language returns; know that Object.freeze in JavaScript is one level deep.
Weigh cost per call against staleness and thread-safety, and name at least two languages that resolve it differently. Recognise that the strongest fix is exposing operations rather than the collection.
Set a house rule across the codebase - which layer snapshots, where immutable collection types are mandatory - and justify it by the enforcement each language actually provides rather than by convention.
## What representation exposure is An object promises to keep some invariant over its state: the basket total matches its lines, the roster is sorted, the set has no duplicates. If an accessor returns the *same* collection object the class stores, the caller now holds a second reference to that state — an *alias* — and can change it without the owner ever running a line of code. The invariant is no longer owned by anyone. This is representation exposure: the internal representation escaped, and with it the ability to reason locally about the class. The interesting part is not the diagnosis, which is the same everywhere. It is that languages disagree profoundly about what tools they give you, and each tool buys a different guarantee. ## Family 1 — the compiler prevents the alias (Rust) Rust's accessor returns `&Vec<T>`, a shared borrow. Two rules apply at compile time: you cannot mutate through a shared borrow, and while any shared borrow is alive the owner cannot take a mutable borrow either. So the escape is impossible and the *owner* is also constrained. No copy, no wrapper, no runtime check — the guarantee costs zero cycles. What it costs instead is expressiveness. Lifetimes surface in the API, callers cannot stash the reference in a long-lived structure, and designs that want many parties holding handles must move to `Rc<RefCell<T>>`, which converts the compile-time error into a runtime panic. Rust does not remove the tradeoff; it moves the price from throughput to signature complexity. ## Family 2 — snapshot on the way out Java's `List.copyOf`, C#'s `ImmutableArray.CreateRange`, Python's `tuple(seq)` all produce a value the caller may hold forever. The caller sees the state as of call time and nothing afterwards. This is the right default when the caller may outlive the call, publish the result to another thread, or store it: no iterator invalidation, no `ConcurrentModificationException`, no torn read. The cost is O(n) per call — deadly on a hot accessor over a large collection — plus the semantic cost that the caller's data is now stale and it cannot tell. ## Family 3 — the wrapper view Java's `Collections.unmodifiableList`, C#'s `ReadOnlyCollection<T>`, Python's `types.MappingProxyType` all wrap the live structure in O(1) and reject mutating calls. Three separate hazards follow from *live*: 1. The caller observes the owner's later writes. Sometimes that is exactly what you want (a monitoring view); usually it is an unannounced coupling. 2. Iteration over the view while the owner mutates fails at runtime — Java throws `ConcurrentModificationException`, and the failure surfaces in the caller's stack trace, not the mutator's. 3. In C#, exposing `IReadOnlyList<T>` over a `List<T>` is defeated by a downcast; `ReadOnlyCollection<T>` blocks the downcast but still tracks the source, and only `ImmutableArray<T>`/`ImmutableList<T>` genuinely detach. Java's `unmodifiableList` wrapper cannot be cast back to a usable mutator, so the two ecosystems differ on the same-looking idiom. Note also *when* the rejection happens: in Rust it is a compile error; in Java, C# and Python it is an exception thrown at the caller's write, potentially in production, months later. ## Family 4 — no mechanism at all Python returns the list and that is that; the convention is to return `tuple(self._items)` or a `MappingProxyType`, both opt-in. JavaScript has `Object.freeze`, which is *shallow*, applies to own properties only, and in sloppy mode fails silently rather than throwing — a whole class of bugs where the write appeared to work. This is why the JavaScript ecosystem reaches for structural-sharing libraries instead of language features. ## How to choose Ask two questions. *Does the caller outlive the invariant?* If it stores, publishes, or crosses a thread boundary, snapshot. *Is the collection large and the call hot?* If yes, a view or an immutable type with structural sharing beats a per-call copy. And a third, cheaper answer often beats both: do not hand out the collection. Expose `size()`, `contains(x)`, `forEach(fn)` — the operations the caller actually needed — and the aliasing question never arises. The same reasoning applies on the way in: a constructor that stores a caller-supplied collection has the identical exposure with the arrow reversed.
- You return a read-only wrapper over the live collection and the caller iterates it on another thread. What can go wrong that a snapshot would have prevented?The wrapper forbids the caller's writes but not the owner's, so the owner can structurally modify the backing collection mid-iteration. In Java that surfaces as a ConcurrentModificationException raised inside the caller's loop; in C# it is an InvalidOperationException from the enumerator. A snapshot detaches the caller from further mutation entirely, so the iteration is safe at the price of being stale. The wrapper also gives no memory-visibility guarantee across threads, which the immutable snapshot does.
- Rust needs no copy and no wrapper to make this safe. What did it give up to get that?It gave up unrestricted aliasing. The borrow appears in the type signature with a lifetime, so callers cannot store the reference beyond the borrow's scope, and while a shared borrow lives the owner itself cannot mutate. Designs that genuinely need several long-lived handles have to opt into Rc<RefCell<T>>, which converts the compile-time guarantee into a runtime borrow panic. The tradeoff moved from CPU cost to API and design complexity, it did not disappear.
A snapshot is a photocopy of the ledger; a view is a window into the vault with the glass sealed. The photocopy goes out of date; through the window you still see every change the owner makes, and you can be looking when a page is torn out.
saying these in an interview costs you the question
- Believing Java's Collections.unmodifiableList produces an independent copy - it is a live view over the same backing list.
- Thinking JavaScript's Object.freeze is recursive, or that it throws in non-strict code.
- Exposing IReadOnlyList<T> in C# and assuming the caller cannot downcast it back to List<T>.
- Claiming Rust's guarantee is free of tradeoffs, without mentioning lifetimes in the signature or the Rc/RefCell escape.
- Calling a shallow copy of the container a defensive copy when its elements are mutable and shared.