A value-type instance is mutated through a member call, the code compiles and runs with no error, and afterwards the value is unchanged. In C# this happens when the call target is a readonly field, an `in` parameter, or the result of a property that returns the struct by value. Explain the mechanism behind the vanishing write, what `readonly struct` (C# 7.2) and readonly members (C# 8) change, and how Swift, Rust, Go and C++ each answer the same 'mutating something that is really a temporary' question differently.
answer
- struct `this` is passed by reference — so readonly storage forces a copy
- readonly field / `in` param / by-value property getter = defensive copy
- `readonly struct` (C# 7.2) elides the copies: correctness + perf
- Swift and Rust reject; Go silently mutates a value receiver's copy
- C++ slicing = same sentence, different mechanism
basics
~20 sThe write landed on a copy the compiler inserted. C# copies a struct before invoking a non-readonly member on a readonly field, an in parameter, or a by-value property result, so the mutation hits a discarded temporary. Declaring the type a readonly struct removes both copy and trap.
solid answer
~50 sThe copy was inserted by the compiler, not by me. A C# struct method receives `this` by reference, so it *may* write the receiver. When the receiver is storage the compiler must protect — a `readonly` field, a `static readonly` field, an `in` parameter — it cannot prove the member is non-mutating, so it copies the struct to a temporary, calls the member there, and discards it. Same for `obj.Prop.Mutate()`, where the getter already returned a value. The call succeeds; nothing observable changes. **Fix:** `readonly struct` (C# 7.2) declares no member writes `this`, so the copies are elided — a correctness *and* a performance fix; C# 8 adds `readonly` on individual members. **Divergence:** Swift rejects it (`mutating` member on a `let` is a compile error). Rust rejects it (`cannot borrow as mutable`). Go silently mutates a value receiver's copy with no diagnostic. C++ gives the same shape as object slicing.
code
text · 13 linesstruct Counter { n; method Bump() { this.n = this.n + 1 } } // Bump writes `this`
holder.c // field declared read-only
holder.c.Bump() // => tmp := copy(holder.c); tmp.Bump(); discard tmp
// holder.c.n unchanged, no error
func f(in p) { p.Bump() } // `in` = reference the callee must not write through
// => same copy-call-discard
obj.Prop.Bump() // getter already returned a value; Bump mutates that value
array[i].Bump() // indexing yields real storage -> mutates in place
list[i].Bump() // indexer returns a value -> mutates a temporarygo deeper
Know the core recall: a value type gets copied a lot, and a mutation applied to a copy is lost. Be able to say that C# sometimes makes that copy for you when the target is declared readonly.
Explain the mechanism — struct members receive this by reference, so readonly storage or an in parameter forces a defensive copy — and name readonly struct as the fix. Be able to point at the three or four call sites where it happens.
Diagnose it from the symptom: a mutation that neither throws nor takes effect means a copy was inserted where you did not write one. Contrast Swift's and Rust's compile-time rejection with Go's silent copy, and know that readonly struct is simultaneously the correctness fix and the performance fix.
Frame it as a language-design tradeoff: value semantics plus mutability plus read-only storage forces the compiler to pick reject, copy, or allow, and each choice has a cost in diagnosability. Argue the policy — mutable value types are banned or wrapped at API boundaries in your codebase, and value types are immutable all the way down, which makes the question moot in every language.
## The symptom You call a method that changes a field, the call compiles and executes, and the field is unchanged. Nothing failed. The mutation succeeded — on an object nobody kept. This is the mirror image of the more familiar bug where a "copy" still aliases the original because memberwise copying stops at the first handle. There, a copy you asked for was shallower than you thought; here, a copy you never asked for was inserted on your behalf. ## Why a compiler would insert a copy at all In C#, an instance member of a struct receives `this` **by reference** — that is how `void Bump() { N++; }` can change the caller's storage. The compiler therefore cannot invoke a struct member on a storage location without granting that member write access to it. Now consider storage the compiler has promised not to change: - a `readonly` instance field (writable only in the constructor), - a `static readonly` field, - an `in` parameter, which is precisely "a reference the callee must not write through". Calling a member that *might* write `this` on such a location would break the promise. Rather than reject the call, C# preserves the promise by **defensive copy**: it copies the struct into a hidden temporary, passes a reference to the temporary, and throws the temporary away. The write happened; it happened somewhere you cannot reach. A related case needs no `readonly` at all. `obj.Prop.Bump()` where the property returns a struct by value: the getter already produced a temporary, and the mutation lands there. C# blocks the *obvious* form of this — `obj.Prop.Field = 5` and `list[i].Field = 5` are compile errors, because you cannot assign to something that is not a variable — but a method call on the same temporary is allowed. The language stops the beginner's version of the mistake and permits the subtle one. Note the asymmetry between containers here: `array[i].Bump()` mutates in place, because array indexing yields a real storage location, while `list[i].Bump()` goes through an indexer that returns a value, so it mutates a temporary. ## Removing the trap `readonly struct` (C# 7.2) is a declaration that **no member of this type writes `this`**. With that guarantee the compiler stops inserting defensive copies: it can pass `this` as a read-only reference everywhere. Two things improve at once — the vanishing-write bug becomes impossible, and the hidden per-call copies (which for a large struct in a hot loop are a real cost, and are exactly what `in` parameters were supposed to avoid) disappear. The price is that the type is genuinely immutable: any "change" must return a new instance. C# 8 added `readonly` on individual members for types that are not yet ready to go all the way, and `readonly record struct` gives you the immutable form with the equality boilerplate generated. ## The same question, four other answers Every language that has both value types and mutability must answer: *what happens when a mutating operation is applied to something that is read-only or is already a temporary?* There are only three answers — reject it, copy silently, or let the write through — and the major languages picked differently. - **Swift rejects.** Calling a `mutating` method on a `let` constant, on a `let` stored property, or on the result of a get-only computed property is a **compile error** ("cannot use mutating member on immutable value"). Swift makes mutability part of the member's declaration and then checks it at the call site, so the vanishing write is diagnosed rather than performed. - **Rust rejects.** `&mut self` methods require a mutable place; calling one through an immutable binding is "cannot borrow as mutable". The only way to reach a lost write is to mutate a temporary you were discarding anyway. - **Go lets it through, silently.** A method with a *value* receiver gets a copy; `c.Bump()` compiles and changes nothing, with no warning. `for _, v := range items { v.N++ }` mutates copies for the same reason. Go's only related diagnostic is that map elements are not addressable, so a pointer-receiver method cannot be called on `m["k"]` — the compiler blocks the case where it would need an address, and stays quiet about the case where copying is legal. - **C++ produces the same shape as slicing.** Passing a derived object by value into a base-typed parameter copies only the base sub-object; both the derived state and the dynamic type are gone, so a virtual call inside dispatches to the base version and mutations touch a local copy. "The object I mutated was not the object I meant" is the same sentence. ## The design conclusion The trap needs three ingredients: value semantics, mutable members, and a notion of read-only storage. Remove any one and it is gone. Since you rarely want to give up the first or the third, the standing advice in every one of these languages converges on removing the second: make value types immutable, and let operations return new instances. Silent copying is not the bug — it is the compiler keeping a promise you made and then contradicted.
- Is `readonly struct` a correctness fix or a performance fix?Both, and for the same reason. Because the compiler knows no member writes `this`, it can pass the receiver as a read-only reference instead of copying it, which removes the hidden per-call copy in hot paths. The same guarantee is what makes the vanishing write impossible, so you cannot get one benefit without the other.
- Why does `obj.Prop.Field = 5` fail to compile while `obj.Prop.Bump()` compiles and silently does nothing?Assignment requires a variable — a storage location — and a property getter returns a value, so C# diagnoses it (CS1612). A method call has no such requirement: invoking a member on a temporary is ordinary and legal, and the compiler has no way to know that this particular member's only effect is to write `this`. So the language catches the blunt form of the mistake and permits the subtle one.
- Swift rejects mutating a `let` struct at compile time. What does its type system have that C# lacks here?Swift marks mutation on the *member*: a method that writes `self` must be declared `mutating`, so the compiler knows at every call site whether the receiver needs write access. C# struct members are all potentially mutating unless the type or member is declared `readonly`, so before C# 7.2 the compiler had no way to distinguish them and had to assume the worst — hence the copy instead of a diagnostic.
You sign a form at a desk that will not let you write on the original, so the clerk quietly hands you a photocopy, watches you fill it in, and shreds it. Nothing errored; nothing was recorded.
saying these in an interview costs you the question
- "The call must have thrown or been optimized away." It ran normally; it just ran on a temporary the compiler created.
- "readonly on a field makes the struct immutable." It only forbids replacing the whole field; the compiler upholds that by copying before any member call, which is what loses the write.
- "`in` parameters are just a performance annotation." `in` is a read-only reference, and passing a mutable struct through it triggers defensive copies that can be slower than passing by value as well as changing behavior.
- "Go/Swift/Rust behave the same way, this is generic value-type behavior." Swift and Rust reject the call at compile time; Go silently mutates a value receiver's copy; only C# inserts the defensive copy.
- "Making the struct a `record struct` fixes it." `record struct` is mutable by default; you need `readonly record struct` (or `readonly struct`) for the guarantee.
- Confusing this with the aliasing bug — the copy that still shares a handle. That is the opposite failure: a copy you wrote that was too shallow, not a copy you never wrote.