What The Struct

Patrick T Coakley 21 min read December 13, 2024 Software Development
[ #csharp #dotnet #programming #performance ]
what the struct

Overview

C# is pretty unique in that it is a managed language that also has a number of features that enable it to work more similarly to languages closer to the metal. Not only does it offer the ability to explicitly utilize pass-by-reference and pass-by-value semantics, it also allows you to declare a type as a value type or a reference type at the declaration site. For instance, if I create a struct in Go, Rust, and C and C++, it is a value type that can be passed by reference, but there is no way to explicitly create a reference type of struct; it is always a value type at its core and so its semantics are baked into the context of how you use it and not what it is. In C#'s case, a struct is a value type and a class is a reference type, so when you declare one of these you are determining the base semantics of those types (though there are ways to change them explicitly and implicitly, as we'll see later).

It's worth noting that the only other popular language that does the same thing is Swift, which also has a struct value type and a class reference type in the same manner. Languages on the JVM like Java (and by extension, Kotlin) don't yet have the ability to create custom value types and are limited to only certain primitives, but there has been work for quite some time with Project Valhalla. Project Valhalla aims to bring value types to the JVM, allowing for more efficient memory usage and performance improvements in the same manner that C# has accomplished. However, this is still early days and not yet a finished project.

So, in practice, what is different between a struct and a class? If we take a look at the IL of a simple type for both, we can see how the compiler treats them differently:

Struct Class
// original
struct Point2DStruct(int x, int y)
{
    public readonly int X = x;
    public readonly int Y = y;
}

// IL
.class private sealed sequential ansi beforefieldinit
  Point2DStruct
    extends [System.Runtime]System.ValueType
{

  .field public initonly int32 X

  .field public initonly int32 Y

  .method public hidebysig specialname rtspecialname instance void
    .ctor(
      int32 x,
      int32 y
    ) cil managed
  {
    .maxstack 8

    IL_0000: ldarg.0      // this
    IL_0001: ldarg.1      // x
    IL_0002: stfld        int32 Point2DStruct::X

    IL_0007: ldarg.0      // this
    IL_0008: ldarg.2      // y
    IL_0009: stfld        int32 Point2DStruct::Y
    IL_000e: ret

  } // end of method Point2DStruct::.ctor
} // end of class Point2DStruct




// original
class Point2DClass(int x, int y)
{
    public readonly int X = x;
    public readonly int Y = y;
}

// IL
.class private auto ansi beforefieldinit
  Point2DClass
    extends [System.Runtime]System.Object
{

  .field public initonly int32 X

  .field public initonly int32 Y

  .method public hidebysig specialname rtspecialname instance void
    .ctor(
      int32 x,
      int32 y
    ) cil managed
  {
    .maxstack 8

    IL_0000: ldarg.0      // this
    IL_0001: ldarg.1      // x
    IL_0002: stfld        int32 Point2DClass::X

    IL_0007: ldarg.0      // this
    IL_0008: ldarg.2      // y
    IL_0009: stfld        int32 Point2DClass::Y

    IL_000e: ldarg.0      // this
    IL_000f: call         instance void [System.Runtime]System.Object::.ctor()
    IL_0014: nop
    IL_0015: ret

  } // end of method Point2DClass::.ctor
} // end of class Point2DClass

There are a few key differences when we take a look at them side-by-side. First, the struct has the keyword sealed in its definition because a struct cannot inherit from other types, while the class does not because by default a class is not sealed. Another difference is the sequential keyword on the struct and the auto keyword on the class; this will be discussed later, but for now just know it has to do with the default physical layout of each type. Also, you'll notice that the Point2DStruct extends the System.ValueType while Point2DClass extends System.Object. Finally, because the struct is a value type its constructor just returns after creating the instance. However, the class needs to also call the base Object constructor before returning the instance. Otherwise, you can see that they mostly operate in the same way at the fundamental level, and that there isn't a ton of "magic" going on.

Why Use Structs?

The primary advantage of having the struct type is the potential for stack allocation, which can significantly improve performance by avoiding the heap and therefore any memory allocations. Aside from just the memory allocations themselves, avoiding any the garbage collector overhead at all is also another major reason why having access to value types is important. This can be especially important when trying to write cache-friendly code by avoiding reference types where possible, as modern CPUs heavily utilize their cache lines to get the best performance.

Although a struct is a value type, it's not a guarantee that it will be allocated to the stack: a struct is stored inline based on where it's being used (although, as we will see later, there are ways to help with this). For example, if you include a struct member in a class, it will be allocated on the heap. However, when declared as a local variable or as a method parameter you are able to take advantage of stack allocation. Additionally, if you box a value type (convert it to a reference type), it will also be allocated on the heap.

This also applies to a struct that implements an interface and is declared or cast as it. For example, if I create a method that takes IMyInterface as a parameter, then a struct will be boxed to that type. Instead, it makes sense to create a generic method that uses a where constraint. Using a where constraint enables static dispatch, through which the compiler generates a type-specific overload of the method and can avoid boxing by not using a vtable, unlike a method that takes the interface argument. In addition, using the where T : struct constraint in generic methods enforces the requirement that only a struct can be passed in and causing the compiler to error out when a reference type is passed in:

interface IPrint
{
    void Print();
}

struct Point2DStruct(int x, int y): IPrint
{
    public readonly int X = x;
    public readonly int Y = y;
    public void Print() => Console.WriteLine($"Hello from {nameof(Point2DStruct)}");
}

class Point2DClass(int x, int y): IPrint
{
    public readonly int X = x;
    public readonly int Y = y;
    public void Print() => Console.WriteLine($"Hello from {nameof(Point2DClass)}");
}

static class Program
{
    static void Main()
    {
        var pStruct = new Point2DStruct(10, 20);
        var pClass = new Point2DClass(10, 20);

        WithoutStructConstraintExample(pStruct);
        WithoutStructConstraintExample(pClass);

        WithConstraintExample(pStruct);
        WithConstraintExample(pClass);

        WithStructConstraintExample(pStruct);
        WithStructConstraintExample(pClass); will error with "type 'Point2DClass' must be a non-nullable value type in order to use it as parameter 'T'"
    }

    static void WithoutStructConstraintExample(IPrint t) => t.Print(); // boxing occurs
    static void WithConstraintExample<T>(T t) where T : IPrint => t.Print(); // no boxing
    static void WithStructConstraintExample<T>(T t) where T : struct, IPrint => t.Print(); // no boxing and only takes non-nullable value types that also adhere to IPrint
}

When using pattern matching with a struct implementing an interface you also can't avoid boxing when you pass it in as a parameter, but you can still pattern on the struct directly without any boxing as usual:

public class Program
{
    interface IPoint
    {
        int X { get; }
        int Y { get; }
    }

    struct Point2D(int x, int y) : IPoint
    {
        public int X { get; } = x;
        public int Y { get; } = y;
    }

    private static void PatternMatching(Point2D point2D) // no boxing
    {
        switch (point2D)
        {
            case { X: > 0, Y: > 0 }:
                Console.WriteLine($"Positive point: ({point2D.X}, {point2D.Y})");
                break;
            case { X: < 0, Y: < 0 }:
                Console.WriteLine($"Negative point: ({point2D.X}, {point2D.Y})");
                break;
            default:
                Console.WriteLine("Zero point");
                break;
        }
    }

    private static void PatternMatchingIPoint(IPoint point2D)
    {
        switch (point2D)
        {
            case { X: > 0, Y: > 0 }:
                Console.WriteLine($"Positive point: ({point2D.X}, {point2D.Y})");
                break;
            case { X: < 0, Y: < 0 }:
                Console.WriteLine($"Negative point: ({point2D.X}, {point2D.Y})");
                break;
            default:
                Console.WriteLine("Zero point");
                break;
        }
    }

    public static void Main()
    {
        var p = new Point2D(10, 20);
        PatternMatching(p);
        PatternMatchingIPoint(p); // boxing occurs here at the call site
    }
}

Outside of the plain old struct, you can also declare a readonly struct to make it immutable. It's also compatible with the record keyword, which, if you're not familiar with it, essentially gives it built-in equality through IEquatable. You can even create a readonly record struct to combine the best of both worlds:

struct Point2D(int X, int Y); // mutable value type
readonly struct Point2DReadOnly(int X, int Y); // immutable value type
record struct Point2DRecord(int X, int Y); // mutable value type with built-in equality using IEquatable
readonly record struct Point2DReadOnlyRecordStruct(int X, int Y); // an immutable value type with built-in equality using IEquatable

Using immutability with struct types helps guide the compiler to not make defensive copies. Defensive copies are just a way to categorize copies that are made before passing something to a method to ensure it won't be modified. While you might see programmers use a defensive copy as a pattern for maintaining immutability, the C# compiler might automatically create one based on the the type being passed in, as well as the way it is passed in. Based on this Microsoft dev blog post, we can see that generated defensive copies can be triggered by a few rules, but the primary distinction is whether or not the struct is marked as readonly. For situations where you know the type is immutable, using a readonly struct or readonly record struct gives you free performance benefits and should be the default.

The only major restrictions on the use of structs are the fact that they are unable to inherit from or be inherited from other types and that they lack the ability to implement finalizers. One other one that can be confusing is that you can't initialize fields in a struct without a constructor:

struct Point2DStruct // Error: Struct with field initializers must include an explicitly declared constructor
{
    public readonly int X = 10;
    public readonly int Y = 10;
}

struct Point2DStruct() // Just adding this fixes the issue
{
    public readonly int X = 10;
    public readonly int Y = 10;
}

class Point2DClass  // Works fine with a class
{
    public readonly int X = 10;
    public readonly int Y = 10;
}

Other than these, a struct can do most of the same stuff as a class, including take advantage of parameter modifiers. By default, since a value type is passed as a copy, you aren't able to modify the original, but with the ref parameter modifier you're able to mutate it inside of a method by passing it as a reference. If you want to maintain the immutable properties of passing in a struct but want to take advantage of pass-by-reference, you can use both ref readonly or in. The main difference here is that in enforces read-only access and allows the compiler to optimize the call by passing in a copy, but ref readonly guarantees that your struct is a reference. At the call site you would use the in or ref keyword (it makes no difference), like this:

public static void Process(ref readonly DataStruct data)
{
   // Do things
}
...

Process(in data);
Process(ref data);

Memory Usage

One of the unique points about the struct type compared to a class is that there is zero overhead on the type itself. What does that mean? Well, every class also includes something called an object header (read more here) that is used to track information about the object. This means that a struct will inherently take less memory than an equivalent class, but there are other factors that could impact the total size at time of creation. This includes boxing since that allocates it to the heap and therefore needs an object header.

Examples for the rest of the article are going to based on a 64-bit CPU, but your computer might be different, so be aware.

Alignment & Padding

Another factor is the concept of alignment, which describes how data is laid out in memory for the type based on its total size and order of fields. Alignment is somewhat tied to the CPU itself and how it loads and stores memory; generally you want to fit data in word-sized chunks. Most compilers will insert extra data through "padding", which is where it will insert hidden data fields to fill in any gaps and make a type fit more neatly inside a CPU word.

While this is an automatic process in C#, you can still use the StructLayout attribute (which, despite its name, can be applied to a class as well) to manually do it. The compiler will automatically choose a LayoutKind based on the type definition and analyzing the type. By default a struct will use LayoutKind.Sequential and a class will use LayoutKind.Auto.

With all of this in mind, let's take a look at a simple example of what identical struct and class sizes might look like:

// [StructLayout(LayoutKind.Sequential)] by default
struct Vector3Struct // 12 bytes total
{
    public float X { get; set; } // 4 bytes because in C# a `float` is always a single-precision floating point number
    public float Y { get; set; } // 4 bytes
    public float Z { get; set; } // 4 bytes
}

// [StructLayout(LayoutKind.Auto)] implicitly
class Vector3Class // 24 bytes total on a 64-bit CPU
{
    // Object header:
    // - Method table pointer 8 bytes
    // - Sync block 8 bytes
    public float X { get; set; } // 4 bytes
    public float Y { get; set; } // 4 bytes
    public float Z { get; set; } // 4 bytes
}

Despite being exactly the same in terms of fields, the struct version is smaller because value types don't have an object header. Just this small difference can have a larger impact on memory usage. This can be especially true in a hot loop where you're instantiating a large number of some type; creating and destroying the struct version is much more insignificant because it can live and die on the stack, but the class needs to deal with garbage collection.

Sometimes you need to explicitly handle the layout manually because either the compiler isn't able to optimize the default or you are working with native memory that the compiler doesn't know enough about to apply the best layout. In this case you can manually create a layout:

[StructLayout(LayoutKind.Sequential)]
public struct PaddedStruct // 12 bytes total after padding
{
    public byte A;   // 1 byte
    // Padding 3 bytes
    public int B;    // 4 bytes
    public short C;  // 2 bytes
    // Padding 2 bytes
}

[StructLayout(LayoutKind.Explicit)]
public struct ExplictLayoutStruct // 8 bytes total
{
     [FieldOffset(0)] public byte A;
     [FieldOffset(1)] public int B;
     [FieldOffset(5)] public short C;
}

public class Program
{
    public static void Main()
    {
        Console.WriteLine($"Size of {nameof(PaddedStruct)}: {Unsafe.SizeOf<PaddedStruct>()} bytes");  // 12 bytes
        Console.WriteLine($"Size of {nameof(ExplictLayoutStruct)}: {Unsafe.SizeOf<ExplictLayoutStruct>()} bytes"); // 8 bytes
    }
}

LayoutKind.Sequential simply uses the order of the fields and pads where needed, whereas LayoutKind.Auto might re-order the fields to produce better alignment. LayoutKind.Explicit, however, allows you to actually manually write the memory layout of the fields, including the offset that each field will have. To use it you also need to use the FieldOffset attribute to let you say where the field should be placed. For example, your first field would be at 0 because it's the first, but then you would calculate total from there on to place each FieldOffset. So if we have a byte of 1 byte that starts at FieldOffset(0), then the next field would be at 1, and if the next is an int that is 4 bytes, then the next field is at 5, and so on. Doing this incorrectly can lead to undefined behavior, so it's best to use when you already know exactly what the memory layout is and have a better idea of what to expect.

A Simple Benchmark

Just for fun, let's do a simple benchmark between the two, as well as System.Numerics.Vector3 as a control since it is a heavily-optimized, SIMD-capable implementation of a Vector3 using a struct:

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Numerics;

public readonly record struct Vector3Struct(float X = 0, float Y = 0, float Z = 0)
{
    public static Vector3Struct Cross(Vector3Struct lhs, Vector3Struct rhs)
        => new(
            lhs.Y * rhs.Z - lhs.Z * rhs.Y,
            lhs.Z * rhs.X - lhs.X * rhs.Z,
            lhs.X * rhs.Y - lhs.Y * rhs.X
        );
}

public sealed record Vector3Class(float X = 0, float Y = 0, float Z = 0)
{
    public static Vector3Class Cross(Vector3Class lhs, Vector3Class rhs)
        => new(
            lhs.Y * rhs.Z - lhs.Z * rhs.Y,
            lhs.Z * rhs.X - lhs.X * rhs.Z,
            lhs.X * rhs.Y - lhs.Y * rhs.X
        );
}

[MemoryDiagnoser]
[WarmupCount(3)]
[IterationCount(10)]
public class StructVsClassBenchmark
{
    [Params(10_000, 100_000, 1_000_000)]
    public int ArraySize { get; set; }

    private (float X, float Y, float Z)[] _inputArray;

    [GlobalSetup]
    public void Setup()
    {
        _inputArray = new (float X, float Y, float Z)[ArraySize];
        var random = new Random(2024);

        for (int i = 0; i < ArraySize; i++)
        {
            _inputArray[i] = (random.NextSingle(), random.NextSingle(), random.NextSingle());
        }
    }

    [Benchmark]
    public Vector3 SystemNumericsVector3CrossProduct()
    {
        var result = new Vector3();
        foreach (var (x, y, z) in _inputArray)
        {
            var v = new Vector3(x, y, z);
            result = Vector3.Cross(result, v);
        }

        return result;
    }

    [Benchmark]
    public Vector3Struct Vector3StructCrossProduct()
    {
        var result = new Vector3Struct();
        foreach (var (x, y, z) in _inputArray)
        {
            var v = new Vector3Struct(x, y, z);
            result = Vector3Struct.Cross(result, v);
        }

        return result;
    }

    [Benchmark]
    public Vector3Class Vector3ClassCrossProduct()
    {
        var result = new Vector3Class();
        foreach (var (x, y, z) in _inputArray)
        {
            var v = new Vector3Class(x, y, z);
            result = Vector3Class.Cross(result, v);
        }

        return result;
    }
}

public class Program
{
    public static void Main(string[] args)
    {
        BenchmarkRunner.Run<StructVsClassBenchmark>();
    }
}

To give a brief overview, this code performs a cross product using an array of randomly-generated input floats, and captures the total in a local variable to make sure nothing is inlined. Like the System.Numerics.Vector3 implementation of .Cross, we will return a new copy every time; this is common practice in vector and matrix math libraries. While you might not instantiate a new variable every time you do this sort of operation, this is often the case and should offer a reasonable baseline. Finally, there are certainly many optimizations that could be done, but I wanted to keep the comparisons simple and straightforward; for some possible gains you could utilize parameter modifiers and play around with using ref structs.

Here are the results from my M2 Ultra Mac Studio:

MethodArraySizeMeanErrorStdDevGen0Allocated
SystemNumericsVector3CrossProduct1000036.87 us0.508 us0.336 us--
Vector3StructCrossProduct1000040.14 us0.924 us0.611 us--
Vector3ClassCrossProduct1000089.97 us0.569 us0.339 us76.4160640032 B
SystemNumericsVector3CrossProduct100000366.31 us2.367 us1.566 us--
Vector3StructCrossProduct100000387.15 us2.511 us1.661 us--
Vector3ClassCrossProduct100000888.40 us21.114 us13.966 us764.64846400033 B
SystemNumericsVector3CrossProduct10000003,534.68 us42.430 us25.250 us-2 B
Vector3StructCrossProduct10000003,882.09 us17.121 us8.955 us-3 B
Vector3ClassCrossProduct10000008,828.78 us251.901 us166.617 us7640.625064000044 B

As expected, the System.Numerics.Vector3 leads the way in performance, but the naive implementation of Vector3Struct is fairly close behind. What's interesting is there were no extra optimizations or attributes applied to achieve better performance, and yet it stays fairly close to the official implementation. Since we wanted to compare value types and reference types head-to-head, we can see that the Vector3Class results in consistently-scaling allocations for each array size and is almost 2-2.5x slower on average than the other two. You'll notice there are some small allocations within a few bytes on the struct versions. This could be due to temporary boxing operations or other implicit allocations in the benchmark setup.

Another thing to note is that we just used floats for our members, which are value types, but for reference type members like string and arrays, the amount of bytes used are the word size because, again, they are being passed around as references. You can roughly calculate the number of bytes each field has and total them against the cost of the word size to use as a baseline, and this holds true for also getting an idea of whether or not your data type will be impacted by padding. As a general rule of thumb, try to make a struct smaller than or equal to your CPU word size. This is because modern CPUs can make heavy use of their cache lines and being able to leverage cache is a huge part of getting the most performance out of computers today; there will always be exceptions so, as always, benchmark and compare.

Which One Should I Choose?

So, when should you pick a struct over a class? Here's a handy guide straight from the source. Generally speaking, it's great for small types that are cheap to create and destroy, especially if they're going to be used in arrays. Examples include many members of System.Numerics. That being said, context matters. Just because you use a struct doesn't mean you will magically gain any performance. In fact, recklessly using a struct over a class and running into situations where it's getting boxed can create more overhead than just using a class in the first place. For most things, using a class is probably the right choice.

When you do need to use a struct, if possible try to make it a readonly record struct to take advantage of some of the performance benefits, but in some cases, like the aforementioned System.Numerics, you might still need mutability or have a need to be compatible with older versions of .NET and are unable to do so. Still, you get a lot out of just using a struct over a class in certain situations, so always benchmark and think about the tradeoffs before deciding.

The Next Generation

C# 7.2 added the ability to create a ref struct type, which is a struct but with the guarantee from the compiler that it will be allocated on the stack and will never get moved to the heap. It would have been nice to use a different keyword to call them because the overloaded use of ref can be confusing and makes it sound like we're creating a reference type struct, or a struct that can only be passed by reference. Just remember that a ref struct cannot be boxed or relocated to the heap, unlike a normal struct, and the compiler will enforce quite a few restrictions on its use, though over the last few releases of C# they have begun to remove some of those limitations. The ones that still remain include not allowing them inside of lambdas, not allowing them to be boxed, not being able to use ref structs as members inside of non-ref structs due to possible heap allocation , and not allowing the use of a ref struct in generics, including arrays of ref struct types or iterators since they are still reference types. Additionally, you weren't able to use them inside of async methods in the past, but with C# 13 you're able to use them as long as they are in a different context as await, meaning you can pass ref structs to async methods, but they cannot be part of the method's async state machine.

The most common ref struct types you will likely see are Span<T> and ReadOnlySpan<T>. Both of these are used heavily in modern .NET in places where you need to operate on a piece of memory but want a cheap, type-safe interface to work inside of a memory-safe context. Normally, when you need to work on unmanaged memory (memory allocated outside of the .NET runtime) you generally need to use unsafe, which is a way for C# to work with pointers to pieces of memory. For the first use-case, a simple example would be string manipulation. Normally, if I want to grab a part of a string I would need to use .Substring or the range indexer syntax. The string type is immutable in C#, so any time you do apply modifications, a new copy is created; in our case of .Substring, a new string is created of the original but sliced to the the indices I provide.

void SubStringExample()
{
    var s = "Hello, World!";
    var substr = s.Substring(0, 5); // a new string of "Hello" is created
    // Do some work...
}

This is a simple example, but imagine you need to do this on a large very large piece of text and don't want to create a new copy just to work on a subset of characters. Instead, you could just use the .AsSpan method to slice the string into a stack-allocated ReadOnlySpan<char> operating as a view into the memory of original string, which lives on the heap, without having to create a new string through a type-safe interface:

void SpanExample()
{
    var s = "Hello, World!";
    var substr = s.AsSpan()[..5]; // a ReadOnlySpan<char> is created on the stack with a view to s, which is on the heap
    // Do some work...
}

The major breakthrough is using the Span types as views into existing memory, including both managed memory and unmanaged memory. As I mentioned before, normally when you wanted to work with a piece of memory from outside the .NET runtime you would have to reach for pointers within an unsafe context, usually needing fixed to pin a pointer's reference in memory from being relocated by the garbage collector. Using fixed effectively pins a pointer to a fixed location which means you can't re-assign it, but it doesn't stop you for doing things that could cause your program to crash from insecure coding practices like pointer arithmetic. And the lack of bounds checking opens up the possibility of a buffer overflows. Doing things that cause undefined behavior can lead to crashes that can be hard to debug due to non-deterministic behavior.

static unsafe void UnsafeExample()
{
    byte[] array = [1, 2, 3];

    fixed (byte* firstBytePtr = &array[0]) // pins the pointer to the same memory location inside the local context
    {
        *firstBytePtr = 42;  // rewrite the first byte to 42
        firstBytePtr += 1; // CS1656: Cannot assign to 'firstBytePtr' because it is a 'fixed variable'
        
        // The danger zone:
        firstBytePtr[44] = 2; // I have zero bounds-checking here and can write outside the array
        *(firstBytePtr + 50) = 2; // Doing pointer arithmetic could lead to undefined behavior
    }
}

static void SpanExample()
{
    byte[] array = [1, 2, 3];
    Span<byte> span = array; // create a Span<byte> view over the array
    span[0] = 42; // the same operation as above without the need for unsafe, fixed, or pointers
    try
    {
        span[44] = 2; // doing this will throw an IndexOutOfRangeException, unlike the `unsafe` example
    }
    catch(IndexOutOfRangeException e)
    {
        Console.Error.WriteLine($"Error: {e.Message}");
        // handle exception safely
    }
}

Again, this is a trivial example, but not only is the Span version much simpler, it's also much safer and leaves less room for errors. Now imagine the memory is from outside of the .NET runtime. Using a Span allows you to work on these pieces of memory with proper bounds checking, protection against buffer overflows, and not having to deal with pointer semantics or improper usage through pointer arithmetic. Perhaps most importantly is that working in a safe context provides you with the ability to recover from errors through exception handling; in an unsafe block you are open to irrecoverable errors that can crash your program. In addition, the Span types are able to work with stackalloc, allowing you to allocate blocks of memory directly on the stack. These features make them a very important part of making modern C# a very fast programming language that is able to handle situations other similar languages aren't able to do without having to jump through a lot of hoops or eschewing memory-safety.

Conclusion

Hopefully this helped you get a better understanding of struct and some of the its related keywords, but there is more that wasn't covered that would take a lot more time to write about. For most people writing code out there, especially those working on web applications using ASP.NET or console applications, the difference between a struct and a class will likely not have a huge impact on the overall performance or architecture of your apps, but it's good to know a little bit about how everything works for those situations where it can make a difference. That being said, a lot of the libraries you are calling in your application code are likely using things like Span under the hood and are probably providing better performance over time as the ongoing work to enhance ref structs and remove limitations moves forward. Also, if you're doing game development or other performance-sensitive kinds of programming, you'll likely come into contact with them a lot more and it's more important to understand the situation better.