Structs, copies and boxing
Where a struct gets silently copied — into a box, out of a List, through a readonly field — and why default(T) skips your constructor.
After this lesson you can
- Name the places a struct is copied without an obvious assignment
- Explain what boxing is and what it costs
- Update a struct in place inside an array and know why a List cannot do the same
Lesson 1 showed that assigning a struct copies it. The surprises come from copies you did not write.
Boxing
A struct can be stored anywhere an object or an interface is
expected. To do that, the runtime boxes it: allocates a heap object
and copies the struct's bytes into it. From then on the box and the
original are separate values.
public interface ICounter{ void Increment(); int Value { get; }} public struct Counter : ICounter{ public int Value { get; private set; } public void Increment() => Value++;} public class Holder{ private readonly Counter _counter; public int Bump() { _counter.Increment(); _counter.Increment(); return _counter.Value; }} public static class Solution{ public static string Describe() { var c = new Counter(); ICounter boxed = c; // boxing copies the struct onto the heap boxed.Increment(); boxed.Increment(); var h = new Holder(); return $"local={c.Value} boxed={boxed.Value} readonlyField={h.Bump()}"; }}boxed.Increment() ran twice, on the box; the local c never moved
from 0. The Holder line is the subtler one: _counter is a
readonly field, so the compiler must not let a method mutate it. It
calls Increment on a defensive copy instead, every time, and the
field stays 0. No warning, no exception: the writes just vanish. A
readonly struct (all fields read-only) makes this impossible, which
is the main reason to declare small structs that way.
Boxing also costs an allocation per value. Non-generic collections
(ArrayList, Hashtable) box every int they store; List<int>
does not, which was much of the point of generics.
Where a struct really lives
public struct Stats{ public int Max; public Stats() { Max = int.MinValue; }} public static class Solution{ public static string Describe() { var made = new Stats(); var arr = new Stats[2]; var deflt = default(Stats); arr[0].Max = 7; // an array element is a variable: this writes in place var list = new List<Stats> { new Stats() }; var copy = list[0]; // the List indexer hands back a copy copy.Max = 7; return $"new()={made.Max} array={arr[1].Max} default={deflt.Max} arr[0]={arr[0].Max} list[0]={list[0].Max}"; }}Three facts in one run:
new Stats()runs the parameterless constructor (allowed on structs since C# 10), soMaxisint.MinValue. Butnew Stats[2]anddefault(Stats)do not run it; they are zeroed memory, soMaxis 0. Any struct must be valid in its all-zero state.- An array element is a variable:
arr[0].Max = 7writes into the array itself. - A
List<T>indexer is a method that returns a copy. Changing the copy leaves the list alone. (Writinglist[0].Max = 7directly does not even compile, error CS1612, precisely because it would be lost.)
Common mistakes
- Mutable structs. Every one of the silent copies above is harmless
for an immutable struct and a bug for a mutable one. Prefer
readonly structand return new values. - Comparing boxed values with
==.object a = 42, b = 42;givesa == bfalse (two boxes, compared by reference) anda.Equals(b)true:
public static class Solution{ public static string Describe() { object a = 42; object b = 42; int x = 42; return $"a==b {a == b} | a.Equals(b) {a.Equals(b)} | (int)a==x {(int)a == x} | a is int n && n==x {(a is int n && n == x)}"; }}- Trusting a struct constructor to run. Arrays,
default, and uninitialised fields all skip it, as shown above.
What interviewers ask
- "What is boxing, and when does it happen?" Copying a value type
into a heap object so it can be used as
objector an interface: assigning a struct to anobjector interface-typed variable, passing it where one is expected, storing it in a non-generic collection. Unboxing is the cast back, and copies again. - "When would you choose a struct over a class?" Small (a few fields), logically a single value, usually immutable, and created in large numbers where avoiding heap allocations matters.
- "Why can't you write
list[0].X = 5for aListof structs?" The indexer returns a copy; the assignment would change a temporary, so the compiler rejects it. Read, modify, write back, or use an array.
Exercise · Pro
Per-slot statistics, updated in place
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade