【C#】ジェネリックの null チェックでガベージを出さない方法を模索


:::note info
2024/11/23 `null` 許容構造体について修正しました。ご指摘ありがとうございます👍️  
2024/11/26 より短い方のコードも検証しました。ご提案ありがとうございます👍️
:::

# はじめに

ジェネリックでコードを書いていると、たまに `null` チェックしたくなることがあります。

```cs
int GetHashCode<T>(T value)
{
    if (value is null) return 0;

    return value.GetHashCode();
}
```

このとき **`T` が値型の場合にガベージが発生します**。理由はよくわかりませんが、内部的に `object.ReferenceEquals()` による参照比較が行われるのでしょうか。

現在は `nullable` があるため `null` チェックする機会は減りましたが、あれば何かに役立ちそうです。

:::note
この記事は C#13 / .net9.0 で検証されています。別のバージョンや .NET Framework では挙動が異なるかもしれません。
:::

## サンプルコード

<details>
<summary>サンプルコード</summary>

```cs
using Xunit;

file static class NullChecker
{
    internal static bool EqualsOperator<T>(T value) => value == null;

    internal static bool IsOperator<T>(T value) => value is null;

    internal static bool IsNull<T>(T value) where T : allows ref struct
    {
        if (typeof(T).IsValueType)
        {
            if (typeof(T).IsGenericType && typeof(T).GetGenericTypeDefinition() == typeof(Nullable<>))
            {
                return value is null;
            }
            return false;
        }
        return value is null;
    }

    internal static bool IsNull2<T>(T value) where T : allows ref struct
    {
        return default(T) is null && value is null;
    }

    internal static bool EqualsOperatorNullable<T>(T? value) => value == null;

    internal static bool IsOperatorNullable<T>(T? value) => value is null;

    internal static bool IsNullNullable<T>(T? value) => value is null;
}

public class __NullCheckerTest
{
    void UseNullable()
    {
        string? nullStr = null;
        Assert.True(nullStr is null);

        // null 許容構造体は null の場合 BOX 化しない
        int? nullInt = null;
        Assert.True(nullInt is null);
    }

    int GetHashCode<T>(T value)
    {
        if (value is null) return 0;

        return value.GetHashCode();
    }

    [Fact]
    void EqualsOperatorTest()
    {
        Assert.True(NullChecker.EqualsOperator<string>(null!));
        Assert.False(NullChecker.EqualsOperator<string>("text"));

        Assert.False(NullChecker.EqualsOperator<int>(0));
    }

    [Fact]
    void IsOperatorTest()
    {
        Assert.True(NullChecker.IsOperator<string>(null!));
        Assert.False(NullChecker.IsOperator<string>("text"));

        Assert.False(NullChecker.IsOperator<int>(0));
    }

    [Fact]
    void IsNullTest()
    {
        Assert.True(NullChecker.IsNull<string>(null!));
        Assert.False(NullChecker.IsNull<string>("text"));

        Assert.False(NullChecker.IsNull<int>(0));

        Assert.False(NullChecker.IsNull<int?>(0));
        Assert.True(NullChecker.IsNull<int?>(null));
    }

    [Fact]
    void IsNull2Test()
    {
        Assert.True(NullChecker.IsNull2<string>(null!));
        Assert.False(NullChecker.IsNull2<string>("text"));

        Assert.False(NullChecker.IsNull2<int>(0));

        Assert.False(NullChecker.IsNull2<int?>(0));
        Assert.True(NullChecker.IsNull2<int?>(null));
    }

    [Fact]
    void EqualsOperatorNullableTest()
    {
        Assert.True(NullChecker.EqualsOperatorNullable<string?>(null));
        Assert.False(NullChecker.EqualsOperatorNullable<string?>("text"));

        Assert.True(NullChecker.EqualsOperatorNullable<int?>(null));
        Assert.False(NullChecker.EqualsOperatorNullable<int?>(0));
    }

    [Fact]
    void IsOperatorNullableTest()
    {
        Assert.True(NullChecker.IsOperatorNullable<string?>(null));
        Assert.False(NullChecker.IsOperatorNullable<string?>("text"));

        Assert.True(NullChecker.IsOperatorNullable<int?>(null));
        Assert.False(NullChecker.IsOperatorNullable<int?>(0));
    }

    [Fact]
    void IsNullNullableTest()
    {
        Assert.True(NullChecker.IsNullNullable<string?>(null));
        Assert.False(NullChecker.IsNullNullable<string?>("text"));

        Assert.True(NullChecker.IsNullNullable<int?>(null));
        Assert.False(NullChecker.IsNullNullable<int?>(0));
    }

    static void DummyTest(Performance p)
    {
        p.AddTest("DummyTest", () =>
        {
            for (var n = 0; n < 10000; ++n)
            {
                // Dummy test
            }
        });
    }

    static void NullTest(Performance p)
    {
        p.AddTest("string == null", () =>
        {
            for (var n = 0; n < 10000; ++n)
                NullChecker.EqualsOperator<string>(null!);
        });

        p.AddTest("string is null", () =>
        {
            for (var n = 0; n < 10000; ++n)
                NullChecker.IsOperator<string>(null!);
        });

        p.AddTest("IsNull(string)", () =>
        {
            for (var n = 0; n < 10000; ++n)
                NullChecker.IsNull<string>(null!);
        });

        p.AddTest("IsNull2(string)", () =>
        {
            for (var n = 0; n < 10000; ++n)
                NullChecker.IsNull2<string>(null!);
        });

        p.AddTest("int == null", () =>
        {
            for (var n = 0; n < 10000; ++n)
            {
                NullChecker.EqualsOperator<int>(0);
            }
        });

        p.AddTest("int is null", () =>
        {
            for (var n = 0; n < 10000; ++n)
                NullChecker.IsOperator<int>(0);
        });

        int? notNullInt = 0;

        p.AddTest("IsNull(int)", () =>
        {
            for (var n = 0; n < 10000; ++n)
                NullChecker.IsNull<int>(0);
        });

        p.AddTest("IsNull(int? 0)", () =>
        {
            for (var n = 0; n < 10000; ++n)
                NullChecker.IsNull<int?>(notNullInt);
        });
        p.AddTest("IsNull(int? null)", () =>
        {
            for (var n = 0; n < 10000; ++n)
                NullChecker.IsNull<int?>(null);
        });

        p.AddTest("IsNull2(int)", () =>
        {
            for (var n = 0; n < 10000; ++n)
                NullChecker.IsNull2<int>(0);
        });

        p.AddTest("IsNull2(int? 0)", () =>
        {
            for (var n = 0; n < 10000; ++n)
                NullChecker.IsNull2<int?>(notNullInt);
        });
        p.AddTest("IsNull2(int? null)", () =>
        {
            for (var n = 0; n < 10000; ++n)
                NullChecker.IsNull2<int?>(null);
        });

        p.AddTest("IntPtr == null", () =>
        {
            for (var n = 0; n < 10000; ++n)
            {
                NullChecker.EqualsOperator<IntPtr>(0);
            }
        });

        p.AddTest("IntPtr is null", () =>
        {
            for (var n = 0; n < 10000; ++n)
                NullChecker.IsOperator<IntPtr>(0);
        });

        p.AddTest("IsNull(IntPtr)", () =>
        {
            for (var n = 0; n < 10000; ++n)
                NullChecker.IsNull<IntPtr>(0);
        });
    }

    static void NullableNullTypeTest(Performance p)
    {
        p.AddTest("string? == null", () =>
        {
            for (var n = 0; n < 10000; ++n)
                NullChecker.EqualsOperatorNullable<string?>(null);
        });

        p.AddTest("string? is null", () =>
        {
            for (var n = 0; n < 10000; ++n)
                NullChecker.IsOperatorNullable<string?>(null);
        });

        p.AddTest("IsNull(string?)", () =>
        {
            for (var n = 0; n < 10000; ++n)
                NullChecker.IsNullNullable<string?>(null);
        });


        p.AddTest("int? == null", () =>
        {
            for (var n = 0; n < 10000; ++n)
                NullChecker.EqualsOperatorNullable<int?>(null);
        });

        p.AddTest("int? is null", () =>
        {
            for (var n = 0; n < 10000; ++n)
                NullChecker.IsOperatorNullable<int?>(null);
        });

        p.AddTest("IsNull(int?)", () =>
        {
            for (var n = 0; n < 10000; ++n)
                NullChecker.IsNullNullable<int?>(null);
        });

        p.AddTest("IntPtr? == null", () =>
        {
            for (var n = 0; n < 10000; ++n)
            {
                NullChecker.EqualsOperator<IntPtr?>(null);
            }
        });

        p.AddTest("IntPtr? is null", () =>
        {
            for (var n = 0; n < 10000; ++n)
                NullChecker.IsOperator<IntPtr?>(null);
        });

        p.AddTest("IsNull(IntPtr?)", () =>
        {
            for (var n = 0; n < 10000; ++n)
                NullChecker.IsNull<IntPtr?>(null);
        });
    }

    static void NullableNotNullTypeTest(Performance p)
    {
        p.AddTest("string? == null", () =>
        {
            for (var n = 0; n < 10000; ++n)
                NullChecker.EqualsOperatorNullable<string?>("text");
        });

        p.AddTest("string? is null", () =>
        {
            for (var n = 0; n < 10000; ++n)
                NullChecker.IsOperatorNullable<string?>("text");
        });

        p.AddTest("IsNull(string?)", () =>
        {
            for (var n = 0; n < 10000; ++n)
                NullChecker.IsNullNullable<string?>("text");
        });


        p.AddTest("int? == null", () =>
        {
            for (var n = 0; n < 10000; ++n)
                NullChecker.EqualsOperatorNullable<int?>(1);
        });

        p.AddTest("int? is null", () =>
        {
            for (var n = 0; n < 10000; ++n)
                NullChecker.IsOperatorNullable<int?>(1);
        });

        p.AddTest("IsNull(int?)", () =>
        {
            for (var n = 0; n < 10000; ++n)
                NullChecker.IsNullNullable<int?>(1);
        });

        p.AddTest("IntPtr? == null", () =>
        {
            for (var n = 0; n < 10000; ++n)
            {
                NullChecker.EqualsOperator<IntPtr?>(1);
            }
        });

        p.AddTest("IntPtr? is null", () =>
        {
            for (var n = 0; n < 10000; ++n)
                NullChecker.IsOperator<IntPtr?>(1);
        });

        p.AddTest("IsNull(IntPtr?)", () =>
        {
            for (var n = 0; n < 10000; ++n)
                NullChecker.IsNull<IntPtr?>(1);
        });
    }
}
```

</details>

## 使い方

```cs
NullChecker.IsNull(0);
NullChecker.IsNull("text");

string nullString = null!;
NullChecker.IsNull(nullString);

int? nullInt = null;
NullChecker.IsNull(nullInt);
```

## パフォーマンス

|Test|Score|%|CG0|
|---|---:|---:|---:|
|string == null|15,651|100.0%|0|
|string is null|15,002|95.9%|0|
|IsNull(string)|14,346|91.7%|0|
|int == null|10,442|66.7%|*29*|
|int is null|10,759|68.7%|*30*|
|**IsNull(int)**|15,936|**101.8%**|**0**|
|**IsNull(int? 0)**|14,828|**94.7%**|**0**|
|**IsNull(int? null)**|15,338|**98.0%**|**0**|

実行環境: Windows11 x64 .NET Runtime 9.0.0  
**Score** は高いほどパフォーマンスがよいです。  
**GC0** はガベージコレクション回数を表します（少ないほどパフォーマンスがよい）。

- 参照型である `string` の場合は違いはありません
- 値型を単純に `null` チェックした場合ガベージが発生します
- 値型である `int` の場合わずかにパフォーマンスが良いです。ガベージも発生しません

## 気になるところ

### `IsNull()` の実装

```cs
static bool IsNull<T>(T value) where T : allows ref struct
{
    if (typeof(T).IsValueType)
    {
        // null 許容構造体の場合は普通に null チェックする
        if (typeof(T).IsGenericType && typeof(T).GetGenericTypeDefinition() == typeof(Nullable<>))
        {
            return value is null;
        }
        return false;
    }
    return value is null;
}
```

実装はシンプルで、実行時の型を確認して**値型の場合は常に `false` を返し**、参照型または `null` 許容構造体の場合は通常の `null` チェックを行います。

`typeof(T)` というのがミソで、実行時（JIT コンパイル時）以下のようなコードが作られます（多分）。

```cs
// string の場合
static bool IsNull(object value)
{
    return value is null;
}

// int の場合
static bool IsNull(int value)
{
    return false;
}

// int? の場合
static bool IsNull(int? value)
{
    return value is null;
}
```

`typeof(T)` は JIT コンパイル時定数で、この場合 `if` 文と通らない方のコードブロックが消滅します。  
ジェネリックメソッドは JIT コンパイル時の処理が特徴的です。

- 参照型: すべて同じメソッドに集約される
- 値型: 指定の型のメソッドを作成する

という仕様だったはずです。確か。  
というのが今回作成した `IsNull()` がやけにパフォーマンスが良い理由です。

### `== null` 比較と `is null` 比較の違い

<https://ufcpp.net/blog/2019/1/fasternullchecks/> によれば、ユーザー定義の `==` を呼び出すかどうかが異なります。

- `== null`: ユーザー定義の `==` を呼び出す
- `is null`: ユーザー定義の `==` を呼び出さない

ただ、殆どの場合両者は同じものと考えていいでしょう。

### `default(T) is null && value is null` とのパフォーマンスの違い

コメントでご提案いただいた以下のコードも、前述コードと同じ機能を実現できます。

```cs
static bool IsNull2<T>(T value) where T : allows ref struct
{
    return default(T) is null && value is null;
}
```

`default(T) is null &&` の部分が JIT コンパイル時にうまく消えてくれます。ガベージも発生しません。

興味深いことに、パフォーマンスを計測していると両者には微妙に違いがあることがわかりました。

|Test|Score|%|CG0|
|---|---:|---:|---:|
|IsNull(string)|14,346|91.7%|0|
|IsNull2(string)|14,097|90.1%|0|
|IsNull(int)|15,936|101.8%|0|
|IsNull2(int)|14,829|94.7%|0|
|IsNull(int? 0)|14,828|94.7%|0|
|IsNull2(int? 0)|14,393|92.0%|0|
|IsNull(int? null)|15,338|98.0%|0|
|IsNull2(int? null)|14,689|93.9%|0|

- 中間言語（IL）が微妙に違うのかもしれませんが、よくわかりませんでした

## おわりに

今回は普段モヤッとしていたところを検証してみました。以前はもっと複雑でパフォーマンスの悪いやり方をしていたので、今回のシンプルかつパフォーマンスがいい方法を見出したのは収穫でした。