Skip to main content

πŸ“ Lesson 4.1: The Memory Model: Value vs Reference

To write high-performance C#, you need a precise picture of where your data lives and how it's copied. This lesson makes the value-vs-reference distinction concrete: stack vs heap, copy semantics, and the hidden cost of boxing.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Distinguish value types from reference types precisely
  • Explain what goes on the stack vs. the heap (and the nuances)
  • Predict copy semantics for structs and classes
  • Recognize boxing/unboxing and its cost
  • Choose between a struct and a class deliberately

Estimated Time: 75 minutes

Project: Observe copy semantics and eliminate hidden boxing.

In This Lesson

Two Kinds of Types

Every C# type is either a value type or a reference type, and that single fact drives how it's stored, copied, and compared.

Value typesReference types
Declared withstruct, enum, primitivesclass, record (class), interface, arrays, delegates
A variable holdsthe actual dataa reference (pointer) to the data
Examplesint, double, bool, DateTime, Guidstring, List<T>, your classes
Default valuezero/"empty" (e.g. 0)null

πŸ“– The core difference

A value-type variable contains its value. A reference-type variable contains a reference to an object stored elsewhere. Copying a value copies the data; copying a reference copies only the pointer β€” both then point to the same object.

Stack vs. Heap

.NET uses two memory regions:

  • The stack β€” fast, automatically managed, per-thread. Holds local variables and method call frames; freed instantly when a method returns.
  • The heap β€” where objects live. Managed by the garbage collector (GC), which reclaims objects no longer referenced (Lesson 4.3).
graph TD subgraph Stack["Stack (fast, auto)"] S1["int age = 30
(the value itself)"] S2["Person p
(a reference)"] end subgraph Heap["Heap (GC-managed)"] H1["Person object
Name, Age"] end S2 -->|"points to"| H1 style Stack fill:#eff6ff,stroke:#3b82f6,stroke-width:2px style Heap fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px

⚠️ "Value types live on the stack" is an oversimplification

The accurate rule is about copy behavior, not location. A value type lives wherever it's declared: a local int is on the stack, but an int field inside a class lives on the heap (inside that object), and a boxed value lives on the heap. Don't over-index on "stack vs heap" β€” focus on value vs reference semantics.

Copy Semantics

This is where the distinction bites. Assigning or passing a value type copies the data; the copy is independent:

struct PointStruct { public int X; public int Y; }

var a = new PointStruct { X = 1, Y = 2 };
var b = a;          // COPY β€” b is a separate PointStruct
b.X = 99;

Console.WriteLine(a.X);   // 1  β€” a is unaffected
Console.WriteLine(b.X);   // 99

Assigning a reference type copies the reference; both variables point to the same object:

class PointClass { public int X; public int Y; }

var c = new PointClass { X = 1, Y = 2 };
var d = c;          // COPY OF THE REFERENCE β€” same object
d.X = 99;

Console.WriteLine(c.X);   // 99  β€” c and d are the same object!
Console.WriteLine(d.X);   // 99

⚠️ Passing structs to methods copies them too

A struct passed to a method is copied, so changes inside the method don't affect the caller's copy (unless passed by ref). Large structs copied frequently can hurt performance β€” every assignment and call duplicates all their fields. This is a key reason to keep structs small.

πŸ’‘ Equality follows suit

By default, value types compare by their contents and reference types by identity (same object) β€” which is exactly the value-vs-reference-equality story from records in Intermediate. A record struct gives you a value type with generated value equality.

Boxing and Unboxing

Sometimes a value type needs to be treated as a reference type β€” for example, stored in an object or a non-generic collection. The runtime boxes it: wraps the value in a heap object. Extracting it back is unboxing.

int n = 42;
object boxed = n;        // BOXING β€” allocates a heap object holding 42
int back = (int)boxed;   // UNBOXING β€” copies the value back out
graph LR A["int n = 42
(on the stack)"] -->|"box β†’ allocates"| B["object on heap
wrapping 42"] B -->|"unbox β†’ copies out"| C["int back = 42"] style A fill:#eff6ff,stroke:#3b82f6,stroke-width:2px style B fill:#fff3cd,stroke:#ffc107,stroke-width:2px style C fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px

⚠️ Boxing is a hidden allocation β€” and it hides everywhere

Each box allocates on the heap and adds GC pressure. It often happens invisibly:

  • Putting a value type into object or a non-generic collection (ArrayList).
  • Calling a method that takes object (e.g. old string.Format with value args).
  • Using a value type through a non-generic interface reference.

βœ… Generics eliminate boxing

This is a core reason generics (Intermediate) exist. A List<int> stores ints directly β€” no boxing β€” while the old non-generic ArrayList boxed every element. Prefer generic collections and APIs, and avoid unnecessary trips through object, to keep value types allocation-free.

Choosing struct vs. class

Default to class. Reach for a struct only when the type genuinely models a small value and you'll benefit from value semantics or reduced allocations.

βœ… Consider a struct when ALL of these hold

  • It's small (roughly ≀ 16 bytes is the common guideline).
  • It's immutable (mutable structs are a well-known source of bugs).
  • It logically represents a single value (a point, a money amount, a coordinate).
  • It won't be boxed frequently.

⚠️ Prefer a class when…

  • The type is large (copying is expensive), or
  • It needs reference identity (shared, mutable state that many holders should see), or
  • It participates in inheritance hierarchies (structs can't inherit).
πŸ’‘ The examples in .NET tell the story: int, DateTime, Guid, TimeSpan, and decimal are structs β€” small, immutable values. List<T>, string, and your entities are classes. When in doubt, choose class; a wrong struct choice is harder to undo.

Modern Struct Features

Modern C# adds tools for high-performance value types:

FeatureWhat it gives you
readonly structAn immutable struct; the compiler enforces no field mutation and can avoid defensive copies.
record structA value type with generated value equality and ToString (Intermediate records, as a struct).
ref structA struct that must live on the stack only (never boxed or heap-allocated) β€” the basis of Span<T>, next lesson.
in parametersPass a large struct by read-only reference to avoid copying it.
// Immutable value type β€” no defensive copies, safe to share by value
public readonly struct Money
{
    public decimal Amount { get; }
    public string Currency { get; }
    public Money(decimal amount, string currency) => (Amount, Currency) = (amount, currency);
}

// Pass a big struct without copying it, using 'in'
static decimal Total(in Money a, in Money b) => a.Amount + b.Amount;

πŸ’‘ readonly struct is the safe default struct

If you do write a struct, make it a readonly struct. Immutability avoids the classic mutable-struct pitfalls (surprising copies discarding changes) and lets the compiler optimize away hidden defensive copies. ref struct is specialized β€” you'll meet it properly with Span<T> in Lesson 4.2.

Exercise & Quiz

πŸ‹οΈ Exercise: Semantics and Boxing

Objective: Demonstrate copy semantics and spot/remove boxing.

Instructions:

  1. Create a new project called MemoryModel.
  2. Define struct Vec { public int X, Y; } and class Node { public int Value; }.
  3. Show that copying a Vec and mutating the copy leaves the original unchanged, while copying a Node reference and mutating it changes both variables. Print to prove it.
  4. Write a method void Move(Vec v) that sets v.X = 100; show the caller's Vec is unaffected (copy). Then make a ref version and show it is affected.
  5. Boxing: add several ints to an object[] (boxing) and to a List<int> (no boxing). Note which allocates.

Starter Code:

struct Vec { public int X, Y; }
class Node { public int Value; }

var v1 = new Vec { X = 1, Y = 1 };
var v2 = v1;      // copy
v2.X = 99;
Console.WriteLine($"v1.X={v1.X}, v2.X={v2.X}");   // TODO: predict then verify

var n1 = new Node { Value = 1 };
var n2 = n1;      // copy of reference
n2.Value = 99;
Console.WriteLine($"n1.Value={n1.Value}, n2.Value={n2.Value}");   // TODO: predict

// TODO: Move(Vec) vs Move(ref Vec); object[] boxing vs List<int>
πŸ’‘ Hint

Vec is a value type β†’ v1.X stays 1, v2.X is 99. Node is a reference type β†’ both are 99. For Move, a plain parameter copies (no effect); ref Vec v mutates the original. object[] a = {1,2,3}; boxes each int; List<int> does not.

βœ… Solution
struct Vec { public int X, Y; }
class Node { public int Value; }

var v1 = new Vec { X = 1, Y = 1 };
var v2 = v1;                 // independent copy
v2.X = 99;
Console.WriteLine($"v1.X={v1.X}, v2.X={v2.X}");     // v1.X=1, v2.X=99

var n1 = new Node { Value = 1 };
var n2 = n1;                 // same object
n2.Value = 99;
Console.WriteLine($"n1.Value={n1.Value}, n2.Value={n2.Value}"); // both 99

Move(v1);
Console.WriteLine($"after Move(copy): v1.X={v1.X}");      // 1 β€” unaffected
MoveRef(ref v1);
Console.WriteLine($"after Move(ref):  v1.X={v1.X}");      // 100 β€” changed

// Boxing vs no boxing
object[] boxed = { 1, 2, 3 };          // each int is boxed (3 heap allocations)
List<int> noBox = new() { 1, 2, 3 };   // stored as ints β€” no boxing
Console.WriteLine($"{boxed.Length} boxed, {noBox.Count} unboxed");

static void Move(Vec v) { v.X = 100; }             // mutates a copy
static void MoveRef(ref Vec v) { v.X = 100; }      // mutates the caller's Vec

🎯 Quick Quiz

Question 1: What does a reference-type variable actually hold?

Question 2: After var b = a; where a is a struct, modifying b…

Question 3: What is boxing?

Summary

πŸŽ‰ Key Takeaways

  • Value types (struct, primitives) hold their data; reference types (class, arrays, string) hold a reference to a heap object.
  • The stack holds locals/frames (fast, auto); the heap holds objects (GC-managed). "Value types are always on the stack" is an oversimplification β€” focus on copy semantics.
  • Copying a value duplicates the data (independent); copying a reference shares the same object. Structs are copied when passed to methods too.
  • Boxing wraps a value type in a heap object β€” a hidden allocation; generics avoid it, so prefer generic collections/APIs.
  • Default to class; use a small, immutable readonly struct for single-value types. Modern features: record struct, ref struct, in parameters.

πŸ“š Additional Resources

πŸš€ What's Next?

You understand where data lives and how it's copied. Next, we use that to process data with zero extra allocations. In Lesson 4.2: Span and Memory, you'll slice arrays and buffers without copying them.

πŸŽ‰ Memory model, mastered!

You can now reason precisely about copies and allocations. Next: high-performance slicing with Span.