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)