Question What’s the difference between value types and reference types in C#, and why does it matter for correctness and performance? Brief Answer Value types (e.g., int, bool, struct) are copied by value. Reference types (class, string, arrays, delegates) are copied by reference (the reference is copied, not the object). Where do they live:
-
Reference types (class, interface, delegate, array, string):
Variables hold a reference (pointer). The object itself is allocated on the managed heap, GC-managed. -
Value types (struct, enum, bool/int/etc., record struct):
Variables hold the data itself, and the value is stored “inline where it lives.” That means:-
Local value types → typically on the stack (or even in CPU registers/JIT-optimized).
-
Fields of a reference type → stored inside that heap object (inline on the heap).
-
Elements of a value-type array → stored contiguously inside the array object on the heap.
-
This is an often misunderstood fact, and one often seen on technical interviews, so make sure to take it to heart! Let’s go through some examples to really get how value and reference types work, and to see some real applications.
Example A — Copy vs Reference Semantics
struct Point
{
public int X, Y; // keep small; prefer readonly in production
}
class Person
{
public string Name;
}
class DemoA
{
static void Main()
{
var p1 = new Point { X = 1, Y = 2 };
var p2 = p1; // copies the value
p2.X = 99;
Console.WriteLine(p1.X); // 1 (unchanged)
var a = new Person { Name = "Marinko" };
var b = a; // copies the reference
b.Name = "Marinko Impersonator";
Console.WriteLine(a.Name); // "Marinko Impersonator" has taken over real Marinko!!!
}
}
- Assignment semantics: structs copy data while classes copy references.
- Where they live: value types live inline (often stack or inside objects/arrays) while reference types live on the GC (Garbage Collector) heap.
- Why it matters: Copying a value type duplicates its state, while copying a reference allows multiple variables to observe/mutate the same object.
Example B — Boxing & Equality
class DemoB
{
static void Main()
{
int i = 42;
object o = i; // boxing (value copied to a new heap object)
Console.WriteLine(o.GetType().Name); // Int32
// Boxing affects equality semantics
int x = 10, y = 10;
Console.WriteLine(x.Equals(y)); // True (value equality)
object ox = x, oy = y; // both boxed (distinct objects)
Console.WriteLine(ox == oy); // False (reference equality)
Console.WriteLine(ox.Equals(oy)); // True (boxed values equal)
}
}
- Boxing copies a value type into a new heap object when it’s treated as
object/interface. - Equality:
==onobjectis reference equality;Equalson boxed values compares underlying values. - Performance: boxing allocates and adds GC pressure—avoid in hot paths; prefer generic APIs (
List<int>vsArrayList).
Example C — Mutable Struct Pitfall
public struct Counter
{
public int Value; // mutable struct for demo (generally avoid)
}
class DemoC
{
static void Main()
{
var list = new List<Counter> { new Counter { Value = 0 } };
// Looks like it mutates the element, but 'c' is a copy in foreach
foreach (var c in list)
{
var cCopy = c;
cCopy.Value++; // only changes the copy
}
Console.WriteLine(list[0].Value); // 0 — surprise!
// Correct: mutate via index or use 'ref'
var tmp = list[0];
tmp.Value++;
list[0] = tmp;
Console.WriteLine(list[0].Value); // 1
}
}
- Foreach copies: the iteration variable is a copy for value types, so mutating it doesn’t affect the collection.
- Guideline: avoid mutable structs (prefer
readonly struct). If you must mutate, do it via indexers/reflocals.
Example D — Large Structs, in Parameters & readonly struct
public readonly struct Matrix3x3
{
public readonly double M11, M12, M13;
public readonly double M21, M22, M23;
public readonly double M31, M32, M33;
public Matrix3x3(double m11, double m12, double m13,
double m21, double m22, double m23,
double m31, double m32, double m33)
=> (M11, M12, M13, M21, M22, M23, M31, M32, M33) =
(m11, m12, m13, m21, m22, m23, m31, m32, m33);
public double Determinant() =>
M11 * (M22 * M33 - M23 * M32) -
M12 * (M21 * M33 - M23 * M31) +
M13 * (M21 * M32 - M22 * M31);
}
class DemoD
{
static double Det(in Matrix3x3 m) // pass by readonly reference (no copy)
=> m.Determinant();
static void Main()
{
var m = new Matrix3x3(1, 2, 3, 4, 5, 6, 7, 8, 9);
Console.WriteLine(Det(in m));
}
}
- Avoid big copies:
inpasses a readonly reference to avoid copying large structs. - readonly struct prevents defensive copies when accessing members and clarifies immutability.
- Locality advantage: arrays/fields of structs store data inline → better cache behavior than arrays of references.
Example E — ref/out/in Summary
class DemoE
{
static void Increment(ref int x) => x++;
static bool TryParsePositive(string s, out int value)
{
if (int.TryParse(s, out value) && value > 0) return true;
value = default; return false;
}
static int Dot(in (int X, int Y) a, in (int X, int Y) b) => a.X * b.X + a.Y * b.Y; // 'in' on a value tuple
static void Main()
{
int n = 5; Increment(ref n); Console.WriteLine(n); // 6
Console.WriteLine(TryParsePositive("10", out var v)); // True, v=10
Console.WriteLine(Dot((1, 2), (3, 4))); // 11
}
}
- ref: pass by reference for read/write. out: write-only outputs. in: readonly reference.
- Use sparingly for clarity; reserve for hot paths or interop.
- Related advanced topic:
ref struct(e.g.,Span<T>) is stack-only and cannot be boxed/captured.
Example F — Nullable Value Types, Defaults & Interning Note
class DemoF
{
static void Main()
{
int? maybe = null; // Nullable<int> — value types can be null via Nullable<T>
Console.WriteLine(maybe.HasValue); // False
Console.WriteLine(default(DateTime)); // 01/01/0001 00:00:00 (zeroed value)
string a = "hello";
string b = string.Intern(new string(new[] { 'h', 'e', 'l', 'l', 'o' }));
Console.WriteLine(object.ReferenceEquals(a, b)); // often True (intern pool)
}
}
- Nullability: value types can be made nullable via
Nullable<T>(T?). Default of value types is a zeroed state. - Strings: reference types but immutable; interning may cause identical literals to refer to the same instance.
Example G — Where do they live?
// Compile with: dotnet build -c Release /p:AllowUnsafeBlocks=true
// Run: dotnet run -c Release
using System;
using System.Runtime.InteropServices;
struct PointV2 { public int X, Y; public PointV2(int x, int y) { X = x; Y = y; } }
sealed class Box { public PointV2 P; }
class Program
{
static unsafe void Main()
{
Console.WriteLine("1) Local value type (stack)");
int local = 123;
int* pLocal = &local; // local lives on the stack
Console.WriteLine($"local int addr: 0x{((nint)pLocal).ToString("X")} (stack)");
Console.WriteLine("n2) Value-type field inside reference type (heap)");
var box = new Box { P = new PointV2(1, 2) }; // 'box' is a heap object
// Pin the object so GC won't move it while we take addresses:
var handle = GCHandle.Alloc(box, GCHandleType.Pinned);
try
{
byte* objPtr = (byte*)handle.AddrOfPinnedObject();
Console.WriteLine($"Box object addr: 0x{((nint)objPtr).ToString("X")} (heap object start)");
// Take the address of the value-type field inside the object:
fixed (PointV2* pField = &box.P)
{
Console.WriteLine($"Box.P field addr: 0x{((nint)pField).ToString("X")} (inside heap object)");
}
}
finally { handle.Free(); }
Console.WriteLine("n3) Elements of a value-type array (contiguous on heap)");
var arr = new PointV2[3] { new(10, 20), new(30, 40), new(50, 60) }; // array is a heap object
fixed (PointV2* p0 = &arr[0])
{
PointV2* p1 = p0 + 1;
PointV2* p2 = p0 + 2;
Console.WriteLine($"arr[0] addr: 0x{((nint)p0).ToString("X")} (heap, inside array)");
Console.WriteLine($"arr[1] addr: 0x{((nint)p1).ToString("X")} (next element, contiguous)");
Console.WriteLine($"arr[2] addr: 0x{((nint)p2).ToString("X")} (next element, contiguous)");
}
Console.WriteLine("nNotes:");
Console.WriteLine("- Exact addresses differ per run; the relative placement illustrates stack vs heap.");
Console.WriteLine("- We pin the object to safely take addresses; normally the GC may move heap objects.");
}
}
This doesn’t depend on undefined behavior: locals are on the stack; we pin the heap object to read its address; and we use fixed to get pointers into managed memory safely.
Summary & Gotchas
- Prefer small, immutable structs; use classes for identity/complex state.
- Beware boxing, mutable struct surprises, and unnecessary copies of large structs.
- Use
in/ref/outjudiciously; measure before optimizing.