Generic Method 内部で null 比較を行ったら、はたして boxing するんでしょうか? たとえば以下みたいな関数があったとします。
// 参照型の null と、Nullable<T> の null の数が知りたい。 public static int CountNull<T1, T2, T3>(T1? value1, T2? value2, T3? value3) { int count = 0; count += value1 is null ? 1 : 0; count += value2 is null ? 1 : 0; count += value3 is null ? 1 : 0; return count; }
この関数はコメントにあるように、参照型の null と、Nullable<T>
の null の数が知るためのものです。
なので、参照型と値型の両方を引数に取る可能性があるため、class constraint (where T1 : class
) 等ができません。で、ここで当然の疑問が湧きます。
「Nullable<T>
はともかく普通の値型ってboxingさせないと null 比較できないよな...?boxingしてるのか...??」
結論を先に書いておくと、 IL ではなく、JITコンパイル時に出力される ASM で最適化が走るため boxing は起きません。 なので Generic Method 内部で null 比較は boxing の事を気にせず書いて問題なしです。
ここから下は余談ですが、( ´_ゝ`)フーンという感じで軽く眺めてもらえれば嬉しいです。
コンパイラによって生成される IL と JIT コンパイラで生成される asm
さて、もうちょっと例をさっぱりさせて、こんな感じで行きましょう。sharplab へのリンクはこちら
static class Utilities { public static int Method<T>(T? value1) { // 0x63 is 99 ! return value1 is null ? 0x63 : 0x7; } }
こいつが IL になると、こんな感じになります。
.class private auto ansi abstract sealed beforefieldinit Utilities extends [System.Runtime]System.Object { // Methods .method public hidebysig static int32 Method<T> ( !!T value1 ) cil managed { .custom instance void System.Runtime.CompilerServices.NullableContextAttribute::.ctor(uint8) = ( 01 00 02 00 00 ) // Method begins at RVA 0x208e // Code size 13 (0xd) .maxstack 8 IL_0000: ldarg.0 IL_0001: box !!T IL_0006: brfalse.s IL_000a IL_0008: ldc.i4.7 IL_0009: ret IL_000a: ldc.i4.s 99 IL_000c: ret } // end of method Utilities::Method } // end of class Utilities
うわぁぁぁぁぁぁぁ boxing だぁぁぁぁ!!!! となります。
では、以下のように class constraint をつけてあげたら boxing 消えてくれるんじゃね?と推察し、where T : class
を付けてあげる。
static class Utilities { public static int Method<T>(T? value1) where T : class { return value1 is null ? 0x63 : 0x7; } }
実際に吐き出される IL がこちら。
.class private auto ansi abstract sealed beforefieldinit Utilities extends [System.Runtime]System.Object { // Methods .method public hidebysig static int32 Method<class T> ( !!T value1 ) cil managed { .custom instance void System.Runtime.CompilerServices.NullableContextAttribute::.ctor(uint8) = ( 01 00 01 00 00 ) .custom instance void [SharpLab.Runtime]SharpLab.Runtime.JitGenericAttribute::.ctor(class [System.Runtime]System.Type[]) = ( 01 00 01 00 00 00 60 53 79 73 74 65 6d 2e 53 74 72 69 6e 67 2c 20 53 79 73 74 65 6d 2e 52 75 6e 74 69 6d 65 2c 20 56 65 72 73 69 6f 6e 3d 36 2e 30 2e 30 2e 30 2c 20 43 75 6c 74 75 72 65 3d 6e 65 75 74 72 61 6c 2c 20 50 75 62 6c 69 63 4b 65 79 54 6f 6b 65 6e 3d 62 30 33 66 35 66 37 66 31 31 64 35 30 61 33 61 00 00 ) .param [1] .custom instance void System.Runtime.CompilerServices.NullableAttribute::.ctor(uint8) = ( 01 00 02 00 00 ) // Method begins at RVA 0x208e // Code size 13 (0xd) .maxstack 8 IL_0000: ldarg.0 IL_0001: box !!T IL_0006: brfalse.s IL_000a IL_0008: ldc.i4.7 IL_0009: ret IL_000a: ldc.i4.s 99 IL_000c: ret } // end of method Utilities::Method } // end of class Utilities
バカな、まだ box !!T
がいるぞ...!
ということで、参照型に制約しようが吐き出される IL に違いがない事が分かってしまいました。
では実際に boxing が発生するか?と考えると、いや絶対そんな手抜き実装してないだろうなぁ、となるので、JIT コンパイル 時に最適化されるんだろうなぁ~と思いを馳せます。
という事で JIT コンパイラ が吐き出す asm を眺める事になります。sharplab は [JitGeneric(typeof(T))]
とかつけると、値型に関しては良しなに展開してくれます。参照型はうまくいかないので、適当な関数経由でみてみましょう。
以下みたいなコードがあったとして、
using SharpLab.Runtime; static class Utilities { // [JitGeneric(typeof(object))] の代替。 public static int F(object? o) { return Method(o); } [JitGeneric(typeof(int))] [JitGeneric(typeof(int?))] // [JitGeneric(typeof(object))] // not work public static int Method<T>(T? value1) { return value1 is null ? 0x63 : 0x7; } }
吐き出される asm は以下。
; Core CLR 6.0.722.32202 on amd64 Microsoft.CodeAnalysis.EmbeddedAttribute..ctor() L0000: ret System.Runtime.CompilerServices.NullableAttribute..ctor(Byte) L0000: push rdi L0001: push rsi L0002: sub rsp, 0x28 L0006: mov rsi, rcx L0009: mov edi, edx L000b: mov rcx, 0x7ffef6690a90 L0015: mov edx, 1 L001a: call 0x00007fff5611b660 L001f: mov [rax+0x10], dil L0023: lea rcx, [rsi+8] L0027: mov rdx, rax L002a: call 0x00007fff5611aeb0 L002f: nop L0030: add rsp, 0x28 L0034: pop rsi L0035: pop rdi L0036: ret System.Runtime.CompilerServices.NullableAttribute..ctor(Byte[]) L0000: lea rcx, [rcx+8] L0004: call 0x00007fff5611aeb0 L0009: nop L000a: ret System.Runtime.CompilerServices.NullableContextAttribute..ctor(Byte) L0000: mov [rcx+8], dl L0003: ret Utilities.F(System.Object) L0000: test rcx, rcx L0003: je short L000c L0005: mov eax, 7 L000a: jmp short L0011 L000c: mov eax, 0x63 L0011: ret Utilities.Method[[System.Int32, System.Private.CoreLib]](Int32) L0000: mov eax, 7 L0005: ret Utilities.Method[[System.Nullable`1[[System.Int32, System.Private.CoreLib]], System.Private.CoreLib]](System.Nullable`1<Int32>) L0000: mov [rsp+8], rcx L0005: cmp byte ptr [rsp+8], 0 L000a: je short L0012 L000c: mov eax, 7 L0011: ret L0012: mov eax, 0x63 L0017: ret
int (null非許容の値型) の場合
該当の asm はここですね。完全に null 比較が消し飛んで定数が返るようになっています。 というか C# の Generics は値型に関しては C++ のテンプレートみたいにそれぞれの型専用にコードが吐き出されるという事を思い出せば、そりゃこういう最適化効いて当然か...って感じ。 まぁ、実際に自分の目で確かめる事は重要なので良しとしましょう。
Utilities.Method[[System.Int32, System.Private.CoreLib]](Int32) L0000: mov eax, 7 L0005: ret
int? (null許容の値型、Nullalbe<T>) の場合
該当の asm はここ。
Utilities.Method[[System.Nullable`1[[System.Int32, System.Private.CoreLib]], System.Private.CoreLib]](System.Nullable`1<Int32>) L0000: mov [rsp+8], rcx L0005: cmp byte ptr [rsp+8], 0 L000a: je short L0012 L000c: mov eax, 7 L0011: ret L0012: mov eax, 0x63 L0017: ret
cmp byte ptr [rsp+8], 0
、なので、[rsp+8]
と 0
を byte ptr
(1 byte = 8 bit)分cmp
比較するぞといってるわけですね。
この比較箇所は、Nullable<T>
がもっている hasValue
の値との比較でしょう。
で、比較が等価だったら、ZF
フラグが立って、 je short L0012
によって L0012
にジャンプして eax
に 0x63
を積んで、ret。
比較に失敗したら eax
に7
を積んでret。
無駄な事は一切してなさそう。良き良き。
public struct Nullable<T> where T : struct { private readonly bool hasValue; internal T value; public Nullable(T value) { this.value = value; hasValue = true; } // impl... }
object (参照型) の場合
該当の asm はここ。
Utilities.F(System.Object) L0000: test rcx, rcx L0003: je short L000c L0005: mov eax, 7 L000a: jmp short L0011 L000c: mov eax, 0x63 L0011: ret
test はオペランドの論理積を取って、その結果によって SF, ZF, PF にフラグが立つ命令。
test rcx, rcx
なので、同じものの論理積とってて一瞬おや?とはなりますが、論理積を取った結果がゼロならそれはつまり null だよね、ということですね。
null だった場合は ZF
にフラグが立ちますから、je
でジャンプしてあげればよろしいでしょう、という事になります。あとは Nullalbe<T>
の場合と同じ。
無駄な事はなさそうですね、ヨシ!
まとめ。
今回の null 比較ネタは JIT が頑張ってくれるので問題なし!最高!以上!
...という話ではあるんですが、このくらいの最適化は序の口で。 最新の .NET7 でのパフォーマンス改善記事とか眺めていると意味不明なレベルの JIT 時最適化が走ってるんですよね、わけがわからないよ(CV:加藤英美里)。
IL を見るだけじゃダメなのです...!asm まで見ないとなのです...!
References
- https://stackoverflow.com/questions/66702734/avoid-boxing-of-is-null
- https://www.slideshare.net/neuecc/cedec-2018-c-c
- https://qiita.com/DQNEO/items/76a99445e3adde72eb2d
- http://softwaretechnique.web.fc2.com/OS_Development/Tips/IA32_Instructions/TEST.html
- https://en.wikipedia.org/wiki/TEST_(x86_instruction)