Generic Structs and Heap Allocation in C

Generic structs in C# allow developers to build reusable, type-safe value types without sacrificing runtime performance. Understanding their interaction with memory is essential for writing low-allocation, high-throughput applications. This article explains what generic structs are, how the .NET Common Language Runtime (CLR) handles them in memory, and the specific conditions under which they prevent or inadvertently cause heap allocations.

What Are Generic Structs?

A generic struct is a value type defined with one or more type parameters. Like standard structs, generic structs are typically allocated inline wherever they are declared—either on the execution stack for local variables or within the heap memory of an enclosing reference type.

public struct KeyValuePair<TKey, TValue>
{
    public TKey Key { get; }
    public TValue Value { get; }

    public KeyValuePair(TKey key, TValue value)
    {
        Key = key;
        Value = value;
    }
}

The CLR handles generic value types differently than generic reference types (classes). When a generic struct is instantiated with a value type (such as int or float), the Just-In-Time (JIT) compiler generates a dedicated, specialized machine code implementation. If it is instantiated with a reference type (like string or custom classes), the JIT shares the code implementation among all reference-type arguments because references are pointer-sized values, but the struct itself remains a value type.

How Generic Structs Affect Heap Allocation

1. Eliminating Boxing Through Type Specialization

The primary performance benefit of generic structs is the elimination of boxing. In non-generic C# code, passing a value type to an abstraction often requires converting it to an object or a non-generic interface, copying the data from the stack to a newly allocated object on the managed heap.

Because the JIT creates specialized native code for each unique value type parameter, operations on KeyValuePair<int, double> run without allocating any heap memory. Fields of type T are stored directly within the struct layout, maintaining value-type semantics and keeping allocations at zero.

2. Interface Calls and Generic Constraints

When a struct implements an interface, invoking that interface can inadvertently cause a heap allocation if not handled via generic constraints.

  • Allocating (Boxing): Passing a struct to a parameter typed directly as an interface causes boxing.

    public void Process(IDisposable disposable) // Boxing occurs if a struct is passed
    {
        disposable.Dispose();
    }
  • Non-Allocating: Using a generic constraint allows the JIT to invoke interface methods directly via the struct's method table without boxing.

    public void Process<T>(T disposable) where T : struct, IDisposable
    {
        disposable.Dispose(); // No boxing; direct call via specialized code
    }

3. Array and Collection Allocations

When generic structs are stored in arrays or generic collections like List<T>, they are packed contiguously in memory.

For an array of structs (MyStruct<int>[]), the runtime allocates a single block of heap memory for the array object itself, and all struct elements reside inline within that continuous memory block. This contrasts with an array of reference types (MyClass<int>[]), where the array contains only pointers, requiring separate heap allocations for each object instance.

Common Scenarios Where Generic Structs Cause Heap Allocations

Despite being value types, generic structs can still trigger heap allocations in several scenarios:

  • Implicit Boxing to object: Calling non-overridden methods inherited from System.Object (such as ToString(), Equals(), or GetHashCode()) causes the runtime to box the struct.
  • Calling GetType(): The GetType() method is non-virtual and cannot be overridden; invoking it on a struct always allocates an object on the heap.
  • Captured Variables in Closures: If a generic struct is captured by a lambda expression, local function, or used within an async state machine, the compiler elevates the variable to a field inside a compiler-generated heap-allocated class.
  • Casting to ValueType or Enum: Any conversion from a generic struct to System.ValueType or System.Enum triggers boxing.

Best Practices for Zero Allocation

  1. Implement IEquatable<T>: Always implement IEquatable<T> and override GetHashCode() and Equals(object) on generic structs to avoid boxing during equality checks and hashing.
  2. Use the readonly Modifier: Mark generic structs as readonly struct whenever possible to guarantee immutability and allow the compiler to eliminate defensive copying.
  3. Pass by Reference with in: For larger generic structs, pass them using the in parameter modifier or ref readonly to avoid copying data across method boundaries without moving the data to the heap.