The never-ending battle: Structs vs. Classes
👋 Introduction
One of the inevitable cases of programming is creating POCOs, DTOs, Value Objects, objects whose (generally) sole purpose is to hold data together so that it can be passed as a single argument to a method. Most developers automatically define these as classes. Let's see the difference between using classes vs. structs for data.
⚖️ Performance Pros & Cons
Classes
✔️ Has inheritance
✔️ Always passes by ref >> better performance when class is bigger.
⚠️ Calling methods on it is acallvirt
(except when sealed).
❌ Cost of allocation
Structs
✔️ No allocations
✔️ Calling methods on it does not involvecallvirt
.
⚠️ Must define explicitly when passing by ref is required using theref
orin
keywords. It is also passed by ref when compiler can optimize it to.
❌ No inheritance
⏱️ Performance
Let's start with the obvious: classes require allocation because they're placed on the heap, whereas structs doesn't require one because they're placed on the stack. Allocating in the heap is costly, but how costly it is exactly? The difficulty of answering it comes from the fact that allocation itself is just one part of the equation, one should also measure GC impact. Now, I'm not saying it's impossible to benchmark it, but creating a server environment with dummy endpoints and then calling them is still too "artifical" for a test like that, so I'm gonna take the path of the least resistance here, and just make some dummy methods and Benchmark.NET them, because one thing is sure: the mroe you allocate the more resource you need to garbage collect them.
Methodology
- Inlining must be disabled because I also want to show the cost of copying structs as parameters.
- I need to call the method twice sequentially, so that the compiler won't passes a simple struct by reference just because it knows it can do that, because the struct won't be used after the method call.
- The triple arguments (i.e. triple allocations) are needed to show a more accurate difference. It's an analogy for unrolling instead of looping (where you don't want to include the overhead of looping in your benchmark, here I want to minimize the overhead of calling those dummy functions twice, and want to emphasize the allocation and passing of arguments part):
Benchmark cases
- Using a
class
- Using a
struct
- Using a
struct
and passing it as byref. - Using a
readonly struct
. - Using a
readonly struct
and passing it as an interface. - Using a
class
and passing it as an interface. - Using a
class
from an object pool (no allocation, but cost of renting from the pool. I omitted returning, because - spoiler alert - even the renting part is pretty costly.)
Results
BenchmarkDotNet=v0.13.2, OS=Windows 10 (10.0.19045.2728)
Intel Core i7-3770S CPU 3.10GHz (Ivy Bridge), 1 CPU, 8 logical and 4 physical cores
.NET SDK=7.0.201
[Host] : .NET 7.0.4 (7.0.423.11508), X64 RyuJIT AVX
DefaultJob : .NET 7.0.4 (7.0.423.11508), X64 RyuJIT AVX
Method | Class | Struct | ByRef Struct | ReadOnlyStruct | ReadOnlyStruct (IF) | Class (IF) | Pooled Class |
---|---|---|---|---|---|---|---|
8 bytes | 17.131 ns ( 72 B) | 2.675 ns (0 B) | 2.925 ns (0 B) | 2.652 ns (0 B) | 41.493 ns (144 B) | 22.736 ns ( 72 B) | 47.348 ns ( 72 B) |
16 bytes | 17.071 ns ( 96 B) | 3.466 ns (0 B) | 3.211 ns (0 B) | 3.458 ns (0 B) | 44.726 ns (192 B) | 27.121 ns ( 96 B) | 48.248 ns ( 96 B) |
24 bytes | 19.364 ns (120 B) | 4.174 ns (0 B) | 3.728 ns (0 B) | 4.285 ns (0 B) | 48.345 ns (240 B) | 27.507 ns (120 B) | 50.854 ns (120 B) |
32 bytes | 21.214 ns (144 B) | 14.594 ns (0 B) | 4.800 ns (0 B) | 14.292 ns (0 B) | 56.298 ns (288 B) | 28.963 ns (144 B) | 52.249 ns (144 B) |
40 bytes | 22.762 ns (168 B) | 31.130 ns (0 B) | 21.435 ns (0 B) | 22.544 ns (0 B) | 66.292 ns (336 B) | 29.313 ns (168 B) | 54.566 ns (168 B) |
48 bytes | 25.059 ns (192 B) | 23.247 ns (0 B) | 21.792 ns (0 B) | 23.206 ns (0 B) | 77.895 ns (384 B) | 33.473 ns (192 B) | 54.993 ns (192 B) |
56 bytes | 27.146 ns (216 B) | 25.520 ns (0 B) | 23.384 ns (0 B) | 30.760 ns (0 B) | 80.589 ns (432 B) | 35.473 ns (216 B) | 57.117 ns (216 B) |
64 bytes | 29.890 ns (240 B) | 26.412 ns (0 B) | 23.974 ns (0 B) | 26.949 ns (0 B) | 92.220 ns (480 B) | 36.309 ns (240 B) | 58.325 ns (240 B) |
72 bytes | 31.947 ns (264 B) | 54.583 ns (0 B) | 30.027 ns (0 B) | 54.402 ns (0 B) | 123.407 ns (528 B) | 39.195 ns (264 B) | 61.228 ns (264 B) |
80 bytes | 34.960 ns (288 B) | 54.728 ns (0 B) | 30.453 ns (0 B) | 54.800 ns (0 B) | 123.931 ns (576 B) | 42.131 ns (288 B) | 61.722 ns (288 B) |
88 bytes | 36.769 ns (312 B) | 55.291 ns (0 B) | 32.640 ns (0 B) | 55.536 ns (0 B) | 130.493 ns (624 B) | 48.553 ns (312 B) | 63.674 ns (312 B) |
96 bytes | 40.362 ns (336 B) | 55.480 ns (0 B) | 32.804 ns (0 B) | 55.513 ns (0 B) | 132.685 ns (672 B) | 46.279 ns (336 B) | 66.593 ns (336 B) |
BenchmarkDotNet=v0.13.5, OS=Windows 11 (10.0.22621.1555/22H2/2022Update/SunValley2)
Intel Core i5-10210U CPU 1.60GHz, 1 CPU, 8 logical and 4 physical cores
.NET SDK=8.0.100-preview.2.23157.25
[Host] : .NET 7.0.5 (7.0.523.17405), X64 RyuJIT AVX2
DefaultJob : .NET 7.0.5 (7.0.523.17405), X64 RyuJIT AVX2
Method | Class | Struct | ByRef Struct | ReadOnlyStruct | ReadOnlyStruct (IF) | Class (IF) | Pooled Class |
---|---|---|---|---|---|---|---|
8 bytes | 13.820 ns ( 72 B) | 3.908 ns (0 B) | 3.610 ns (0 B) | 2.810 ns (0 B) | 45.542 ns (144 B) | 25.727 ns ( 72 B) | 50.799 ns ( 72 B) |
16 bytes | 14.539 ns ( 96 B) | 5.268 ns (0 B) | 3.257 ns (0 B) | 3.515 ns (0 B) | 47.421 ns (192 B) | 28.338 ns ( 96 B) | 51.894 ns ( 96 B) |
24 bytes | 16.353 ns (120 B) | 7.860 ns (0 B) | 3.910 ns (0 B) | 4.676 ns (0 B) | 47.605 ns (240 B) | 29.874 ns (120 B) | 54.126 ns (120 B) |
32 bytes | 18.363 ns (144 B) | 13.598 ns (0 B) | 5.024 ns (0 B) | 11.833 ns (0 B) | 51.165 ns (288 B) | 31.567 ns (144 B) | 56.978 ns (144 B) |
40 bytes | 19.679 ns (168 B) | 22.772 ns (0 B) | 22.620 ns (0 B) | 20.801 ns (0 B) | 63.756 ns (336 B) | 32.129 ns (168 B) | 60.587 ns (168 B) |
48 bytes | 23.193 ns (192 B) | 21.776 ns (0 B) | 20.741 ns (0 B) | 20.606 ns (0 B) | 65.064 ns (384 B) | 34.041 ns (192 B) | 57.108 ns (192 B) |
56 bytes | 22.546 ns (216 B) | 26.859 ns (0 B) | 21.277 ns (0 B) | 25.131 ns (0 B) | 75.063 ns (432 B) | 33.905 ns (216 B) | 55.134 ns (216 B) |
64 bytes | 24.382 ns (240 B) | 27.752 ns (0 B) | 22.879 ns (0 B) | 25.021 ns (0 B) | 75.182 ns (480 B) | 35.507 ns (240 B) | 59.046 ns (240 B) |
72 bytes | 26.693 ns (264 B) | 47.548 ns (0 B) | 29.581 ns (0 B) | 46.762 ns (0 B) | 105.982 ns (528 B) | 38.115 ns (264 B) | 58.835 ns (264 B) |
80 bytes | 28.797 ns (288 B) | 48.184 ns (0 B) | 30.414 ns (0 B) | 47.931 ns (0 B) | 110.353 ns (576 B) | 40.108 ns (288 B) | 59.573 ns (288 B) |
88 bytes | 30.449 ns (312 B) | 48.224 ns (0 B) | 32.261 ns (0 B) | 48.424 ns (0 B) | 115.588 ns (624 B) | 42.359 ns (312 B) | 62.141 ns (312 B) |
96 bytes | 47.058 ns (336 B) | 48.190 ns (0 B) | 31.268 ns (0 B) | 47.522 ns (0 B) | 118.348 ns (672 B) | 45.558 ns (336 B) | 65.599 ns (336 B) |
Observations & Comments
- ReadOnly Structs: I figured the compiler would optimize this and would pass it by ref, since it is readonly, meaning there's no purpose in copying it. But it's probably the same logic as if the struct would be a readonly field itself in which case the compiler creates defensive copies to make sure the struct remains untouched.
- Copying of structs: According to my benchmarks on .NET7 it seems that after 64bytes the compiler stops inlining the method that copies the struct which results in additional call operations that suddenly makes it worse comparing to classes. It must be checked on .NET8 whether the implementation of 512bit vectors might cause inlining at higher sizes only.
- Structs implementing interfaces: Structs implementing interfaces causes GC allocation (due to boxing when passed as interfaces) which makes them your worst possible option. You can see the allocation is twice as much as in the case of classes. This is due to the number of arguments (6), because for all the arguments to be able to pass it, they have to be boxed, which is 6 boxing, which is 6 allocations. In the cases of classes, there are 3 allocations only.
- one can circumvent this by passing them as generic params which have interface constraints.
- StackOverflowException: Large structs are always risky due to limited stack space.
The struct
, the readonly
, and the in
keywords
Defensive copy may happen when you defined your struct, or the field you assigned it to as readonly
, or when your argument has the in
modifier but you pass it without the in
modifier. It happens, because the compiler would like to make sure that you're original struct remains untouched, and it achieves this by giving you a copy of the struct each time you access it so that your original one cannot be modified in any way. The good news is, that this behaviour became much more consistent and predictable in .NET Core (don't know exactly when). In .NET 7, the compiler creates defensive copies every time when the struct is passed byval, and skips creating it when passed byref.
It's worth noting that there are cases when you don't need to use the in
or ref
keyword to pass the struct by reference. One example is the one I avoided deliberately in the benchmarks (because I wanted the JIT to create copies to benchmark copy time):
- Have a
struct
by value (so create it locally, or have it as a byval argument) - Calling a method with that
struct
as an argument - Do nothing with the
struct
Actually large structs always get passed by ref (ref relative to the stack or the stack base pointer), the question is: will the compiler create a defensive copy? In this case, it obviously won't do such a thing, because a copy would only be needed for a second usage of the struct, but there's only a single usage, the method call, for which the compiler passes the newly created struct by ref.
📋 Summary
Performance Table
Feature | Class | Struct | ByRef Struct | ReadOnlyStruct | ReadOnlyStruct (IF) | Class (IF) | Pooled Class |
---|---|---|---|---|---|---|---|
Scaling | ✔️ | ❌❌ | ✔️ | ❌❌ | ❌❌ | ✔️ | ✔️✔️ |
Small sizes | ❌ | ✔️✔️ | ✔️✔️ | ✔️✔️ | ❌❌ | ❌❌ | ❌❌ |
Medium sizes | ✔️ | ✔️ | ✔️✔️ | ✔️ | ❌❌ | ✔️ | ❌ |
Large sizes | ❌ | ❌❌ | ❌❌ | ❌❌ | ❌❌ | ❌ | ✔️✔️ |
Other disadvantages/restrictions of structs
When passing it by ref (using the in
or ref
keyword):
❌ Cannot be used in async methods
❌ Cannot be used in iterator methods (methods that contains the yield
statement)
❌ Cannot be used in the first parameter of an extension method when the struct is to be extended is defined as a generic type.
When using with generic typed APIs:
❌ Most of the time there is a reference-only type restriction defined
❌ Even when there's no reference-only restriction, the API might simply not work with value types (it's essentially a bug, devs tend to forget specifying the reference-only type restriction)
Takeaway
All in all, it's probably a good rule of thumb to always use, or at least strive to use structs when you want to define a data structure. With structs you get greater allocation performance, and won't put any pressure on the GC.