skip to content

Representation Exposure & Leaky State

How internals escape even through private fields — returning a live collection or storing a caller's mutable object — and how defensive copies stop it. Asked because it is a subtle, real bug class.

on this pageshow

questions

3

A constructor or setter accepts a mutable object from the caller and stores it. In some languages the caller is left with no usable handle on what it passed; in others it keeps one, and in at least one popular language a copy is made that still shares storage. Explain the mechanisms and where the trap is.

level: middleimportance: should knowfreq 40%

answer

  1. exposure on ingress, not just egress
  2. move / value copy / shallow header copy / share
  3. Go: slice header copied, array shared
  4. Swift COW stops at class references
  5. spine copy leaves elements shared

basics

~20 s

Rust moves ownership, so the caller's binding is unusable afterwards. Swift structs copy by value. Go copies a struct but its slice and map fields still point at the same backing storage. Java, C#, Python and JavaScript share by reference unless you copy by hand.

solid answer

~50 s

That storing a caller's mutable object aliases it is the shared premise; the mechanisms differ. - **Rust** moves a non-`Copy` value into the constructor: the caller's binding is statically dead, so no alias can exist. Cost - the caller must clone if it wanted to keep using it, and that cost is now explicit. - **Swift** structs and arrays are values; assignment and parameter passing copy (with copy-on-write, so it is cheap). But a `class` reference held inside the struct still aliases, so value semantics stop at the first reference type. - **Go** is the trap: copying a struct copies the *slice header*, not the backing array, so `b := a; b.Items[0] = "y"` is visible through `a`. Same for map and pointer fields. - **Java, C#, Python, JavaScript** share by default; the manual defensive copy (`new ArrayList<>(other)`, `list(x)`) copies the spine only, leaving mutable elements shared.

code

go · 6 lines
go
type Cart struct{ Items []string }

a := Cart{Items: []string{"x"}}
b := a          // struct copy: header copied, array shared
b.Items[0] = "y"
// a.Items[0] == "y"  -- the mutation is visible through a

go deeper

for a junior

Know that storing a caller's object stores a shared reference in most mainstream languages, and that a copy in the constructor is the standard fix.

for a middle

Distinguish a struct copy from a deep copy and demonstrate the Go slice-header case; explain why a spine-only copy leaves mutable elements exposed.

for a senior

Argue for immutable parameter types over per-entry-point copying, and state each language's cost - explicit clones in Rust, copy-on-write plus the class-reference hole in Swift.

for a principal

Turn it into an API contract rule for the codebase - which boundaries take ownership, which borrow - and make the contract visible in signatures or documentation rather than in reviewer memory.

## Exposure has two directions Most discussions of representation exposure look at the way out - an accessor handing back internal state. The way *in* is the mirror image and is missed more often: a constructor or setter takes a mutable object from a caller, stores the reference, and the caller keeps its own reference. Nothing looks wrong; the object simply has a co-owner it never agreed to. Whether that is even possible depends on the language's model of assignment, and the four models in wide use give four different answers. ## Model 1 - ownership transfer (Rust) Passing a non-`Copy` value moves it. After `Basket::new(items)`, the caller's `items` binding is no longer usable; the compiler rejects any further read. Aliasing is not defended against, it is *unrepresentable*. If the caller genuinely wanted to keep a copy it writes `items.clone()`, which makes the cost visible at the call site rather than hiding it in the callee. The design cost is that every such handover is a decision the programmer must make explicitly. ## Model 2 - value semantics (Swift, C++ by value) In Swift, structs, arrays, dictionaries and strings are values: assigning or passing them copies. Copy-on-write keeps this cheap - the copy is a retain until someone writes. So `var b = a` followed by `b.items[0] = "y"` leaves `a` untouched. C++ by-value parameters behave similarly through the copy constructor, with `std::move` as the opt-in transfer and `shared_ptr` as the opt-in sharing. The boundary matters: Swift value semantics stop at the first *reference* type. A struct holding a `class` instance copies the reference, and both copies see the same object. "It's a struct, so it's safe" is only true transitively when the whole graph is values. ## Model 3 - shallow struct copy over shared storage (Go) Go copies structs on assignment and on pass-by-value, which reads like Swift but is not. A slice is a three-word header - pointer, length, capacity - so copying the struct copies the header and both copies point at the *same backing array*. Writing through one index is visible through the other. Maps and channels are reference types outright, so a copied struct shares the same map. The trap is that the copy is real, visible in the code, and insufficient; the fix is an explicit deep copy (`append([]T(nil), s...)`, or `maps.Clone`). ## Model 4 - reference by default (Java, C#, Python, JavaScript) Here nothing happens automatically. The mitigation is the defensive copy in the constructor, and it has its own depth limit: `new ArrayList<>(other)` and Python's `list(x)` copy the spine and share every element. If the elements are mutable, you have moved the exposure one level down rather than removing it. Java's `Date`-style mutable value objects were the classic instance of this; the language answered it by adding immutable replacements (`java.time`, then records), which is the deeper lesson - the durable fix is an immutable parameter type, not a copy at every entry point. ## The design rule that follows The question "is this copy deep enough?" is decided by the language's ownership model, not by whether you remembered to write a copy. Three practical consequences: 1. **Prefer immutable parameter types.** If the type the caller hands you cannot change, aliasing is harmless and free. This is why Rust's `String`, Swift's value types, Java's records and Kotlin's data classes with `val` all reduce the surface. 2. **Copy at the boundary, once.** A defensive copy in a constructor is worth more than one in every method, because the constructor is the only place the alias enters. 3. **Say what you did.** An API that stores the caller's object and one that copies it are behaviourally different contracts; document which. Go's standard library is explicit about this - many types say the caller must not modify the slice after passing it. ## In interview terms The strong answer names at least two languages that disagree and says what each pays: Rust makes the alias impossible at the cost of forcing an explicit clone; Swift makes it impossible for value graphs at the cost of copy-on-write machinery and a hard stop at class references; Go's automatic copy is shallow and therefore the most dangerous of the three, because it looks like Swift's.

  • A Go API accepts a []byte and keeps it. What must its documentation say, and what would the equivalent Rust signature communicate instead?
    The Go doc must state whether the callee retains the slice, because the caller can still write through its own copy of the header into the same backing array; the standard library says things like 'the caller must not modify b afterwards'. Rust encodes the same fact in the type: taking Vec<u8> by value moves it and the caller cannot use it again, while taking &[u8] promises the callee will not retain it beyond the call. One language documents the contract, the other compiles it.
  • Your constructor copies the incoming list, yet callers still corrupt your object's state. What is the most likely explanation?
    The copy is shallow: it duplicated the list structure but both lists hold the same element objects, and those elements are mutable. The caller mutates an element and the change is visible inside your object. The durable fix is to make the element type immutable - a record, a data class with read-only fields, a frozen value - rather than to deepen the copy at every entry point.

saying these in an interview costs you the question

  • Assuming a Go struct copy isolates the copy - slice, map and pointer fields still alias.
  • Saying 'Swift is value semantics so nothing aliases' without excepting class references held inside structs.
  • Treating new ArrayList<>(other) or list(x) as a deep copy.
  • Copying defensively in every method instead of once at the boundary where the reference enters.
  • Claiming Rust 'defends against' aliasing here; it moves ownership so the second handle does not exist.

context

open as a page

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?

level: seniorimportance: should knowfreq 45%

basics

~10 s

Rust 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.

open as a page

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%

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.

open as a page