What The Struct
Patrick T Coakley 21 min read December 13, 2024 Software Development[ #csharp #dotnet #programming #performance ]
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 |
---|---|
|
|
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:
static
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
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:
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 struct
s 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:
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
...
;
;
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
// [StructLayout(LayoutKind.Auto)] implicitly
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:
public
public
public
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 .;
using .;
using .;
public readonly record struct Vector3Struct(float X = 0, float Y = 0, float Z = 0)
public sealed record
public class StructVsClassBenchmark
{
public int ArraySize { get; set; }
private _inputArray;
public void
{
_inputArray = ;
var random = ;
for
}
public Vector3
{
var result = ;
foreach
return result;
}
public Vector3Struct
{
var result = ;
foreach
return result;
}
public Vector3Class
{
var result = ;
foreach
return result;
}
}
public
To give a brief overview, this code performs a cross product using an array of randomly-generated input float
s, 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 struct
s.
Here are the results from my M2 Ultra Mac Studio:
Method | ArraySize | Mean | Error | StdDev | Gen0 | Allocated |
---|---|---|---|---|---|---|
SystemNumericsVector3CrossProduct | 10000 | 36.87 us | 0.508 us | 0.336 us | - | - |
Vector3StructCrossProduct | 10000 | 40.14 us | 0.924 us | 0.611 us | - | - |
Vector3ClassCrossProduct | 10000 | 89.97 us | 0.569 us | 0.339 us | 76.4160 | 640032 B |
SystemNumericsVector3CrossProduct | 100000 | 366.31 us | 2.367 us | 1.566 us | - | - |
Vector3StructCrossProduct | 100000 | 387.15 us | 2.511 us | 1.661 us | - | - |
Vector3ClassCrossProduct | 100000 | 888.40 us | 21.114 us | 13.966 us | 764.6484 | 6400033 B |
SystemNumericsVector3CrossProduct | 1000000 | 3,534.68 us | 42.430 us | 25.250 us | - | 2 B |
Vector3StructCrossProduct | 1000000 | 3,882.09 us | 17.121 us | 8.955 us | - | 3 B |
Vector3ClassCrossProduct | 1000000 | 8,828.78 us | 251.901 us | 166.617 us | 7640.6250 | 64000044 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 float
s 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 struct
s as members inside of non-ref struct
s 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 struct
s 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
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
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
static void SpanExample()
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 struct
s 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.