skip to content

A team assumes that marking an object read-only protects the whole structure hanging off it. But in C++ a `const` member function can still mutate through a pointer member, and in Swift a struct bound with `let` is deep through nested structs yet stops at the first `class` reference it holds. How deep does a read-only guarantee actually reach in the languages you know, and how would you design an API that hands back nested structures in a language where it does not reach?

level: principalimportance: nice to knowfreq 22%

answer

  1. reach measured in reference hops
  2. C++ const: through members, not pointees
  3. propagate_const exists because the default surprises
  4. Swift let is deep until the first class
  5. fix the leaf type, not the wrapper

basics

~20 s

Read-only depth varies by language. C++ const stops at a pointer's target; Swift value semantics stop at the first class reference; Kotlin's List and C# IReadOnlyList are compile-time views only. Rust's shared borrow is transitive; Java's wrappers are not. So make leaf types immutable.

solid answer

~50 s

The useful question is how far the guarantee reaches, not whether one exists. - **C++**: `const` is transitive through members but stops at pointees — inside a `const` method a `T*` member behaves as `T* const`, so pimpl state stays fully mutable. `std::experimental::propagate_const` exists to patch exactly that default. D deliberately took the other branch: its `const` is transitive by design. - **Swift**: `let` on a struct is deep through nested structs, then stops dead at the first `class` reference — the object behind it is still mutable. - **Kotlin / C#**: `List<T>` and `IReadOnlyList<T>` are static views; the referent may be a live mutable list and the elements are untouched. - **JavaScript**: no deep-freeze primitive, so the ecosystem answered with recursive deepFreeze helpers and Immer's auto-freezing proxies. Rust's `&T` is the transitive pole, Java's `List.copyOf` the shallow one. Where depth is missing, push immutability into the element types or return a purpose-built projection instead of the domain object.

code

text · 9 lines
text
handle --(hop 0)--> object --(hop 1)--> child --(hop 2)--> grandchild

Rust  &T                : protected at every hop (static)
D     const/immutable   : protected at every hop (language rule)
Swift let on a struct   : protected until the first class-typed field
C++   const method      : hop 0 and value members; pointee NOT protected
Kotlin List / C# IReadOnlyList : hop 0 shape only; referent and elements free
JS    Object.freeze     : hop 0 own properties only
Java  List.copyOf       : hop 0 spine only; elements free

go deeper

for a junior

Know one concrete instance: freezing or wrapping a container does not stop someone editing the objects inside it. Being able to state that clearly with one example is enough.

for a middle

Be able to name the depth of the mechanisms you use daily — Kotlin List is a read-only view, Object.freeze is one level, List.copyOf fixes the spine — and reach for immutable element types as the fix.

for a senior

Compare reaches across languages and explain why: C++ const stopping at pointees and propagate_const, Swift's value semantics ending at the first reference type, and the difference between a compile-time view and an actually immutable object.

for a principal

Turn it into an API contract question: decide where immutability lives in the type system, prefer immutable leaves and projection types over runtime deep copies, and write contracts that state the guarantee's reach rather than saying "unmodifiable" and stopping.

## The question is reach, not existence Every read-only mechanism answers "can you change this?" for some set of memory. The interesting design axis is how large that set is. A **shallow** guarantee covers one object's own slots. A **transitive** (deep) guarantee covers everything reachable from that object by following references. Most "but I made it immutable" bugs live in the gap, because the syntax that produces a shallow guarantee looks exactly like the syntax people imagine produces a deep one. ## C++: transitive through members, stops at pointees Inside a `const` member function every data member is treated as const. A member of type `std::vector<T>` really is protected; a member of type `T` really is protected. But a member of type `T*` becomes `T* const` — the *pointer* is const, the *pointee* is not. So a `const` method may freely mutate everything behind its pointers. That is not an edge case: the pimpl idiom puts an entire class's state behind exactly one such pointer, which means `const` says nothing at all about the class's real state. `std::experimental::propagate_const` is a wrapper type whose whole purpose is to push constness through to the pointee; a standards body shipping a fix-up wrapper is the clearest possible admission that the default surprises people. `mutable` is the other escape, but that one is at least written down at the declaration. D is the instructive contrast. Its `const` and `immutable` are **transitive by language rule** — you cannot reach a mutable object through a const reference — and the D designers describe that as a deliberate correction of C++. Same keyword, opposite reach. ## Swift: deep through values, stops at the first reference type Swift structs are values, and values contain values, so `let config = Config(...)` really does freeze the whole nested struct tree: you cannot mutate any field at any depth, and copies are independent. The guarantee ends the instant the graph contains a `class`. A `let` struct holding a `var` class-typed property means you cannot *reassign* that property, but you can mutate the referenced object's properties all day, and two "copies" of the struct share that object. This is the practically important rule of thumb in Swift codebases: value semantics are only as deep as the last struct in the chain. ## Kotlin and C#: read-only is a static view, not a depth claim Kotlin's `List<T>` and C#'s `IReadOnlyList<T>` are interfaces that omit mutators. They constrain *what this reference can do*, not what the referent is: the same backing mutable list can be held elsewhere and changed under the caller, and nothing whatsoever is said about the elements. C#'s `readonly` field has the same shape one level up — it prevents reassigning the field, not mutating the object it points to. Even genuinely persistent collections like `ImmutableArray<T>` are immutable in exactly the shallow sense: the sequence is fixed, the elements are whatever they were. ## JavaScript: the ecosystem answered where the language did not `Object.freeze` covers own properties only. There is no deep-freeze primitive, so every large codebase either writes a recursive `deepFreeze` helper or adopts a structural-sharing library — Immer being the common one, which uses proxies for copy-on-write and freezes its produced results. That the answer arrived as libraries rather than as syntax is itself the point: depth was left to userland. ## The poles Rust sits at one end: immutability is a property of the *access path*, so reading a field through `&T` yields `&Field`, and the whole reachable closure is read-only, checked statically at no runtime cost — with `Cell`/`RefCell`/`Mutex` as named, type-visible opt-outs. Java sits at the other: `List.copyOf` and `Collections.unmodifiableList` fix the spine, and `list.get(0).setStatus(...)` still compiles. ## Why the divergence exists Deep guarantees need either whole-graph reasoning at compile time (Rust's borrow checker, D's type rule) or a runtime walk of the graph (deepFreeze). C++ chose neither, for the same reason it does most things: constness is a property of a declaration, and a pointer's declaration is not its target's declaration. Swift chose deep-for-values because values are copied anyway. Kotlin and C# chose interface subsetting because it costs nothing and composes with existing collections. ## Designing an API where depth is absent 1. **Make the leaf types immutable rather than wrapping the container.** A collection of immutable elements needs no wrapper for element safety; a wrapper over mutable elements is theatre. This is why records, `val`-only data classes, `readonly record struct` and Swift structs all arrived within a few years of each other. 2. **Return a projection, not the domain object.** If the caller needs three fields, hand back a small purpose-built read-only type. The depth question disappears because the returned graph has no mutable nodes. 3. **State the reach in the contract.** "Returns an unmodifiable list" is an incomplete sentence; the honest version says the list cannot be structurally modified and the elements are shared. 4. **Do not model depth by recursing at runtime.** Deep-freezing or deep-copying a large graph per call turns a design problem into a throughput problem, and still misses cycles, lazily materialised slots, and anything reachable through a closure. ## The verdict A wrapper or a `const` is a statement about one node. The element type is what actually protects the data. Ask, of any read-only claim, "how many reference hops does it survive?" — for most languages the answer is zero or one.

  • Why does C++ ship std::experimental::propagate_const at all?
    Because `const` is transitive through members but not through pointer members: inside a `const` member function a `T*` member behaves as `T* const`, so the pointed-to object stays mutable. That makes `const` silently uninformative for any class built on the pimpl idiom or on pointer-linked structures. `propagate_const` is a wrapper that pushes constness to the pointee, restoring the reach most people already assumed `const` had.
  • In Swift, how do you tell whether a `let` binding actually freezes the whole structure?
    Walk the type graph and look for the first `class`. As long as every type along a path is a struct or enum, the `let` is deep — nested value fields cannot be mutated at any level and copies are independent. The moment a path reaches a reference type, the binding only prevents rebinding that property; the referenced object's own `var` properties remain mutable and are shared between copies. Teams that care enforce this with value-only models or by making the class's state immutable.
  • Given a language with no transitive guarantee, what is the cheapest design that gives callers a genuinely safe nested structure?
    Return a purpose-built projection: a small read-only type carrying exactly the fields the caller needs, built from immutable leaves. It removes the depth question entirely because the returned graph contains no mutable nodes, it is O(fields) rather than O(graph) like a deep copy, and it doubles as a stable contract that does not leak the domain model. Deep-copying or deep-freezing on every call is the expensive alternative and still misses cycles and lazily populated slots.

Sealing an envelope protects the envelope. If the letter inside is the address of a warehouse, anyone who reads it can still rearrange the warehouse. Transitivity is a rule that travels with the address, not a seal on the envelope.

saying these in an interview costs you the question

  • Saying that wrapping or freezing a container protects its contents — every mainstream shallow mechanism guards one node, not the graph.
  • Claiming a C++ `const` member function cannot change the object's state; it can change everything behind a pointer member, which for pimpl classes is all of the state.
  • Treating Swift value semantics as automatically deep, without noticing that the guarantee stops at the first `class` reference in the type graph.
  • Calling Kotlin's `List<T>` or C#'s `IReadOnlyList<T>` immutable; they are read-only *views* whose referent may be a live mutable list, and the elements are never covered.
  • Proposing a recursive deep-copy or deep-freeze on every accessor as the general fix, ignoring cost, cycles, and slots that are not enumerable.

context