3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2024-11-23

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

はじめに

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

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

    return value.GetHashCode();
}

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

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

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

サンプルコード

サンプルコード
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);
        });
    }
}

使い方

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() の実装

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 コンパイル時)以下のようなコードが作られます(多分)。

// 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 とのパフォーマンスの違い

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

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)が微妙に違うのかもしれませんが、よくわかりませんでした

おわりに

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

3
2
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?