Understanding the Struct vs Class Decision
Structs and classes look similar in C#, but they behave differently under the hood. Structs are value types that live on the stack, while classes are reference types allocated on the heap. This fundamental difference affects how your code performs, especially when you're creating thousands or millions of instances.
The decision between structs and classes isn't about which one is better. It's about matching the right tool to your specific needs. You'll get the best results when you understand how each type allocates memory, handles copying, and impacts garbage collection.
You'll learn how value types and reference types work differently, when structs provide real performance advantages, and practical guidelines for making the right choice in your applications.
How Structs and Classes Differ
Classes are reference types. When you create a class instance, the CLR allocates memory on the heap and gives you a reference to that memory. Passing a class to a method means passing the reference, not the actual data.
Structs are value types. They typically live on the stack when they're local variables, though they can exist on the heap when they're fields of a class or array elements. When you pass a struct to a method, C# copies the entire value by default.
public struct Point
{
public int X { get; set; }
public int Y { get; set; }
public Point(int x, int y)
{
X = x;
Y = y;
}
public double DistanceFromOrigin()
{
return Math.Sqrt(X * X + Y * Y);
}
}
// Usage shows value type behavior
Point p1 = new Point(10, 20);
Point p2 = p1; // Copies the entire struct
p2.X = 30; // Modifying p2 doesn't affect p1
Console.WriteLine($"p1.X: {p1.X}"); // Output: p1.X: 10
Console.WriteLine($"p2.X: {p2.X}"); // Output: p2.X: 30
public class Person
{
public string Name { get; set; }
public int Age { get; set; }
public Person(string name, int age)
{
Name = name;
Age = age;
}
}
// Usage shows reference type behavior
Person person1 = new Person("Alice", 30);
Person person2 = person1; // Copies the reference, not the data
person2.Age = 35; // Modifying person2 affects person1
Console.WriteLine($"person1.Age: {person1.Age}"); // Output: person1.Age: 35
Console.WriteLine($"person2.Age: {person2.Age}"); // Output: person2.Age: 35
With the struct, p2 gets a complete copy of p1's data. Changes to p2 don't affect p1. With the class, person2 gets a copy of the reference to the same object, so both variables point to identical data in memory.
Memory Allocation Patterns
Where your data lives in memory has significant performance implications. Stack allocation is fast because it's just moving a stack pointer. Heap allocation requires the garbage collector to find available space, track the object's lifetime, and eventually clean it up.
public struct Vector3
{
public float X, Y, Z;
public Vector3(float x, float y, float z)
{
X = x; Y = y; Z = z;
}
}
public class Transform
{
public Vector3 Position { get; set; } // Struct stored as part of class
public Vector3 Rotation { get; set; }
}
public void ProcessPhysics()
{
// These vectors live on the stack (fast allocation)
Vector3 velocity = new Vector3(1.0f, 0.0f, 0.0f);
Vector3 acceleration = new Vector3(0.1f, 0.0f, 0.0f);
// Calculate new velocity
velocity.X += acceleration.X;
velocity.Y += acceleration.Y;
velocity.Z += acceleration.Z;
// This transform lives on the heap
// But its Vector3 fields are embedded in the Transform object
Transform transform = new Transform
{
Position = velocity, // Copies the struct into the transform
Rotation = new Vector3(0, 45, 0)
};
}
// When processing thousands of objects per frame
public void UpdateGameObjects(List<GameObject> objects)
{
foreach (var obj in objects)
{
// Each temp calculation uses stack-allocated structs
Vector3 newPos = obj.Position;
newPos.Y -= 9.8f * Time.DeltaTime; // Gravity
obj.Position = newPos;
}
}
Local struct variables like velocity and acceleration allocate instantly on the stack and disappear automatically when the method returns. The Transform class allocates on the heap, but its Vector3 fields are embedded directly in the Transform object, not stored as separate heap allocations.
Structs and Immutability
Immutable structs avoid defensive copying and make your code safer. When a struct is immutable, the compiler can optimize better because it knows the value won't change. The readonly struct modifier enforces immutability at compile time.
// Immutable struct with readonly modifier
public readonly struct Money
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency)
{
Amount = amount;
Currency = currency ?? throw new ArgumentNullException(nameof(currency));
}
// Operations return new instances instead of modifying
public Money Add(Money other)
{
if (Currency != other.Currency)
throw new InvalidOperationException("Cannot add different currencies");
return new Money(Amount + other.Amount, Currency);
}
public Money Multiply(decimal factor)
{
return new Money(Amount * factor, Currency);
}
}
// Usage is clear and safe
Money price = new Money(29.99m, "USD");
Money tax = price.Multiply(0.08m);
Money total = price.Add(tax);
// Modern C# 10+ with record struct (immutable by default)
public readonly record struct Temperature(double Value, string Unit)
{
public Temperature ToFahrenheit()
{
if (Unit == "C")
return new Temperature(Value * 9 / 5 + 32, "F");
return this;
}
public Temperature ToCelsius()
{
if (Unit == "F")
return new Temperature((Value - 32) * 5 / 9, "C");
return this;
}
}
// Clean usage with record structs
Temperature temp = new(20, "C");
Temperature fahrenheit = temp.ToFahrenheit();
Console.WriteLine($"{fahrenheit.Value}°{fahrenheit.Unit}"); // 68°F
The readonly modifier prevents accidental mutations and enables better compiler optimizations. Record structs in C# 10+ give you immutability with less boilerplate, making them perfect for small value types like Money or Temperature.
Avoiding Copies with Ref Parameters
When you need to pass large structs without copying them, use ref, in, or out parameters. The in modifier passes a readonly reference, preventing both copying and modifications. This gives you the performance of reference semantics with the safety of immutability.
public struct Matrix4x4
{
// 16 floats = 64 bytes
public float M11, M12, M13, M14;
public float M21, M22, M23, M24;
public float M31, M32, M33, M34;
public float M41, M42, M43, M44;
// Bad - copies 64 bytes on every call
public static Matrix4x4 Multiply(Matrix4x4 left, Matrix4x4 right)
{
Matrix4x4 result = default;
// Matrix multiplication logic
return result;
}
// Better - passes by readonly reference (no copy)
public static Matrix4x4 Multiply(in Matrix4x4 left, in Matrix4x4 right)
{
Matrix4x4 result = default;
result.M11 = left.M11 * right.M11 + left.M12 * right.M21 +
left.M13 * right.M31 + left.M14 * right.M41;
// ... rest of multiplication
return result;
}
}
// Using in parameters for read-only access
public readonly struct BoundingBox
{
public Vector3 Min { get; }
public Vector3 Max { get; }
public BoundingBox(Vector3 min, Vector3 max)
{
Min = min;
Max = max;
}
// in parameter avoids copying while preventing modification
public bool Contains(in Vector3 point)
{
return point.X >= Min.X && point.X <= Max.X &&
point.Y >= Min.Y && point.Y <= Max.Y &&
point.Z >= Min.Z && point.Z <= Max.Z;
}
public bool Intersects(in BoundingBox other)
{
return !(other.Min.X > Max.X || other.Max.X < Min.X ||
other.Min.Y > Max.Y || other.Max.Y < Min.Y ||
other.Min.Z > Max.Z || other.Max.Z < Min.Z);
}
}
// Efficient usage in collision detection
public void CheckCollisions(List<BoundingBox> boxes)
{
for (int i = 0; i < boxes.Count; i++)
{
for (int j = i + 1; j < boxes.Count; j++)
{
// No copying, boxes[i] and boxes[j] passed by reference
if (boxes[i].Intersects(in boxes[j]))
{
HandleCollision(i, j);
}
}
}
}
The in modifier tells the compiler to pass a reference to the struct instead of copying it. This works great for larger structs where copying would be expensive, while still preventing the called method from modifying the original value.
Practical Guidelines for Choosing
Use structs when you're modeling simple values that make sense to copy. Good candidates include coordinates, measurements, colors, time durations, and mathematical types. The key is that copying the value should have the same meaning as copying the concept it represents.
Choose classes when you need identity semantics, where each instance is unique and modifications should be visible to all references. Classes are also better for large types, anything requiring inheritance, or types that benefit from polymorphic behavior.
// Good struct - represents a simple value
public readonly struct DateRange
{
public DateTime Start { get; }
public DateTime End { get; }
public DateRange(DateTime start, DateTime end)
{
if (end < start)
throw new ArgumentException("End must be after start");
Start = start;
End = end;
}
public int Days => (End - Start).Days;
public bool Contains(DateTime date) => date >= Start && date <= End;
}
// Good class - represents an entity with identity
public class User
{
public int Id { get; set; }
public string Username { get; set; }
public string Email { get; set; }
public DateTime CreatedAt { get; set; }
public List<string> Roles { get; set; }
public void AddRole(string role)
{
Roles ??= new List<string>();
if (!Roles.Contains(role))
Roles.Add(role);
}
}
// Good struct - small, immutable, frequently created
public readonly struct Percentage
{
private readonly double value;
public Percentage(double value)
{
if (value < 0 || value > 100)
throw new ArgumentOutOfRangeException(nameof(value));
this.value = value;
}
public double AsDecimal() => value / 100.0;
public static Percentage operator +(Percentage a, Percentage b) =>
new Percentage(Math.Min(a.value + b.value, 100));
}
// Should be a class - mutable with complex behavior
public class ShoppingCart
{
private readonly List<CartItem> items = new();
public decimal Total => items.Sum(i => i.Price * i.Quantity);
public int ItemCount => items.Sum(i => i.Quantity);
public void AddItem(Product product, int quantity)
{
var existing = items.FirstOrDefault(i => i.ProductId == product.Id);
if (existing != null)
existing.Quantity += quantity;
else
items.Add(new CartItem(product, quantity));
}
public void RemoveItem(int productId)
{
items.RemoveAll(i => i.ProductId == productId);
}
public void Clear() => items.Clear();
}
DateRange and Percentage are perfect structs. They're small, immutable, and represent single values. User and ShoppingCart need to be classes because they have identity and complex mutable state that you want to modify in place.
Avoiding Common Mistakes
Don't use structs for large types. The general guideline is to keep structs under 16 bytes when possible. Beyond that size, copying becomes expensive enough that you lose the performance benefits.
Watch out for boxing. When you cast a struct to an interface or object type, the runtime creates a heap-allocated copy. This defeats the purpose of using a struct in the first place.
Be careful with mutable structs. When you modify a struct that's a property of another struct, you might not be modifying what you think. The property getter returns a copy, so your changes apply to that copy instead of the original.
// Pitfall 1: Boxing defeats the purpose
public struct Point
{
public int X, Y;
}
void ProcessPoint(object obj) // Requires boxing
{
if (obj is Point p) // Creates a boxed copy on the heap
{
Console.WriteLine($"{p.X}, {p.Y}");
}
}
Point point = new Point { X = 10, Y = 20 };
ProcessPoint(point); // Boxing allocates on heap, causes GC pressure
// Better: Use generics to avoid boxing
void ProcessPoint<T>(T value) where T : struct
{
if (value is Point p)
{
Console.WriteLine($"{p.X}, {p.Y}");
}
}
// Pitfall 2: Mutable struct surprises
public struct MutablePoint
{
public int X { get; set; }
public int Y { get; set; }
}
public class Container
{
public MutablePoint Position { get; set; }
}
var container = new Container { Position = new MutablePoint { X = 10, Y = 20 } };
container.Position.X = 30; // Modifies a copy, not the stored value!
Console.WriteLine(container.Position.X); // Still 10, not 30
// Fix: Use readonly struct or make changes explicit
var pos = container.Position;
pos.X = 30;
container.Position = pos; // Now it works
// Pitfall 3: Struct with reference type fields
public struct DataWrapper
{
public int Id;
public string Name; // Reference type
public List<int> Values; // Reference type
}
DataWrapper w1 = new() { Id = 1, Name = "Test", Values = new List<int> { 1, 2 } };
DataWrapper w2 = w1; // Copies Id, but Name and Values are references
w2.Values.Add(3); // Modifies the same list that w1 references!
Console.WriteLine(w1.Values.Count); // 3, not 2
// This mixed behavior is confusing - use a class instead
These pitfalls show why careful consideration matters. Boxing defeats performance gains, mutable structs create confusing behavior, and mixing value and reference semantics leads to bugs. When in doubt, start with a class and switch to a struct only when you have a clear reason.