Value Types Explained
Myth: Value types always live on the stack and reference types always live on the heap. Reality: The CLR decides where values live based on context. A value type declared as a local variable typically lives on the stack, but when it's a field in a class or part of an array, it lives on the heap right alongside reference types.
What actually matters is how value types behave when you copy them. Unlike reference types that share a single object through multiple references, value types copy their entire contents. When you assign one struct to another or pass it to a method, you get an independent copy with its own memory.
You'll learn how structs and enums work under the hood, when boxing happens and how to avoid it, how to write efficient value types, and the trade-offs between structs and classes for your specific scenarios.
Building Custom Value Types with Structs
Structs let you create custom value types that behave like integers or decimals. They're perfect for small, immutable data structures where you want copy semantics instead of reference sharing. The compiler guarantees that every struct has a parameterless constructor that initializes all fields to their default values.
When you design a struct, think about its size and mutability. Small structs under 16 bytes perform well because copying them costs less than following a reference to the heap. Larger structs can actually hurt performance because every assignment, parameter pass, and return statement copies all that data.
Here's a practical example showing a Point struct that represents coordinates in 2D space. This type makes sense as a struct because it's small, immutable, and represents a single conceptual value rather than an entity with identity.
public readonly struct Point
{
public double X { get; init; }
public double Y { get; init; }
public Point(double x, double y)
{
X = x;
Y = y;
}
public double DistanceFromOrigin()
{
return Math.Sqrt(X * X + Y * Y);
}
public Point Translate(double dx, double dy)
{
return new Point(X + dx, Y + dy);
}
public override string ToString()
{
return $"({X}, {Y})";
}
}
// Usage
var p1 = new Point(3, 4);
var p2 = p1; // Creates a complete copy
p2 = p2.Translate(1, 1);
Console.WriteLine($"p1: {p1}"); // Output: p1: (3, 4)
Console.WriteLine($"p2: {p2}"); // Output: p2: (4, 5)
Notice the readonly keyword on the struct definition. This prevents accidental mutations and lets the compiler optimize better. The init keyword on properties allows construction-time initialization while maintaining immutability afterward. When p2 gets modified through Translate, it creates a new Point rather than changing the existing one.
Working with Enumerations
Enums are value types that define a set of named constants. They improve code readability by replacing magic numbers with meaningful names. Under the hood, every enum is backed by an integral type, which defaults to int but can be byte, short, long, or any other integer type.
The compiler treats enums as distinct types, so you can't accidentally pass an int where an enum is expected without an explicit cast. This type safety prevents bugs where you might pass the wrong constant value. Modern C# also supports flags enums for representing combinations of options using bitwise operations.
public enum OrderStatus
{
Pending = 0,
Processing = 1,
Shipped = 2,
Delivered = 3,
Cancelled = 4
}
[Flags]
public enum FilePermissions
{
None = 0,
Read = 1,
Write = 2,
Execute = 4,
ReadWrite = Read | Write,
All = Read | Write | Execute
}
public class Order
{
public int Id { get; set; }
public OrderStatus Status { get; set; }
public bool CanBeCancelled()
{
return Status == OrderStatus.Pending ||
Status == OrderStatus.Processing;
}
}
// Usage
var order = new Order { Id = 1, Status = OrderStatus.Pending };
Console.WriteLine(order.CanBeCancelled()); // True
var permissions = FilePermissions.Read | FilePermissions.Write;
bool canWrite = (permissions & FilePermissions.Write) != 0;
Console.WriteLine(canWrite); // True
The Flags attribute signals that the enum supports bitwise combinations. Always use powers of two for flag values so each flag occupies a unique bit position. You can combine flags with the OR operator and check for specific flags with the AND operator.
Understanding Boxing and Unboxing
Boxing happens when the runtime converts a value type to a reference type, typically when you assign it to an object variable or pass it to a method expecting object. The CLR allocates a new object on the heap, copies the value type's data into it, and returns a reference to that heap allocation.
Unboxing reverses the process by extracting the value back from the boxed object. This operation requires an explicit cast and throws InvalidCastException if the types don't match. Both operations have performance costs: boxing allocates memory and creates garbage collection pressure, while unboxing requires type checking.
Modern .NET code rarely needs boxing because generics provide type-safe collections. However, reflection, legacy APIs, and certain serialization scenarios still trigger boxing. Here's how it works and how to avoid it.
// Boxing example
int value = 42;
object boxed = value; // Boxing allocates heap memory
int unboxed = (int)boxed; // Unboxing requires cast
// Performance problem: boxing in loops
var list = new ArrayList(); // Non-generic collection
for (int i = 0; i < 1000; i++)
{
list.Add(i); // Boxes each integer!
}
// Solution: use generic collections
var genericList = new List<int>();
for (int i = 0; i < 1000; i++)
{
genericList.Add(i); // No boxing
}
// Another common boxing scenario
int x = 10;
Console.WriteLine($"Value: {x}"); // No boxing with string interpolation
Console.WriteLine("Value: " + x); // Boxes x for concatenation
// Avoid boxing with interfaces
interface IValue
{
int GetValue();
}
struct ValueWrapper : IValue
{
private int _value;
public ValueWrapper(int value) => _value = value;
public int GetValue() => _value;
}
// Calling through interface boxes the struct
IValue wrapper = new ValueWrapper(5); // Boxing occurs here
int result = wrapper.GetValue();
The generic List avoids boxing because the compiler generates specialized code for List<int> that works directly with integers. String interpolation in modern C# also avoids boxing for common types. When you cast a struct to an interface, boxing happens because interfaces are reference types and the CLR needs to create a reference to pass around.
Common Mistakes to Avoid
Mutable structs create confusing behavior because modifying a copy doesn't affect the original. If you write a struct with public setters, calling a method that modifies the struct on a property getter creates a copy that gets immediately discarded. Use readonly struct and init properties to prevent this entire class of bugs.
Large structs hurt performance despite being value types. The .NET design guidelines recommend keeping structs under 16 bytes. A large struct passed to methods or returned from functions copies all that data every time. Consider using a class instead, or pass large structs by ref readonly to avoid copying.
Comparing structs for equality requires explicit implementation or you'll get slow reflection-based comparison. The default Equals method uses reflection to compare all fields, which is correct but slow. Override Equals and GetHashCode, or better yet, implement IEquatable<T> for type-safe comparison without boxing.
Nullability confusion occurs when developers forget that structs can't be null unless wrapped in Nullable<T>. A regular int parameter can't be null, but int? can. Make your intent clear by using nullable value types when you need to represent the absence of a value, not by using magic numbers like -1 or 0.
Try It Yourself
Here's a complete example demonstrating value types, boxing behavior, and proper struct design. You'll create a small benchmark comparing different approaches.
Steps
- dotnet new console -n ValueTypeDemo
- cd ValueTypeDemo
- Replace Program.cs with the code below
- dotnet run
using System.Diagnostics;
var sw = Stopwatch.StartNew();
// Demonstrate struct copying
var p1 = new Point(10, 20);
var p2 = p1;
Console.WriteLine($"p1: {p1}, p2: {p2}");
Console.WriteLine($"Are equal: {p1.Equals(p2)}");
// Demonstrate boxing cost
int iterations = 1_000_000;
object[] objects = new object[iterations];
sw.Restart();
for (int i = 0; i < iterations; i++)
{
objects[i] = i; // Boxing
}
Console.WriteLine($"Boxing {iterations} ints: {sw.ElapsedMilliseconds}ms");
// Demonstrate enum usage
var order = new Order(OrderStatus.Shipped);
Console.WriteLine($"Order status: {order.Status}");
Console.WriteLine($"Can cancel: {order.CanCancel()}");
readonly struct Point
{
public double X { get; init; }
public double Y { get; init; }
public Point(double x, double y) { X = x; Y = y; }
public override string ToString() => $"({X}, {Y})";
}
enum OrderStatus { Pending, Processing, Shipped, Delivered }
record Order(OrderStatus Status)
{
public bool CanCancel() => Status is OrderStatus.Pending
or OrderStatus.Processing;
}
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
What you'll see
The output shows point copying, equality comparison, boxing performance impact, and enum-based logic. The boxing operation takes measurable time because it allocates a million objects on the heap.
Choosing Between Structs and Classes
Use a struct when you're modeling a single value that's small and immutable. Types like Point, Rectangle, Color, or Currency are excellent struct candidates because they represent values rather than entities. Choose a class when the type represents an entity with identity, needs inheritance, or exceeds 16 bytes in size.
Consider copying cost versus allocation cost. Structs avoid heap allocations but pay for copying. Classes allocate once on the heap but multiple variables can share the same instance. If you pass the type around frequently, classes often perform better despite the allocation overhead.
Immutability matters more for structs than classes. A mutable struct creates surprising behavior because modifications affect copies, not the original. Make structs readonly and use init properties or constructor-based initialization. If you need mutability, that's a strong signal to use a class instead.
Default constructors always exist for structs. You cannot prevent someone from writing new Point() which zeros all fields. Design your structs so the all-zeros state is valid, or accept that consumers might create invalid instances. Classes give you more control over construction.