Null-coalescing assignment operator ??=

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.