Skip to main content

Value Types vs Reference Types (C# Deep Dive)

Overview

Understanding the fundamental difference between value types and reference types is critical for writing efficient C# code and acing senior-level interviews.

Core Differences

AspectValue TypesReference Types
StorageStack (or inline in parent object)Heap
AssignmentCopies the entire valueCopies the reference
Default valueZero/null fieldsnull
InheritanceCannot inherit (except from ValueType)Can inherit
BoxingBoxed when cast to objectNo boxing needed
Examplesint, struct, enum, record structclass, record class, string, arrays

Memory Layout

// Value type - stored on stack
public struct Point
{
public int X; // Inline in struct
public int Y; // Inline in struct
}

Point p = new Point { X = 10, Y = 20 };
// Entire struct (8 bytes) is on stack

// Reference type - object on heap, reference on stack
public class Person
{
public string Name; // Reference to string on heap
public int Age; // Value inline in object
}

Person person = new Person { Name = "Alice", Age = 30 };
// 'person' variable holds address (8 bytes on stack)
// Person object itself is on heap

Boxing and Unboxing

What is Boxing?

Boxing is converting a value type to object or interface type, which creates a heap-allocated copy.

int value = 42;           // Value type on stack
object boxed = value; // Boxing: copy to heap
int unboxed = (int)boxed; // Unboxing: copy back to stack

Performance Impact

// ❌ BAD: Boxing in loops
List<object> list = new();
for (int i = 0; i < 1000; i++)
{
list.Add(i); // Boxing on each iteration! 1000 heap allocations
}

// ✅ GOOD: No boxing
List<int> list = new();
for (int i = 0; i < 1000; i++)
{
list.Add(i); // No boxing, stays on stack
}

Surprising Boxing Scenarios

// Boxing when calling interface methods on structs
public struct Counter : IComparable<Counter>
{
public int Value;

public int CompareTo(Counter other) => Value.CompareTo(other.Value);
}

Counter c1 = new Counter { Value = 5 };

// ❌ Boxing: struct cast to interface
IComparable<Counter> comparable = c1; // Boxes!
comparable.CompareTo(c1);

// ✅ No boxing: direct call
c1.CompareTo(c1); // No boxing

struct vs class

When to Use struct

// ✅ Good struct candidates: small, immutable, value semantics
public readonly struct Money
{
public decimal Amount { get; }
public string Currency { get; }

public Money(decimal amount, string currency)
{
Amount = amount;
Currency = currency;
}
}

// ❌ Bad struct: large, mutable, reference semantics
public struct HugeStruct // Don't do this!
{
public byte[] Data; // 1MB array
public List<string> Items;
public int Counter;
}

Struct Guidelines

Use struct when:

  • Size ≤ 16 bytes
  • Logically represents a single value
  • Immutable
  • Short-lived
  • Will not be boxed frequently

Use class when:

  • Represents complex entities
  • Needs inheritance
  • Large data structures
  • Long-lived objects
  • Needs reference semantics

record struct vs record class

record struct (C# 10+)

// Immutable value type with value equality
public readonly record struct Point3D(double X, double Y, double Z);

var p1 = new Point3D(1, 2, 3);
var p2 = new Point3D(1, 2, 3);

Console.WriteLine(p1 == p2); // True - value equality

record class (C# 9+)

// Immutable reference type with value equality
public record Person(string Name, int Age);

var person1 = new Person("Alice", 30);
var person2 = new Person("Alice", 30);

Console.WriteLine(person1 == person2); // True - value equality
Console.WriteLine(ReferenceEquals(person1, person2)); // False - different objects

Comparison Table

Featurerecord structrecord classstructclass
StorageStack/InlineHeapStack/InlineHeap
EqualityValueValueDefault (reference-like)Reference
MutabilityCan be mutableCan be mutableCan be mutableCan be mutable
with expressions
Inheritance

with Expressions

// record struct
public readonly record struct Config(int Timeout, bool EnableCache);

var config1 = new Config(30, true);
var config2 = config1 with { Timeout = 60 }; // Creates new instance

// record class
public record User(int Id, string Name, string Email);

var user1 = new User(1, "Alice", "alice@example.com");
var user2 = user1 with { Email = "newemail@example.com" }; // Creates new instance

Pass by Value vs Pass by Reference

Value Type Behavior

void ModifyValue(int x)
{
x = 100; // Modifies local copy only
}

int num = 42;
ModifyValue(num);
Console.WriteLine(num); // Still 42

Reference Type Behavior

void ModifyReference(Person person)
{
person.Name = "Bob"; // Modifies the object on heap
}

var person = new Person { Name = "Alice" };
ModifyReference(person);
Console.WriteLine(person.Name); // "Bob" - changed!

ref, in, and out

// ref: pass by reference, can read and modify
void Increment(ref int value)
{
value++;
}

int num = 10;
Increment(ref num);
Console.WriteLine(num); // 11

// in: pass by reference, read-only (optimization for large structs)
void ProcessLarge(in LargeStruct data)
{
// data = new LargeStruct(); // ❌ Compiler error
Console.WriteLine(data.Value); // ✅ Can read
}

// out: pass by reference, must be initialized in method
bool TryParse(string input, out int result)
{
return int.TryParse(input, out result);
}

ref struct (Stack-only Types)

// ref struct can ONLY exist on stack - perfect for high-performance scenarios
public ref struct SpanWrapper\<T\>
{
private Span\<T\> _span;

public SpanWrapper(Span\<T\> span)
{
_span = span;
}
}

// ❌ Cannot do these with ref struct:
// - Store in class field
// - Box to object
// - Use in async methods
// - Store in regular struct
// - Use with Task or any heap-allocated generic

Common Interview Questions

Q: What happens when you pass a struct to a method?

A: The entire struct is copied (unless using ref, in, or out). For large structs, this can be expensive. That's why structs should be small.

Q: Why is string a reference type but seems immutable?

A: String is a reference type stored on the heap, but it's designed to be immutable for thread-safety and optimization. Operations that seem to modify strings actually create new string instances.

Q: When does boxing occur and why is it bad?

A: Boxing occurs when a value type is cast to object or interface type. It's bad because it causes heap allocation, GC pressure, and performance overhead. It happens in collections like ArrayList, calling interface methods on structs, and concatenating value types with strings.

Q: What's the difference between record struct and readonly struct?

A: A record struct automatically implements value equality, ToString, and supports with expressions. A readonly struct ensures all fields are readonly but doesn't get these automatic implementations. You can combine them: readonly record struct.

Q: Can a struct contain a reference type field?

A: Yes! A struct can contain reference type fields. The struct itself is a value type (copied on assignment), but its reference fields point to heap objects. Example:

public struct Container
{
public int Value; // Value type field
public string Text; // Reference type field
}

var c1 = new Container { Value = 1, Text = "Hello" };
var c2 = c1; // Struct is copied, but both point to same "Hello" string

Practice Exercises

  1. Implement a Money struct with proper value equality
  2. Demonstrate boxing behavior with different scenarios
  3. Create a record struct for a 3D vector with mathematical operations
  4. Show the memory difference between struct and class with a profiler
  5. Implement a high-performance algorithm using ref struct

Performance Tips

// ✅ Use readonly struct to enable compiler optimizations
public readonly struct Point
{
public int X { get; }
public int Y { get; }

public Point(int x, int y) => (X, Y) = (x, y);
}

// ✅ Use 'in' parameter for large structs to avoid copying
public double Calculate(in Matrix4x4 matrix)
{
// Read matrix without copying
return matrix.M11 + matrix.M22;
}

// ✅ Use ref return for modifying struct in place
ref struct GetElement(ref Span<int> span, int index)
{
return ref span[index];
}