Null operator performance

Spread the love

The null-conditional (AKA “Elvis”) operator and null-coalescing operators were introduced in C# 6.0 and C# 2.0 respectively. They’ve been with us for years and help make our code more concise. But have you ever wondered about their effect on performance, especially when dealing with value types?

In reality, a value type (struct) cannot be null. We need to either box it or wrap it in a Nullable to achieve this. In the worst case, it sounds like trying to use one of these null operators would require heap allocation or at least incur a bit of bookkeeping overhead for tracking “nullity.” However, these are just hypotheses. We need to look behind the code to find out the truth.

We’ll consider this example:

    public struct MyValue
    {
        private readonly byte[] bytes;

        public MyValue(byte[] bytes)
        {
            this.bytes = bytes;
        }

        public int SizeN => this.bytes?.Length ?? 0;

        public int SizeT => (this.bytes != null) ? this.bytes.Length : 0;
    }

The SizeN property uses null operators to achieve the ultimate scannable one-liner. The SizeT property uses the old-fashioned ternary operator — not as terse but readable enough. They each have the same result, but which is better performance-wise? We could start by looking at the IL disassembly:

.method public hidebysig specialname 
    instance int32 get_SizeN () cil managed 
{
    // Method begins at RVA 0x2059
    // Code size 15 (0xf)
    .maxstack 8

    // byte[] array = bytes;
    IL_0000: ldarg.0
    IL_0001: ldfld uint8[] ConsoleApp2.MyValue::bytes
    // if (array == null)
    IL_0006: dup
    // (no C# code)
    IL_0007: brtrue.s IL_000c

    IL_0009: pop
    // return 0;
    IL_000a: ldc.i4.0
    // (no C# code)
    IL_000b: ret

    // return array.Length;
    IL_000c: ldlen
    IL_000d: conv.i4
    // (no C# code)
    IL_000e: ret
} // end of method MyValue::get_SizeN

.method public hidebysig specialname 
    instance int32 get_SizeT () cil managed 
{
    // Method begins at RVA 0x2069
    // Code size 19 (0x13)
    .maxstack 8

    // if (bytes == null)
    IL_0000: ldarg.0
    IL_0001: ldfld uint8[] ConsoleApp2.MyValue::bytes
    // (no C# code)
    IL_0006: brtrue.s IL_000a

    // return 0;
    IL_0008: ldc.i4.0
    // (no C# code)
    IL_0009: ret

    // return bytes.Length;
    IL_000a: ldarg.0
    IL_000b: ldfld uint8[] ConsoleApp2.MyValue::bytes
    IL_0010: ldlen
    IL_0011: conv.i4
    // (no C# code)
    IL_0012: ret
} // end of method MyValue::get_SizeT

Already we can make two observations:

  1. The null operator code has no overhead! It compiles down to a simple null check and early return.
  2. The code size for the ternary operator is larger! It seems that the culprit is multiple field access; we touch this.bytes twice (first to compare it and second to invoke it) but the null operator version can implicitly avoid this.

Ultimately, we need to see a benchmark to determine if we have a real difference in execution time, and to rule out any hidden heap allocations (I didn’t see any in the IL but always measure!):

    [SimpleJob(RuntimeMoniker.NetCoreApp31)]
    [MemoryDiagnoser]
    public class OperatorBenchmark
    {
        private MyValue nullVal;
        private MyValue hasVal;

        [GlobalSetup]
        public void Setup()
        {
            this.nullVal = default;
            this.hasVal = new MyValue(new byte[] { 0xA, 0xB, 0xC });
        }

        [Benchmark]
        public int NullN() => this.nullVal.SizeN;

        [Benchmark]
        public int NullT() => this.nullVal.SizeT;

        [Benchmark]
        public int HasN() => this.hasVal.SizeN;

        [Benchmark]
        public int HasT() => this.hasVal.SizeT;
    }

Using BenchmarkDotNet, we’ll compare four situations, the Cartesian product of { null value, has value } x { null operator, ternary operator }. Here are the results on my machine:

| Method |      Mean |     Error |    StdDev |    Median | Gen 0 | Gen 1 | Gen 2 | Allocated |
|------- |----------:|----------:|----------:|----------:|------:|------:|------:|----------:|
|  NullN | 0.5501 ns | 0.0175 ns | 0.0146 ns | 0.5463 ns |     - |     - |     - |         - |
|  NullT | 0.5400 ns | 0.0092 ns | 0.0076 ns | 0.5399 ns |     - |     - |     - |         - |
|   HasN | 0.5842 ns | 0.0483 ns | 0.0693 ns | 0.5421 ns |     - |     - |     - |         - |
|   HasT | 0.5401 ns | 0.0085 ns | 0.0066 ns | 0.5381 ns |     - |     - |     - |         - |

Given the extremely small running times, the error range, and the median values, it’s safe to call these more or less identical in terms of speed. And look, no heap allocation!

On balance, I say use the null operators. The code style is idiomatic, the IL code size is smaller, and the runtime performance is nearly equivalent as the next most terse ternary option.

Leave a Reply

Your email address will not be published. Required fields are marked *