Null-coalescing assignment operator ??=
02/20/2020
9 minutes
The Operator
One of the new C# features is the null-coalescing assignment operator: ??=
. It assigns the value of its right-hand operand to the left-hand operand if the left-hand operand is null.In other words:
if(myfield == null) myfield = new object();
can be replaced with
myfield ??= new object();
This feels to me a bit more encripted on a single line, but it can increase the overall readability of your method / constructor.
One might ask though: are these generating equivalent IL / Assembly level instructions?In the rest of the post, I will take a look into that. Let's start with inspecting the IL code first.
IL code
I have created two samples to investigate the above question. One, where the value being set is a reference type, and one where it is a nullable value type.
public SomeData NullCoalescingSetClass() { _someData ??= new SomeData(); return _someData; }
public int? NullCoalescingSetStruct() { _someValue ??= 1; return _someValue; }
In case of the class there is no difference between using the null-coalescing assignment operator or the if
statement. In both cases the same IL code gets generated:
// if (_someData == null)
IL_0000: ldarg.0
IL_0001: ldfld class CSharpLanguage.Benchmark/SomeData CSharpLanguage.Benchmark::_someData
// (no C# code)
IL_0006: brtrue.s IL_0013
// _someData = new SomeData();
IL_0008: ldarg.0
IL_0009: newobj instance void CSharpLanguage.Benchmark/SomeData::.ctor()
IL_000e: stfld class CSharpLanguage.Benchmark/SomeData CSharpLanguage.Benchmark::_someData
// return _someData;
IL_0013: ldarg.0
IL_0014: ldfld class CSharpLanguage.Benchmark/SomeData CSharpLanguage.Benchmark::_someData
IL_0019: ret
Even using the if
statement and a nullable value type, we get a similar IL code. In this case, instead of allocating classes, a new value type is created only.
IL_0000: ldarg.0
IL_0001: ldflda valuetype [System.Runtime]System.Nullable`1<int32> CSharpLanguage.Benchmark::_someValue
IL_0006: call instance bool valuetype [System.Runtime]System.Nullable`1<int32>::get_HasValue()
// (no C# code)
IL_000b: brtrue.s IL_0019
// _someValue = 1;
IL_000d: ldarg.0
IL_000e: ldc.i4.1
IL_000f: newobj instance void valuetype [System.Runtime]System.Nullable`1<int32>::.ctor(!0)
IL_0014: stfld valuetype [System.Runtime]System.Nullable`1<int32> CSharpLanguage.Benchmark::_someValue
// return _someValue;
IL_0019: ldarg.0
IL_001a: ldfld valuetype [System.Runtime]System.Nullable`1<int32> CSharpLanguage.Benchmark::_someValue
// (no C# code)
IL_001f: ret
However, when using the null-coalescing assignment operator on a nullable struct, we get a totally different IL code. Here an additional local variable is being used. _someValue
field's value being retrieved first with GetValueOrDefault()
method.
// int valueOrDefault = _someValue.GetValueOrDefault();
IL_0000: ldarg.0
IL_0001: ldflda valuetype [System.Runtime]System.Nullable`1<int32> CSharpLanguage.Benchmark::_someValue
IL_0006: call instance !0 valuetype [System.Runtime]System.Nullable`1<int32>::GetValueOrDefault()
IL_000b: stloc.0
// if (!_someValue.HasValue)
IL_000c: ldarg.0
IL_000d: ldflda valuetype [System.Runtime]System.Nullable`1<int32> CSharpLanguage.Benchmark::_someValue
IL_0012: call instance bool valuetype [System.Runtime]System.Nullable`1<int32>::get_HasValue()
// (no C# code)
IL_0017: brtrue.s IL_0027
// valueOrDefault = 1;
IL_0019: ldc.i4.1
IL_001a: stloc.0
// _someValue = valueOrDefault;
IL_001b: ldarg.0
IL_001c: ldloc.0
IL_001d: newobj instance void valuetype [System.Runtime]System.Nullable`1<int32>::.ctor(!0)
IL_0022: stfld valuetype [System.Runtime]System.Nullable`1<int32> CSharpLanguage.Benchmark::_someValue
// return _someValue;
IL_0027: ldarg.0
IL_0028: ldfld valuetype [System.Runtime]System.Nullable`1<int32> CSharpLanguage.Benchmark::_someValue
// (no C# code)
IL_002d: ret
Let's further investigate the value type cases.
Performance
Having a different IL does not always mean that differences are not optimized away by the JIT. One quick performance test can help to confirm if further investigation might be needed.
Here is the benchmark result of the two methods:
Method | Mean | Error | StdDev |
---|---|---|---|
IfNullSetStruct | 1.212 ns | 0.0241 ns | 0.0321 ns |
NullCoalescingSetStruct | 1.607 ns | 0.0306 ns | 0.0300 ns |
Benchmark suggests some differences between the two implementation.
Assembly code
Let's see the related native code. I used WinDBG to examine the JIT compiled methods. In case of instanciating classes when the field is null, the same IL code is generated on x64 .net core 3.1. I added comments for better understanding:
Running either of the following commands results identical instructions:
!name2ee *!CSharpLanguage.Benchmark.NullCoalescingSetClass !name2ee *!CSharpLanguage.Benchmark.IfNullSetClass
C:\Users\...\Source\Repos\Playground\CSharpLanguage\Program.cs @ 57: >>> 00007ffc`6abe1120 55 push rbp 00007ffc`6abe1121 4883ec30 sub rsp,30h // Moving the stack pointer 00007ffc`6abe1125 488d6c2430 lea rbp,[rsp+30h] // rbp points to the current stack 00007ffc`6abe112a 33c0 xor eax,eax // Clear out rax 00007ffc`6abe112c 488945f8 mov qword ptr [rbp-8],rax 00007ffc`6abe1130 48894d10 mov qword ptr [rbp+10h],rcx 00007ffc`6abe1134 488b4d10 mov rcx,qword ptr [rbp+10h] // rcx contains [rbp+10h] which is pointing to 'this' 00007ffc`6abe1138 4883790800 cmp qword ptr [rcx+8],0 // The field for the class compared to null 00007ffc`6abe113d 752d jne 00007ffc`6abe116c // Jumping C:\Users\...\Source\Repos\Playground\CSharpLanguage\Program.cs @ 58: 00007ffc`6abe113f 48b92021ca6afc7f0000 mov rcx,7FFC6ACA2120h (MT: CSharpLanguage.Benchmark+SomeData) // Allocating memory for SomeData type 00007ffc`6abe1149 e8625bb15f call coreclr!JIT_TrialAllocSFastMP_InlineGetThread (00007ffc`ca6f6cb0) 00007ffc`6abe114e 488945f8 mov qword ptr [rbp-8],rax 00007ffc`6abe1152 488b4df8 mov rcx,qword ptr [rbp-8] 00007ffc`6abe1156 e8cdf4ffff call 00007ffc`6abe0628 (CSharpLanguage.Benchmark+SomeData..ctor(), mdToken: 0000000006000009) // Calling the constructor of SomeData at the allocated address 00007ffc`6abe115b 488b5510 mov rdx,qword ptr [rbp+10h] // Copying the address of the new object to the 'this' classes field. Needs to use WriteBarrier because of older to younger generation references. 00007ffc`6abe115f 488d4a08 lea rcx,[rdx+8] 00007ffc`6abe1163 488b55f8 mov rdx,qword ptr [rbp-8] 00007ffc`6abe1167 e8844cb15f call coreclr!JIT_WriteBarrier (00007ffc`ca6f5df0) // Return...
When using if
statement to set a value for a nullable value type JIT compiled
!name2ee *!CSharpLanguage.Benchmark.IfNullSetStruct C:\Users\...\Source\Repos\Playground\CSharpLanguage\Program.cs @ 42: >>> 00007ffc`7aa51000 55 push rbp 00007ffc`7aa51001 4883ec30 sub rsp,30h // Moving the stack pointer 00007ffc`7aa51005 488d6c2430 lea rbp,[rsp+30h] // rbp points to the current stack frame 00007ffc`7aa5100a 48894d10 mov qword ptr [rbp+10h],rcx 00007ffc`7aa5100e 488b4d10 mov rcx,qword ptr [rbp+10h] // rcx contains 'this' 00007ffc`7aa51012 3909 cmp dword ptr [rcx],ecx 00007ffc`7aa51014 488b4d10 mov rcx,qword ptr [rbp+10h] 00007ffc`7aa51018 4883c108 add rcx,8 // rcx contains the field's address 00007ffc`7aa5101c e867f5ffff call 00007ffc`7aa50588 (System.Nullable`1[[System.Int32, System.Private.CoreLib]].get_HasValue(), mdToken: 000000000600149A) // Calling HasValue on the fields. 00007ffc`7aa51021 85c0 test eax,eax 00007ffc`7aa51023 7520 jne 00007ffc`7aa51045 // If the field has value, jump to return statement C:\Users\...\Source\Repos\Playground\CSharpLanguage\Program.cs @ 43: 00007ffc`7aa51025 33c9 xor ecx,ecx // clearing rcx 00007ffc`7aa51027 48894df8 mov qword ptr [rbp-8],rcx 00007ffc`7aa5102b 488d4df8 lea rcx,[rbp-8] 00007ffc`7aa5102f ba01000000 mov edx,1 // Loading 1 into edx 00007ffc`7aa51034 e847f5ffff call 00007ffc`7aa50580 (System.Nullable`1[[System.Int32, System.Private.CoreLib]]..ctor(Int32), mdToken: 0000000006001499) 00007ffc`7aa51039 488b45f8 mov rax,qword ptr [rbp-8] // rax contains the new struct 00007ffc`7aa5103d 488b5510 mov rdx,qword ptr [rbp+10h] // rdx contains 'this' 00007ffc`7aa51041 48894208 mov qword ptr [rdx+8],rax // Copy the created nullable struct to the field of 'this' // Return...
Comparing this to case where the null-coalescing assignment operator has been used, the NullCoalescingSetStruct
method:
!name2ee *!CSharpLanguage.Benchmark.NullCoalescingSetStruct C:\Users\...\Source\Repos\Playground\CSharpLanguage\Program.cs @ 48: >>> 00007ffc`7d841060 55 push rbp 00007ffc`7d841061 4883ec30 sub rsp,30h 00007ffc`7d841065 488d6c2430 lea rbp,[rsp+30h] 00007ffc`7d84106a 33c0 xor eax,eax 00007ffc`7d84106c 8945fc mov dword ptr [rbp-4],eax // Initialize a local integer (note integers are 4 bytes) 00007ffc`7d84106f 48894d10 mov qword ptr [rbp+10h],rcx 00007ffc`7d841073 488b4d10 mov rcx,qword ptr [rbp+10h] 00007ffc`7d841077 3909 cmp dword ptr [rcx],ecx 00007ffc`7d841079 488b4d10 mov rcx,qword ptr [rbp+10h] 00007ffc`7d84107d 4883c108 add rcx,8 // Calling GetValueOrDefault method on 'this' objects field. Result is stored in a local variable 00007ffc`7d841081 e812f5ffff call 00007ffc`7d840598 (System.Nullable`1[[System.Int32, System.Private.CoreLib]].GetValueOrDefault(), mdToken: 000000000600149C) 00007ffc`7d841086 8945fc mov dword ptr [rbp-4],eax // Result is stored on the stack 00007ffc`7d841089 488b4d10 mov rcx,qword ptr [rbp+10h] 00007ffc`7d84108d 3909 cmp dword ptr [rcx],ecx 00007ffc`7d84108f 488b4d10 mov rcx,qword ptr [rbp+10h] 00007ffc`7d841093 4883c108 add rcx,8 // Calling HasValue method on local variable with the result 00007ffc`7d841097 e8ecf4ffff call 00007ffc`7d840588 (System.Nullable`1[[System.Int32, System.Private.CoreLib]].get_HasValue(), mdToken: 000000000600149A) 00007ffc`7d84109c 85c0 test eax,eax 00007ffc`7d84109e 7525 jne 00007ffc`7d8410c5 // Jump to return if value is not null 00007ffc`7d8410a0 c745fc01000000 mov dword ptr [rbp-4],1 // Set 1 for the local integer 00007ffc`7d8410a7 33c9 xor ecx,ecx // Creating a new nullable int on the stack 00007ffc`7d8410a9 48894df0 mov qword ptr [rbp-10h],rcx 00007ffc`7d8410ad 488d4df0 lea rcx,[rbp-10h] 00007ffc`7d8410b1 8b55fc mov edx,dword ptr [rbp-4] 00007ffc`7d8410b4 e8c7f4ffff call 00007ffc`7d840580 (System.Nullable`1[[System.Int32, System.Private.CoreLib]]..ctor(Int32), mdToken: 0000000006001499) 00007ffc`7d8410b9 488b45f0 mov rax,qword ptr [rbp-10h] // rax contains the int? 00007ffc`7d8410bd 488b5510 mov rdx,qword ptr [rbp+10h] // rdx contains 'this' 00007ffc`7d8410c1 48894208 mov qword ptr [rdx+8],rax // Set the field with the int? C:\Users\...\Source\Repos\Playground\CSharpLanguage\Program.cs @ 49: 00007ffc`7d8410c5 488b4510 mov rax,qword ptr [rbp+10h] // rax contains 'this' 00007ffc`7d8410c9 33d2 xor edx,edx 00007ffc`7d8410cb 48895008 mov qword ptr [rax+8],rdx // Set the field with the int? // Return...
Conclusion
The code itself is longer, and has more instructions, it seems current JIT does not do further optimizations. Does this difference matter? No, it does not in most of the cases. It matters for extra tight loops only. Also note, that just by using a struct instead of a class, we gain significant performance advantage, but this has been excluded from the Performance section of this post.