dely Tech Blog

クラシル・TRILLを運営するdely株式会社の開発ブログです

【C#】null許容値型のnonnull判定どれが早いかクイズ

f:id:meilcli:20201201102746j:plain

どうもC#erの@MeilCliです。仕事ではAndroidエンジニアをしていますがC#erなのでアドベントカレンダーではC#について書きます

今回参加しているアドベントカレンダーはこちらです。3日目の記事になります

adventar.org

あと、同様なカレンダーがもう一つあります

adventar.org

問: どれが早いか

int? n = 0;

if (n.HasValue) {}// ①
if (n is int) {}// ②
if (n is int and int) {}// ③
if (n is not null) {}// ④

※ Roslyn master(25 Nov 2020)時点

正解はこの記事の中盤に書いています

n.HasValueとはなんぞや

C#erではない人向けに解説すると、C#のnull許容型は2種類(null許容参照型・null許容値型)が存在します

null許容参照型のほうはC# 8.0でできた概念で、参照型の変数・値がnullになりえるものをnull許容参照型、nullになりえないものをnull非許容参照型とすることで近年のモダンな言語のnullセーフティーを取り入れようとするものです

null許容値型のほうはだいぶ昔からあり*1、通常は何らかの値が存在する構造体においてNullable構造体を介することで疑似的にnullという状態を表現できるようにするという機能です

そして本題のn.HasValueについてですが、Nullable構造体が値を持っているかを返すプロパティとしてHasValueがあるというわけです

n is int and intとはなんぞや

n is int and intという一見奇妙な式はC# 9.0で機能強化されたパターンマッチングによるものです

強化内容としては主にパターンマッチング中にand, or, notが使えるようになるといった感じです

たとえば変数nが0から10の間というのを素直に表現すると0 <= n && n <= 10ですが、機能強化されたパターンマッチングで表現するとn is >= 0 and <= 10となります*2

パターンマッチングには型パターンがありn is int and > 0int?に値があり*3それが0以上という表現ができるのですが、無意味にintを判定させることもできるのでn is int and intという書き方ができるわけです

答: どれが早いか

正解は①, ③, ④です。②のn is intによる判定が遅いという感じです

①と④だと考えた人は惜しかったですね。以前、C# 7.3の頃に同様な記事を書いてn is intは遅いよ~と話していたのでMeilCliの個人ブログの熱烈な読者の方*4は同様にn is int and intも遅いと考えたことでしょう(たぶん)

初見で①, ③, ④と答えれた人はすごいですね。C#のエキスパートもしくはMeilCliのTwitterのフォロワーですね

確: どれが早いか

さて、ほんとに②だけが遅いのか

手っ取り早くBenchmarkDotNetを使って計測してみましょう

namespace BenchmarkCode
{
    [SimpleJob]
    [MeanColumn, MinColumn, MaxColumn]
    [MemoryDiagnoser]
    public class NullableInt
    {
        public IEnumerable<int?> Source()
        {
            yield return 1;
        }

        [Benchmark]
        [ArgumentsSource(nameof(Source))]
        public bool HasValue(int? n)
        {
            return n.HasValue;
        }

        [Benchmark]
        [ArgumentsSource(nameof(Source))]
        public bool IsInt(int? n)
        {
            return n is int;
        }

        [Benchmark]
        [ArgumentsSource(nameof(Source))]
        public bool IsIntAndInt(int? n)
        {
            return n is int and int;
        }

        [Benchmark]
        [ArgumentsSource(nameof(Source))]
        public bool IsNotNull(int? n)
        {
            return n is not null;
        }
    }
}

計測コードはこちらです。コードの簡略化のため直接returnしています。SharpLabで軽く確認した限りだとif文の中で記述した場合と大差がない状態になっていました

そして結果がこう*5

BenchmarkDotNet=v0.12.1, OS=Windows 10.0.19042
Intel Core i9-10900K CPU 3.70GHz, 1 CPU, 16 logical and 8 physical cores
.NET Core SDK=5.0.100
  [Host]     : .NET Core 5.0.0 (CoreCLR 5.0.20.51904, CoreFX 5.0.20.51904), X64 RyuJIT
  DefaultJob : .NET Core 5.0.0 (CoreCLR 5.0.20.51904, CoreFX 5.0.20.51904), X64 RyuJIT
Method n Mean Error StdDev Min Max Gen 0 Gen 1 Gen 2 Allocated
HasValue 1 0.6882 ns 0.0093 ns 0.0087 ns 0.6732 ns 0.7032 ns - - - -
IsInt 1 31.5686 ns 0.1760 ns 0.1646 ns 31.2655 ns 31.8268 ns 0.0023 - - 24 B
IsIntAndInt 1 0.6989 ns 0.0074 ns 0.0069 ns 0.6891 ns 0.7116 ns - - - -
IsNotNull 1 0.6731 ns 0.0076 ns 0.0071 ns 0.6634 ns 0.6852 ns - - - -

明らかにn is intの場合が遅いですね

解: どれが早いか

さて、なぜこのような差がついたかというとベンチマーク結果で明らかなのですが、n is intの場合にボックス化が発生しているためです

bool HasValue(int? n) => n.HasValue;
bool IsInt(int? n) => n is int;
bool IsIntAndInt(int? n) => n is int and int;
bool IsNotNull(int? n) => n is not null;

SharpLabで上記のコードをデコンパイルするとIsInt以外のケースはldarga.sで引数のアドレスを取得してcallでNullable構造体のHasValueの値を返すようになっています

.method assembly hidebysig static 
    bool '<<Main>$>g__IsIntAndInt|0_2' (
        valuetype [System.Private.CoreLib]System.Nullable`1<int32> n
    ) cil managed 
{
    // Method begins at RVA 0x2056
    // Code size 8 (0x8)
    .maxstack 8

    IL_0000: ldarga.s n
    IL_0002: call instance bool valuetype [System.Private.CoreLib]System.Nullable`1<int32>::get_HasValue()
    IL_0007: ret
} // end of method '<Program>$'::'<<Main>$>g__IsIntAndInt|0_2'

こんな感じ

.method assembly hidebysig static 
    bool '<<Main>$>g__IsInt|0_1' (
        valuetype [System.Private.CoreLib]System.Nullable`1<int32> n
    ) cil managed 
{
    // Method begins at RVA 0x205f
    // Code size 15 (0xf)
    .maxstack 8

    IL_0000: ldarg.0
    IL_0001: box valuetype [System.Private.CoreLib]System.Nullable`1<int32>
    IL_0006: isinst [System.Private.CoreLib]System.Int32
    IL_000b: ldnull
    IL_000c: cgt.un
    IL_000e: ret
} // end of method '<Program>$'::'<<Main>$>g__IsInt|0_1'

しかし、IsIntのケースではboxでボックス化をしていることがわかります

ボックス化をするコストがかかることによってほかのケースより遅いというわけです

なぜこのようなことが起きるのか

C#コンパイラーであるRoslynのソースを読めば答えが見つかると思いますが、手元でいろいろなケースのCIL*6を確かめてるとnull許容値型のvalue is Tのケースのみ最適化のようなことがされず常にボックス化されるコードが吐き出されており、C# 7.0以降で追加されたパターンマッチングの機能の領域に入ると最適化されたコードが吐き出されてる印象がありました

そのためなのか一見n is intより無駄が多そうなn is int and intのほうが早いみたいな不可解な現象がおきてるようです

ちなみに

この記事のネタはアドベントカレンダーの記事を書いてて偶然発見したものをアドベントカレンダーの記事化したものです。本題のほうは16日に投稿すると思います(たぶん)

昨日の「dely #1 Advent Calendar 2020」はGENさんの「木も見て森も見るための Athena(Presto) 集計術」でした

明日はfunzinさんの「RenovateをiOSアプリ開発に導入してみた」です。お楽しみに!


join-us.dely.jp

bethesun.connpass.com

*1:たぶんC# 2.0ですかね

*2:数値範囲に関しては個人的にはパターンマッチングを使わない表現のほうが数直線上でとらえることができるので好きです

*3:not nullでも表現できますね

*4:熱烈な読者の方が存在するのかは定かではない

*5:Hyper-V上で計測してるので、i9-10900K 10C20Tではなくi9-10900K 8C16Tです

*6:Common Intermediate Language、共通中間言語、一部からはMSILとも呼ばれる。さっき出てたC#じゃないコードのこと