What is New here? - aka 4 ways or creating structs
03/20/2022
6 minutes
In this post I will investigate 4 different approaches for creating structs and I will measure their performance characteristics.
The release of C# 10 removed a previous restriction on struct constructors: structs are allowed have default constructors. This means the code below compiles without a build error.
public struct Person { public int Age { get; set; } = 1; public string Name { get; set; } = "Foo"; }
In this case the default constructor will set the property values as 1
and Foo
respectively. This behavior is really similar to classes. The 4 ways this post explores creating structs:
Using the
new
keywordUsing the
default
keywordUsing generics and
new()
constraintUsing generics and
struct
constraint
The rest of the post shows the code sample for each option, compares performance from mean execution time and memory point of view, and finally looks into the IL/JITted assembly code to reason about the performance findings. For microbenchmarks the BenchmarkDotNet library is in .NET6 and Windows 11 platform.
Using the new
keyword
public Person New() { return new Person(); }
This code returns a new Person
with the default constructor invoked, so the struct returned has the Name property set to "Foo"
and the Age property to 1
.
Using the default
keyword
public Person Default() { return default(Person); }
This method uses the default
keyword to create the Person
struct. The default constructor will not execute and all the fields are set to 0 or null
, whether it is a value or reference type. In case of the Person
struct, the Age is set to 0, and the Name is null
.
Using generics and new()
constraint
A generic solution is often needed in case serialization and deserialization. A library performing the serialization might not presume the type of the source or target object, hence needs to use a generic approach.
public Person GenericNew() { return CreateNew<Person>(); } private T CreateNew<T>() where T : new() => new();
In this case the code is structured into two methods: a generic CreateNew<T>
method has a new()
constraint which means that the type must have a public parameterless constructor: new T()
.
For creating the generic object, C# compiler emits a call to Activator.CreateInstance, which creates a new instance of the type.
IL_0000: call !!0 [System.Runtime]System.Activator::CreateInstance<!!T>()
Using generics and struct
constraint
public Person GenericDefault() { return CreateDefault<Person>(); } private T CreateDefault<T>() where T : struct => default;
The code is still structured into two methods, but now the generic CreateDefault<T>()
uses a struct
constraint to enforce, that only structs may be used as generic type parameters. It allows to use the default
keyword to get the struct created, while not allowing nulls for reference types. This the C# compiler uses the same initobj IL instruction as the non-generic method to create the struct.
The next section of this post compares these methods from performance point of view.
Benchmarks
These benchmarks are captured using BenchmarkDotNet on .NET6, Windows 11 Build 21H2 on x64 platform with x64 RyuJIT.
| Method | Mean | Error | StdDev | Median | Gen 0 | Allocated | |--------------- |-----------:|----------:|----------:|-----------:|-------:|----------:| | Default | 0.0002 ns | 0.0007 ns | 0.0011 ns | 0.0000 ns | - | - | | New | 1.6940 ns | 0.0689 ns | 0.1225 ns | 1.7064 ns | - | - | | GenericNew | 14.8469 ns | 0.3227 ns | 0.7544 ns | 14.6591 ns | 0.0102 | 32 B | | GenericDefault | 0.0393 ns | 0.0278 ns | 0.0803 ns | 0.0000 ns | - | - |
The Default and GenericDefault cases are close to be equivalent of an empty method call, mostly because they don't need to do anything, just return memory that is zero-ed out. The New method takes somewhat longer, but note, that the default constructor of the struct executes this time, setting the Age and Name properties. So far these cases don't cause any memory allocation on the heap, which is desired in case of using structs. However, GenericNew performance lies out as it both allocates memory on the heap and executes significantly slower to the other methods.
Let's observer the JITted assembly code to reveal the inner workings.
Jitted Code
Default and GenericDefault
As previously pointed out, Default and GenericDefault use the same IL instructions. After optimizations and inlining, the runtime emits identical assembly code:
xor eax,eax
mov [rdx],rax
mov [rdx+8],eax
mov rax,rdx
ret
; Total bytes of code 12
In the first instruction 0 is set in the eax
register. Then the two fields (Name and Age) are overwritten with this value.
New assembly code
; Benchmarks.New()
push rsi
mov rsi,rdx
mov rdx,276D3CDA038
mov rdx,[rdx]
mov rcx,rsi
call CORINFO_HELP_CHECKED_ASSIGN_REF
mov dword ptr [rsi+8],1
mov rax,rsi
pop rsi
ret
; Total bytes of code 37
The size of the code is more than 3 times larger in bytes compared to Default and GenericDefault methods. This is because it needs to execute the code for the default constructor. For the Name property the "Foo"
string has to be loaded and set (which involves also executing a write barrier), while for the Age property the value 1 is written to the rsi+8
address.
GenericNew assembly code
Finally, the largest code is required for the GenericNew method.
; Benchmarks.GenericNew()
mov rcx,rdx
jmp near ptr System.Activator.CreateInstance[[Person, StructConstructor]]()
; Total bytes of code 8
; System.Activator.CreateInstance[[Person, StructConstructor]]()
push rdi
push rsi
push rbx
sub rsp,20
mov rbx,rcx
mov rcx,offset MT_Person
call CORINFO_HELP_TYPEHANDLE_TO_RUNTIMETYPE
mov rcx,rax
call System.RuntimeType.CreateInstanceOfT()
mov rsi,rax
mov rdx,offset MT_Person
cmp [rsi],rdx
je short M01_L00
mov rdx,rsi
mov rcx,offset MT_Person
call CORINFO_HELP_UNBOX
M01_L00:
add rsi,8
mov rdi,rbx
call CORINFO_HELP_ASSIGN_BYREF
movsq
mov rax,rbx
add rsp,20
pop rbx
pop rsi
pop rdi
ret
; Total bytes of code 94
Most of the code is required for implementing Activator.CreateInstance
method. It needs to load the type handle for Person
. It needs to create the given instance for the type: the call to System.RuntimeType.CreateInstanceOfT()
will instantiate a new object and call its default constructor. It also needs to unbox the returned object, which involves further runtime type checking. All these extra operations result in a performance cost compared to the other methods. Because the object gets created on the heap, it accounts for the generation 0 allocations in the benchmark results. Unboxing involves further copies, the data, making this method ultimately the slowest performing solution.
Conclusion
C# 10 makes struct creation mentally more involved, as developers have to keep track the different ways and outcomes of struct creation. A rather unfortunate outcome is the performance trap of using generics with new()
constraint and structs, which results in a performance penalty compared to the other options presented in this post.