Fundamentals - Part 18

12/27/2025
5 minute read

What a struct is (and what it is not)

  • A struct is a value type. Assigning it, returning it, or passing it to a method (by default) copies its bits.
  • A class is a reference type. Assigning it copies a reference; both variables point at the same object.
  • A struct is not "always on the stack". Value types can live on the stack, inside other objects on the heap, inside arrays on the heap, or boxed as an object.

A quick mental model

var a = new Point(1, 2);
var b = a;      // copies (b is independent)
b = new Point(9, 9);

var c = new Person("Ada");
var d = c;      // copies a reference (c and d are the same object)
d.Name = "Grace";

Benefits of structs

  • Fewer allocations: used as locals or fields, structs avoid separate object allocations (and the GC work that follows).
  • Better locality: arrays and fields of structs store data inline, which can be more cache-friendly than chasing references.
  • "No null" by default: a Point is always a Point (unless you choose Point?), which can simplify invariants and call sites.
  • Value semantics match the domain: coordinates, ranges, IDs, small units of measure, and "result bundles" often read better as values.

Drawbacks of structs

  • Copy costs: every by-value pass/return/assignment copies the whole struct. If the struct is large, this adds up quickly.
  • Surprising mutation behavior: mutating a struct often mutates a copy, not "the one you meant".
  • Boxing: treating a struct as object or as an interface can allocate and copy (and it changes what you are mutating).
  • The default value exists: default(MyStruct) is always legal and does not call your constructors, so your invariants must tolerate it.
  • Limited OO features: no inheritance from other structs/classes, no finalizers, and interfaces can have performance implications.

Gotchas to watch for

Gotcha 1: property access returns a copy

This is the classic "why won't this compile?" moment:

public struct Point
{
    public int X;
    public int Y;
}

public sealed class Widget
{
    public Point Location { get; set; }
}

var w = new Widget();
w.Location.X = 10; // does not compile: you're trying to mutate a copy

The fix is to mutate a local and assign it back:

var loc = w.Location;
loc.X = 10;
w.Location = loc;

This copy-on-access behavior shows up in other places too (indexers, dictionary lookups, foreach).

Gotcha 2: mutating a struct in a collection usually mutates a copy

var points = new List<Point> { new Point { X = 1, Y = 2 } };

foreach (var p in points)
{
    p.X++; // compiles only if Point has mutable fields, but it mutates the foreach copy
}

Console.WriteLine(points[0].X); // still 1

If you need in-place updates, do it by index (or consider that a struct might not be the right shape):

for (var i = 0; i < points.Count; i++)
{
    var p = points[i];
    p.X++;
    points[i] = p;
}

Gotcha 3: boxing creates a copy (and often an allocation)

var p = new Point { X = 1, Y = 2 };

object o = p; // boxes: copies p into an object

Two implications:

  • It can allocate (which defeats one of the reasons you chose a struct).
  • Any mutation happens on the boxed copy, not on the original p.

Gotcha 4: default bypasses invariants

Even if you validate in a constructor, default(T) and new T() (for unconstrained generics) produce the all-zero value.

Rule of thumb: either make default a valid state, or make the type hard to misuse (typically by making it immutable and validating on creation).

Gotcha 5: mutable structs and hash-based collections do not mix

If a struct participates in hashing (dictionary key, hash set element), any mutation of fields used for Equals/GetHashCode after insertion can make the value "disappear" from the collection.

If you want to go deeper on equality, see [Fundamentals - Part 17](./Fundamentals - Part 17.md).

The usual fixes are:

  • Make the struct immutable (readonly struct), so mutating members do not exist.
  • If mutation is required, pass by ref (and accept the aliasing implications).

When to choose a struct (rules of thumb)

  • The type is small (commonly: a few primitive fields; avoid "bags of data" with many fields).
  • The type is logically a value (no identity, no lifecycle, no shared mutable state).
  • The type is immutable (prefer readonly struct or readonly record struct).
  • The type is used in hot paths where allocations matter, or in large arrays where locality matters.
  • You can tolerate default(T) existing without breaking invariants.

If you are unsure, start with a class. You can change a class to a struct later less often than you think, but changing a public struct to a class is a breaking change too, so treat the choice as part of your API design.

A good baseline: small, immutable, equatable

public readonly struct Temperature : IEquatable<Temperature>
{
    public decimal Celsius { get; }

    public Temperature(decimal celsius) => Celsius = celsius;

    public bool Equals(Temperature other) => Celsius == other.Celsius;
    public override bool Equals(object? obj) => obj is Temperature t && Equals(t);
    public override int GetHashCode() => Celsius.GetHashCode();

    public static bool operator ==(Temperature left, Temperature right) => left.Equals(right);
    public static bool operator !=(Temperature left, Temperature right) => !left.Equals(right);
}

Notes:

  • readonly struct prevents accidental mutation and helps the compiler avoid defensive copies.
  • IEquatable<T> avoids boxing and keeps equality fast and predictable.

Mutable structs: when they are appropriate (and when they are not)

Appropriate

Mutable structs are usually only a good idea when all of the following are true:

  • The struct is short-lived (typically a local variable in a tight loop).
  • The struct is not shared (not stored in a field where multiple callers observe it).
  • The struct is not used as a key in hash-based collections.
  • The struct is not passed around as object/interface (to avoid boxing and copy confusion).
  • The performance benefit is measurable and worth the complexity.

Examples of places where mutable structs can make sense:

  • An internal accumulator used inside one method to avoid repeated allocations.
  • A ref struct-style "view" over memory (like Span<T>), where stack-only rules prevent many misuse patterns.

Inappropriate

Avoid mutable structs when:

  • The type is part of a public API that many call sites will use in unpredictable ways.
  • The value will be stored in properties, collections, or captured closures, where copy semantics are easy to miss.
  • The type must maintain strong invariants (mutation makes it hard to keep the value always-valid).
  • You need polymorphism or identity semantics (use classes).

Final thoughts

  • Prefer structs for small, immutable values with clear value semantics.
  • Treat "struct vs class" as an API design decision, not a micro-optimization.
  • If you must use a mutable struct, keep it local, keep it unshared, and assume callers will hit every gotcha you forgot.
An error has occurred. This application may no longer respond until reloaded. Reload x