C# Type System
The C# type system is the foundation of all .NET programming. Understanding value types vs reference types, memory allocation, and type conversions is essential for writing efficient, bug-free code.
Table of Contents
- Value Types vs Reference Types
- Memory Model: Stack vs Heap
- Boxing and Unboxing
- Nullable Types
- Struct vs Class
- ref, out, and in Parameters
- Span and Memory
- Interview Questions
Value Types vs Reference Types
Value Types
Value types directly contain their data. When you assign a value type to another variable, a copy of the data is created.
// Value types are stored on the stack (when local variables)
int a = 42;
int b = a; // b gets a COPY of a's value
b = 100; // a is still 42
// Built-in value types
bool isValid = true; // Boolean
byte smallNumber = 255; // 8-bit unsigned (0-255)
short mediumNumber = 32767; // 16-bit signed
int normalNumber = 2147483647; // 32-bit signed
long bigNumber = 9223372036854775807L; // 64-bit signed
float singlePrecision = 3.14f; // 32-bit floating point
double doublePrecision = 3.14159265359; // 64-bit floating point
decimal money = 123.45m; // 128-bit decimal (financial)
char letter = 'A'; // 16-bit Unicode character
// Structs are value types
DateTime now = DateTime.Now;
TimeSpan duration = TimeSpan.FromHours(2);
Guid id = Guid.NewGuid();
Reference Types
Reference types store a reference (pointer) to the data. Multiple variables can reference the same object.
// Reference types are stored on the heap
string name1 = "John";
string name2 = name1; // name2 references the SAME string
// Classes are reference types
Person person1 = new Person { Name = "Alice" };
Person person2 = person1; // Both reference the SAME object
person2.Name = "Bob"; // person1.Name is also "Bob" now!
// Arrays are reference types (even arrays of value types)
int[] numbers1 = { 1, 2, 3 };
int[] numbers2 = numbers1; // Both reference the SAME array
numbers2[0] = 99; // numbers1[0] is also 99
// Other reference types
object obj = new object();
dynamic dyn = "hello";
string text = "immutable";
Key Differences
| Aspect | Value Types | Reference Types |
|---|---|---|
| Storage | Stack (usually) | Heap |
| Assignment | Copies data | Copies reference |
| Default | Zero/false/null | null |
| Inheritance | Cannot inherit | Can inherit |
| Nullable | Requires ? |
Nullable by default |
| Performance | Faster allocation | GC overhead |
Memory Model: Stack vs Heap
Stack Memory
The stack is a LIFO (Last In, First Out) data structure used for:
- Method call frames
- Local value type variables
- Method parameters
- Return addresses
void ProcessData()
{
// All these live on the stack
int count = 10; // 4 bytes on stack
double price = 99.99; // 8 bytes on stack
bool isActive = true; // 1 byte on stack
// When ProcessData() returns, stack frame is popped
// All local variables are automatically deallocated
}
Stack characteristics:
- β Very fast allocation/deallocation
- β Automatic memory management
- β Limited size (~1MB default on Windows)
- β LIFO access pattern only
Heap Memory
The heap is used for:
- Reference type objects
- Large data structures
- Objects with unknown lifetime
void CreateObjects()
{
// 'person' reference is on stack (8 bytes on x64)
// Person object is on heap
Person person = new Person();
// 'data' reference is on stack
// int[] array is on heap (even though int is value type!)
int[] data = new int[1000];
// String content is on heap
string message = "Hello, World!";
}
Heap characteristics:
- β Dynamic size
- β Objects persist beyond method scope
- β Slower allocation
- β Requires Garbage Collection
Visual Representation
STACK HEAP
βββββββββββββββββββ βββββββββββββββββββββββ
β person (ref)βββββΌβββββββββββΊβ Person object β
β count = 10 β β Name: "John" β
β price = 99.99 β β Age: 30 β
βββββββββββββββββββ€ βββββββββββββββββββββββ
β data (ref)βββββββΌβββββββββββΊβββββββββββββββββββββββ
β isActive = true β β int[1000] array β
βββββββββββββββββββ β [0]=0, [1]=0, ... β
βββββββββββββββββββββββ
Boxing and Unboxing
What is Boxing?
Boxing converts a value type to a reference type by wrapping it in an object on the heap.
int number = 42;
object boxed = number; // Boxing: copies value to heap
// What happens internally:
// 1. Allocate memory on heap
// 2. Copy value to heap
// 3. Return reference to heap object
What is Unboxing?
Unboxing extracts the value type from the boxed object.
object boxed = 42; // Boxing
int number = (int)boxed; // Unboxing: copies value back to stack
// What happens internally:
// 1. Check if boxed is actually an int (throws InvalidCastException if not)
// 2. Copy value from heap to stack
Performance Impact
Boxing is expensive! Each boxing operation:
- Allocates ~12 bytes + value size on heap
- Copies the value
- Creates GC pressure
// β BAD: Boxing in a loop
ArrayList list = new ArrayList();
for (int i = 0; i < 1000000; i++)
{
list.Add(i); // Boxing happens 1 million times!
}
// β
GOOD: Use generic collections
List<int> list = new List<int>();
for (int i = 0; i < 1000000; i++)
{
list.Add(i); // No boxing - List<int> stores int directly
}
Common Boxing Scenarios
// 1. Assigning to object
object obj = 42; // Boxing
// 2. Calling methods that take object
void Log(object value) { }
Log(42); // Boxing
// 3. String interpolation (sometimes)
int count = 5;
string s = $"Count: {count}"; // May box depending on runtime
// 4. Using non-generic collections
Hashtable ht = new Hashtable();
ht.Add("key", 42); // Boxing
// 5. Interface dispatch on value types
IComparable comparable = 42; // Boxing
Avoiding Boxing
// Use generics
public void Process<T>(T value) where T : struct
{
// No boxing - T is constrained to value types
}
// Use Span<T> for array operations
Span<int> span = stackalloc int[100];
// Use ref struct for stack-only types
ref struct StackOnlyData
{
public int Value;
}
Nullable Types
Nullable Value Types
Value types cannot be null by default. Use ? to make them nullable.
// Regular value type - cannot be null
int count = 0;
// count = null; // Compile error!
// Nullable value type - can be null
int? nullableCount = null;
nullableCount = 42;
// Checking for value
if (nullableCount.HasValue)
{
int value = nullableCount.Value;
Console.WriteLine(value);
}
// Null-coalescing operator
int result = nullableCount ?? 0; // Returns 0 if null
// Null-conditional with value types
int? length = text?.Length; // null if text is null
Nullable Reference Types (C# 8+)
Enable nullable reference types to catch null reference bugs at compile time.
#nullable enable
// Non-nullable reference type - compiler warns if null
string name = "John";
// name = null; // Warning: assigning null to non-nullable
// Nullable reference type - explicitly allows null
string? nickname = null;
// Working with nullable references
if (nickname != null)
{
Console.WriteLine(nickname.Length); // Safe - compiler knows it's not null
}
// Null-forgiving operator (use sparingly!)
string definitelyNotNull = nickname!; // Tells compiler "trust me"
Null-Coalescing Patterns
// ?? - returns right side if left is null
string name = GetName() ?? "Unknown";
// ??= - assigns only if null
name ??= "Default";
// ?. - null-conditional access
int? length = text?.Length;
// Combined patterns
string result = user?.Profile?.DisplayName ?? "Anonymous";
// Throw if null
string required = value ?? throw new ArgumentNullException(nameof(value));
Struct vs Class
When to Use Struct
Use struct when:
- Data is small (< 16 bytes ideally)
- Data is logically a single value
- Data is immutable
- You need value semantics
- Avoiding heap allocation is important
// β
GOOD struct examples
public readonly struct Point
{
public double X { get; }
public double Y { get; }
public Point(double x, double y) => (X, Y) = (x, y);
public double DistanceTo(Point other) =>
Math.Sqrt(Math.Pow(X - other.X, 2) + Math.Pow(Y - other.Y, 2));
}
public readonly struct Money
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency) =>
(Amount, Currency) = (amount, currency);
}
public readonly struct DateRange
{
public DateTime Start { get; }
public DateTime End { get; }
public TimeSpan Duration => End - Start;
}
When to Use Class
Use class when:
- Object has complex behavior
- Object needs inheritance
- Object is large (> 16 bytes)
- Reference semantics are needed
- Object needs to be modified after creation
// β
GOOD class examples
public class Customer
{
public int Id { get; set; }
public string Name { get; set; }
public List<Order> Orders { get; } = new();
public decimal TotalSpent => Orders.Sum(o => o.Amount);
}
public class FileProcessor
{
private readonly Stream _stream;
public FileProcessor(Stream stream) => _stream = stream;
public async Task ProcessAsync() { /* ... */ }
}
Decision Matrix
| Criteria | Use Struct | Use Class |
|---|---|---|
| Size | < 16 bytes | > 16 bytes |
| Mutability | Immutable | Either |
| Inheritance | Not needed | Needed |
| Allocation pattern | Frequent, short-lived | Long-lived |
| Identity important | No | Yes |
| Passed frequently | Yes (value copy OK) | Yes (reference copy) |
readonly struct
The readonly struct modifier ensures immutability and enables compiler optimizations.
// readonly struct - all fields must be readonly
public readonly struct Vector3
{
public float X { get; }
public float Y { get; }
public float Z { get; }
public Vector3(float x, float y, float z) =>
(X, Y, Z) = (x, y, z);
// Methods cannot modify state
public Vector3 Normalize() =>
new Vector3(X / Length, Y / Length, Z / Length);
public float Length =>
MathF.Sqrt(X * X + Y * Y + Z * Z);
}
record struct (C# 10+)
Combines struct benefits with record features.
// record struct - value semantics with record features
public record struct Coordinate(double Latitude, double Longitude);
// readonly record struct - immutable record struct
public readonly record struct Temperature(double Celsius)
{
public double Fahrenheit => Celsius * 9 / 5 + 32;
public double Kelvin => Celsius + 273.15;
}
ref, out, and in Parameters
ref Parameter
Passes a reference to the variable. Both caller and method can modify.
void Swap(ref int a, ref int b)
{
int temp = a;
a = b;
b = temp;
}
int x = 10, y = 20;
Swap(ref x, ref y); // x = 20, y = 10
// Must be initialized before passing
// int z;
// Swap(ref z, ref x); // Error: z must be assigned
out Parameter
Passes a reference that the method MUST assign.
bool TryParse(string input, out int result)
{
if (int.TryParse(input, out result))
return true;
result = 0; // Must assign even on failure
return false;
}
// Usage - doesn't need to be initialized
if (TryParse("42", out int value))
{
Console.WriteLine(value);
}
// Discard if you don't need the value
if (TryParse("42", out _))
{
Console.WriteLine("Valid number");
}
in Parameter
Passes a readonly reference. Prevents copying while ensuring immutability.
// β
GOOD: Large struct passed by readonly reference
double CalculateDistance(in Point3D a, in Point3D b)
{
// Cannot modify a or b
// a.X = 0; // Error: cannot modify 'in' parameter
return Math.Sqrt(
Math.Pow(b.X - a.X, 2) +
Math.Pow(b.Y - a.Y, 2) +
Math.Pow(b.Z - a.Z, 2));
}
Point3D point1 = new(1, 2, 3);
Point3D point2 = new(4, 5, 6);
double dist = CalculateDistance(in point1, in point2);
ref return and ref local
Return references to avoid copying.
public class Matrix
{
private double[,] _data = new double[100, 100];
// Return reference to element
public ref double GetElement(int row, int col) =>
ref _data[row, col];
}
Matrix matrix = new Matrix();
ref double element = ref matrix.GetElement(0, 0);
element = 42.0; // Modifies the matrix directly
Span and Memory
Span
Span<T> is a stack-only type that represents a contiguous region of memory.
// Create Span from array
int[] array = { 1, 2, 3, 4, 5 };
Span<int> span = array;
// Create Span from slice
Span<int> slice = array.AsSpan(1, 3); // { 2, 3, 4 }
// Modify through Span
span[0] = 100; // Modifies array[0]
// Stack allocation with Span
Span<int> stackArray = stackalloc int[100];
stackArray[0] = 42;
// ReadOnlySpan for immutable access
ReadOnlySpan<char> text = "Hello, World!".AsSpan();
ReadOnlySpan<char> hello = text.Slice(0, 5); // "Hello"
Memory
Memory<T> is like Span<T> but can be stored on the heap.
// Memory can be stored in classes
public class DataProcessor
{
private Memory<byte> _buffer;
public DataProcessor(byte[] data)
{
_buffer = data;
}
public async Task ProcessAsync()
{
// Get Span for synchronous work
Span<byte> span = _buffer.Span;
// Memory can be used in async methods
await ProcessBufferAsync(_buffer);
}
}
Performance Benefits
// β BAD: Creates new string allocations
string ExtractYear(string date)
{
return date.Substring(0, 4); // Allocates new string
}
// β
GOOD: No allocations
ReadOnlySpan<char> ExtractYearSpan(ReadOnlySpan<char> date)
{
return date.Slice(0, 4); // Returns slice of existing memory
}
// Usage
string date = "2024-01-15";
ReadOnlySpan<char> year = ExtractYearSpan(date); // No allocation!
Interview Questions
Q1: Whatβs the difference between value types and reference types?
A: Value types store data directly and are typically allocated on the stack. When assigned to another variable, the value is copied. Reference types store a reference to data on the heap, and assignment copies only the reference, not the data.
Q2: What is boxing and why should you avoid it?
A: Boxing is converting a value type to a reference type (object), which allocates memory on the heap and copies the value. It should be avoided because it:
- Causes heap allocations
- Creates GC pressure
- Requires type checking during unboxing
- Is significantly slower than value type operations
Use generics to avoid boxing.
Q3: When would you use a struct instead of a class?
A: Use a struct when:
- The data is small (< 16 bytes)
- It represents a single value
- Itβs immutable
- Value semantics are desired
- Frequent allocation/deallocation is needed
Q4: What is Span<T> and when would you use it?
A: Span<T> is a stack-only type that provides a type-safe, memory-safe view into a contiguous region of memory. Use it for:
- Zero-allocation string/array slicing
- Stack-allocated arrays (
stackalloc) - High-performance parsing and manipulation
- Avoiding array copies
Q5: Explain the difference between ref, out, and in parameters.
A:
ref: Passes by reference, must be initialized, can be read and modifiedout: Passes by reference, doesnβt need initialization, must be assigned by methodin: Passes by readonly reference, prevents copying large structs while ensuring immutability
Q6: Why are strings immutable in C#?
A: Strings are immutable for:
- Thread safety (no locking needed)
- String interning (sharing identical strings)
- Security (strings canβt be modified after validation)
- Hashing (hash codes remain consistent)
Any βmodificationβ creates a new string object.
Common Pitfalls
- β Boxing value types in hot paths
- β Using mutable structs (confusing behavior when copied)
- β Large structs (> 16 bytes) causing excessive copying
- β Ignoring nullable reference type warnings
- β Using
outwhen the value is always needed (prefer return value)
Best Practices
- β
Use
readonly structfor immutable value types - β Enable nullable reference types in new projects
- β Use generic collections to avoid boxing
- β
Use
Span<T>for high-performance scenarios - β
Prefer
inparameter for large readonly structs - β Use records for data-centric types
Sources
- Microsoft Docs: Value Types
- Microsoft Docs: Memory and Span
- .NET Design Guidelines