MOFAKH.COM
← Back to profile
C#

The C# memory model: values, references, and boxing

Aug 1, 20267 min readWritten

Value type or reference type is not really "stack vs heap". It is what gets copied when you assign — and almost everything else (storage, nullability, boxing) follows from that one fact.

Value types vs reference types

  • value type: int, double, bool, DateTime, any struct, any enum, Guid
    • A value-type variable holds the data itself. Assignment copies the data.
  • reference type: any class, string, arrays, delegates, interface, record class
    • A reference-type variable holds a reference (think: address) to an object that lives on the heap.
    • Assignment copies the reference — both variables now point at the same object.
    • The reference sits where the variable lives; the object it points to lives on the heap.

The whole model is one rule: assignment copies the variable. For a struct that means every field. For a class that means the address only — which is why two class variables can quietly share one object.

Stack and heap are a consequence, not the definition

  • The stack is fast, small (~1MB per thread), and automatic.
    • Each method call gets a stack frame holding its local variables and parameters.
    • When the method returns, the frame is popped — cleanup is free and instant.
  • The heap is large and managed by the garbage collector (GC).
    • Every new-ed class instance lives here.
    • Objects survive after the method that created them returns — that is the point of the heap — but the GC must periodically find and reclaim dead ones, which costs CPU time.

A value type is not "always on the stack" — that is the tempting oversimplification. Value types live wherever their container lives:

  • Local value type → stack
  • Value type as a field inside a class → heap, with its owner
  • Value type captured by a lambda / used in async or an iterator → lifted onto the heap in a compiler-generated class

"Value type" describes copy semantics. Where it is stored is an implementation detail the runtime picks.

Struct vs. class: how a developer actually chooses

  • Default to class.
  • Reach for a struct when the type is small (rule of thumb: ≤16 bytes), represents a single value conceptually (a money amount, a coordinate, a time range), and is immutable.
    • DateTime, decimal, TimeSpan, and Guid are all structs for exactly these reasons.
  • Mutability matters more for structs than for classes: copy semantics mean you sometimes mutate a temporary copy and your change silently vanishes. So write structs readonly.

Equality defaults differ too, and it is worth knowing before you drop a type into a HashSet or Distinct():

  • struct → field-by-field equality, but the default uses reflection unless you override Equals/GetHashCode.
  • class → reference equality unless you override it.
  • record struct / record → value equality generated for you — the reason records are the default for DTOs.

Nullable types

  • Value types can't be null — an int is always some number. But real domains need "absent": an employee's TerminationDate while they are still employed.
  • Hence int?, DateTime? — shorthand for Nullable<T>, a struct wrapping a value plus a HasValue flag.
    • Nullable<T> itself is never null. It is a struct that carries whether it currently holds a value.
    • int? x; → the compiler treats it as Nullable<int> x;. The struct isn't null; it simply reports "I don't currently contain a valid int."
csharp
// simplified — the real Nullable<T> also has GetValueOrDefault(), etc.
public struct Nullable<T> where T : struct
{
    private bool hasValue;
    private T value;
 
    public bool HasValue => hasValue;
 
    public T Value
    {
        get
        {
            if (!hasValue)
                throw new InvalidOperationException();
            return value;
        }
    }
}
 
// int? x = 5;          int? x = null;
// -----------------    -----------------
// HasValue = true      HasValue = false
// Value    = 5         Value    = 0  (ignored)
  • Reference types could always be null — historically the source of the most common exception in all of .NET: NullReferenceException.
    • With nullable reference types on: string means "never null, I promise"; string? means "might be null, callers must check."

default is the keyword for "the default value of this type", and it ties the two worlds together:

csharp
DateTime d = default;   // 01/01/0001 12:00:00 AM
bool     b = default;   // false
int      x = default;   // 0
int?     y = default;   // null

int? y = default lands on null because Nullable<int>'s default state is HasValue = false (with Value = 0 ignored).

Boxing and unboxing

  • Boxing happens when a value type is used where an object (or interface) is expected. The runtime:
    • allocates a heap object,
    • copies the value into it,
    • hands back a reference.
  • Unboxing copies it back out. Each box = one heap allocation = future GC work.
csharp
int hours = 40;
object boxed = hours;        // boxing: allocation + copy
int unboxed = (int)boxed;    // unboxing: cast + copy
  • Generics exist precisely to give type safety without boxing — List<int> knows it holds ints, so nothing needs to become object. (This is the bridge into Day 2.)
  • String interpolation of a struct via its ToString() is fine, but passing a struct into something typed as object — old APIs, some logging overloads, non-generic collections — still boxes today.
Note to self

The aliasing trap and immutability cancel out in Money { decimal Amount; string Currency }: copying a Money shares the one string reference, but strings are immutable, so no one can mutate the shared object — the Day-1 bug can't fire. That is the whole model clicking together in one type. Also: readonly struct + in parameters remove the defensive copies the compiler otherwise inserts — measure before reaching for them.