IL2CPP Output for C# 7.3: Pattern Matching
Last week we started exploring the new features of C# 7.3 in Unity 2018.3 by delving into tuples. This week we’ll continue and look at pattern matching. Read on to see how the many forms of pattern matching are actually implemented by IL2CPP!
Pattern Matching if
To start off today, we'll look at the new pattern matching feature in its simplest form: an if
statement.
static class TestClass { static int TestPatternMatchingIf(object o) { if (o is int i) { return i; } return 20; } }
In previous versions of C# we could write if (o is int)
to check the type, but then we'd need to perform a cast with int i = o as int
or int i = (int)o
to unbox the object
and get an int
variable. Notice here how we can just add an i
after is int
and declare a variable all in one expression.
Now let's look at the C++ that IL2CPP in Unity 2018.3.0f2 generates to see how this works, particularly which cast (o as int
or (int)o
) gets used:
extern "C" IL2CPP_METHOD_ATTR int32_t TestClass_TestPatternMatchingIf_m05B5FD66EB3D6AD75E9583AFAE18903E843E0778 (RuntimeObject * ___o0, const RuntimeMethod* method) { static bool s_Il2CppMethodInitialized; if (!s_Il2CppMethodInitialized) { il2cpp_codegen_initialize_method (TestClass_TestPatternMatchingIf_m05B5FD66EB3D6AD75E9583AFAE18903E843E0778_MetadataUsageId); s_Il2CppMethodInitialized = true; } int32_t V_0 = 0; RuntimeObject * V_1 = NULL; { RuntimeObject * L_0 = ___o0; RuntimeObject * L_1 = L_0; V_1 = L_1; if (!((RuntimeObject *)IsInstSealed((RuntimeObject*)L_1, Int32_t585191389E07734F19F3156FF88FB3EF4800D102_il2cpp_TypeInfo_var))) { goto IL_0013; } } { RuntimeObject * L_2 = V_1; V_0 = ((*(int32_t*)((int32_t*)UnBox(L_2, Int32_t585191389E07734F19F3156FF88FB3EF4800D102_il2cpp_TypeInfo_var)))); int32_t L_3 = V_0; return L_3; } IL_0013: { return ((int32_t)20); } }
To begin with, we get the method initialization overhead that comes along any time we do runtime type checking like this. Afterward, IL2CPP makes three aliases of the ___o0
parameter: V_1
, L_0
, and L_1
. It then calls IsInstSealed
with Int32_t..._il2cpp_TypeInfo_var
to check if the object
is an int
. If it isn't, it uses a goto
to return 20
. If it is, it calls UnBox
with the same int
type info (Int32_t..._il2cpp_TypeInfo_var
) and returns that.
Next, let's go look at IsInstSealed
to see what the first check is like:
inline RuntimeObject* IsInstSealed(RuntimeObject *obj, RuntimeClass* targetType) { #if IL2CPP_DEBUG IL2CPP_ASSERT((targetType->flags & TYPE_ATTRIBUTE_SEALED) != 0); IL2CPP_ASSERT((targetType->flags & TYPE_ATTRIBUTE_INTERFACE) == 0); #endif if (!obj) return NULL; // optimized version to compare sealed classes return (obj->klass == targetType ? obj : NULL); }
Assuming IL2CPP_DEBUG
is not defined for release builds, all this does is check for null and compare the object.klass
field to the Int32_t..._il2cpp_TypeInfo_var
.
Now let's look at UnBox
:
inline void* UnBox(RuntimeObject* obj, RuntimeClass* expectedBoxedClass) { NullCheck(obj); if (obj->klass->element_class == expectedBoxedClass->element_class) return il2cpp::vm::Object::Unbox(obj); RaiseInvalidCastException(obj, expectedBoxedClass); return NULL; }
This checks for null and class equality again before calling il2cpp::vm::Object::Unbox
to do the unboxing. If either check fails, an exception is thrown. This is how (int)o
casting works, not o as int
casting.
Let's look at il2cpp::vm::Object::Unbox
now:
void* Object::Unbox(Il2CppObject* obj) { void* val = (void*)(((char*)obj) + sizeof(Il2CppObject)); return val; }
Unboxing simply skips the first part of the Il2CppObject
to get at the class that derives from it.
With all that C++ in mind, let's look at the assembly that's produced by Xcode 10.1 for iOS in relase mode. It's OK to not understand the assembly very deeply; I'll analyze it afterward and point out it's main attributes.
push {r4, r5, r7, lr} add r7, sp, #8 movw r5, :lower16:(__ZZ73TestClass_TestPatternMatchingIf_m05B5FD66EB3D6AD75E9583AFAE18903E843E0778E25s_Il2CppMethodInitialized-(LPC22_0+4)) mov r4, r0 movt r5, :upper16:(__ZZ73TestClass_TestPatternMatchingIf_m05B5FD66EB3D6AD75E9583AFAE18903E843E0778E25s_Il2CppMethodInitialized-(LPC22_0+4)) LPC22_0: add r5, pc ldrb r0, [r5] cbnz r0, LBB22_2 movw r0, :lower16:(L_TestClass_TestPatternMatchingIf_m05B5FD66EB3D6AD75E9583AFAE18903E843E0778_MetadataUsageId$non_lazy_ptr-(LPC22_1+4)) movt r0, :upper16:(L_TestClass_TestPatternMatchingIf_m05B5FD66EB3D6AD75E9583AFAE18903E843E0778_MetadataUsageId$non_lazy_ptr-(LPC22_1+4)) LPC22_1: add r0, pc ldr r0, [r0] ldr r0, [r0] bl __ZN6il2cpp2vm13MetadataCache24InitializeMethodMetadataEj movs r0, #1 strb r0, [r5] LBB22_2: cbz r4, LBB22_4 movw r0, :lower16:(L_Int32_t585191389E07734F19F3156FF88FB3EF4800D102_il2cpp_TypeInfo_var$non_lazy_ptr-(LPC22_2+4)) movt r0, :upper16:(L_Int32_t585191389E07734F19F3156FF88FB3EF4800D102_il2cpp_TypeInfo_var$non_lazy_ptr-(LPC22_2+4)) LPC22_2: add r0, pc ldr r0, [r0] ldr r1, [r0] ldr r0, [r4] cmp r0, r1 beq LBB22_5 LBB22_4: movs r0, #20 pop {r4, r5, r7, pc} LBB22_5: mov r0, r4 bl __Z5UnBoxP12Il2CppObjectP11Il2CppClass ldr r0, [r0] pop {r4, r5, r7, pc}
The beginning of the function is all method initialization overhead: the if
and call to il2cpp_codegen_initialize_method
. We then don't see a call to IsInstSealed
because it's been inlined as we can see from the comparison (cmp
) and if
check (beq
).
The call to UnBox
, however, wasn't inlined, and with good reason. It's about 150 instructions long, so I'll omit it here as there's too much to go through. In short, a ton of code is generated to support the exceptions that may be thrown. It appears that the type checking that was already done by IsInstSealed
is repeated and likely is the reason this function call can't be inlined due to all the exception overhead.
Conclusion: Pattern matching with if
is equivalent to type checking with is
then casting with (SomeType)o
.
Pattern Matching switch
Next up, we can do the same pattern matching using a new version of switch
. This new version supports non-integral types and evaluates its case
and default
sections sequentially. Here's how the equivalent of the previous test function could be rewritten with it:
static class TestClass { static int TestPatternMatchingSwitch(object o) { switch (o) { case int i: return i; default: return 20; } } }
Again, we combine a type check case int
with a variable declaration int i
to get pattern matching. When this case is checked, if i
is an int
then the section for this case
will execute. Otherwise, it'll proceed and execute the default
section.
Now let's look at the C++ that IL2CPP outputs:
extern "C" IL2CPP_METHOD_ATTR int32_t TestClass_TestPatternMatchingSwitch_m20EB7E1E14C792E7ADACC9B05BCD01557B6F4743 (RuntimeObject * ___o0, const RuntimeMethod* method) { static bool s_Il2CppMethodInitialized; if (!s_Il2CppMethodInitialized) { il2cpp_codegen_initialize_method (TestClass_TestPatternMatchingSwitch_m20EB7E1E14C792E7ADACC9B05BCD01557B6F4743_MetadataUsageId); s_Il2CppMethodInitialized = true; } RuntimeObject * V_0 = NULL; int32_t V_1 = 0; RuntimeObject * V_2 = NULL; { RuntimeObject * L_0 = ___o0; V_0 = L_0; RuntimeObject * L_1 = V_0; if (!L_1) { goto IL_0018; } } { RuntimeObject * L_2 = V_0; RuntimeObject * L_3 = L_2; V_2 = L_3; if (!((RuntimeObject *)IsInstSealed((RuntimeObject*)L_3, Int32_t585191389E07734F19F3156FF88FB3EF4800D102_il2cpp_TypeInfo_var))) { goto IL_0018; } } { RuntimeObject * L_4 = V_2; V_1 = ((*(int32_t*)((int32_t*)UnBox(L_4, Int32_t585191389E07734F19F3156FF88FB3EF4800D102_il2cpp_TypeInfo_var)))); int32_t L_5 = V_1; return L_5; } IL_0018: { return ((int32_t)20); } }
Notice that this is not the same, but it's close. The function still begins with the method initialization overhead and ends with the type checking and unboxing. In between, IL2CPP has inserted a null check before the case
executes. As we saw in the first example, this is even more redundant as both IsInstSealed
and UnBox
already check for null
.
We've already seen all the other C++ functions, so let's move right on to the assembly:
push {r4, r5, r7, lr} add r7, sp, #8 movw r5, :lower16:(__ZZ77TestClass_TestPatternMatchingSwitch_m20EB7E1E14C792E7ADACC9B05BCD01557B6F4743E25s_Il2CppMethodInitialized-(LPC24_0+4)) mov r4, r0 movt r5, :upper16:(__ZZ77TestClass_TestPatternMatchingSwitch_m20EB7E1E14C792E7ADACC9B05BCD01557B6F4743E25s_Il2CppMethodInitialized-(LPC24_0+4)) LPC24_0: add r5, pc ldrb r0, [r5] cbnz r0, LBB24_2 movw r0, :lower16:(L_TestClass_TestPatternMatchingSwitch_m20EB7E1E14C792E7ADACC9B05BCD01557B6F4743_MetadataUsageId$non_lazy_ptr-(LPC24_1+4)) movt r0, :upper16:(L_TestClass_TestPatternMatchingSwitch_m20EB7E1E14C792E7ADACC9B05BCD01557B6F4743_MetadataUsageId$non_lazy_ptr-(LPC24_1+4)) LPC24_1: add r0, pc ldr r0, [r0] ldr r0, [r0] bl __ZN6il2cpp2vm13MetadataCache24InitializeMethodMetadataEj movs r0, #1 strb r0, [r5] LBB24_2: cbz r4, LBB24_4 movw r0, :lower16:(L_Int32_t585191389E07734F19F3156FF88FB3EF4800D102_il2cpp_TypeInfo_var$non_lazy_ptr-(LPC24_2+4)) movt r0, :upper16:(L_Int32_t585191389E07734F19F3156FF88FB3EF4800D102_il2cpp_TypeInfo_var$non_lazy_ptr-(LPC24_2+4)) ldr r2, [r4] LPC24_2: add r0, pc ldr r0, [r0] ldr r1, [r0] cmp r2, r1 beq LBB24_5 LBB24_4: movs r0, #20 pop {r4, r5, r7, pc} LBB24_5: mov r0, r4 bl __Z5UnBoxP12Il2CppObjectP11Il2CppClass ldr r0, [r0] pop {r4, r5, r7, pc}
There are some minor textual differences between this function and the last one that used if
, but they're functionally equivalent as far as instructions executed goes. The C++ compiler was able to determine that the extra null check in the middle of the function was redundant and then removed it.
Conclusion: Pattern matching switch
generates slightly worse C++ than its if
equivalent, but the resulting assembly is identical.
Pattern Matching With when
Next we'll use another feature of the new pattern matching switch
: when
. This allows us to match more complex patterns than just a variable's type. To do so, we add a when
expression that evaluates to bool
after the pattern match. This expression has access to the casted variable, so we can presumably use that additional information to improve our pattern matching.
Here's how it looks:
static class TestClass { static int TestPatternMatchingSwitchWhen(object o) { switch (o) { case int i when i > 0: return i; default: return 20; } } }
This code adds when i > 0
compared to the previous version, so only positive int
values will match and non-int
and non-positive int
values will not.
Next, let's check the IL2CPP output:
extern "C" IL2CPP_METHOD_ATTR int32_t TestClass_TestPatternMatchingSwitchWhen_mEFD80A251F05B76A64C1AB826D6F61C430198B8E (RuntimeObject * ___o0, const RuntimeMethod* method) { static bool s_Il2CppMethodInitialized; if (!s_Il2CppMethodInitialized) { il2cpp_codegen_initialize_method (TestClass_TestPatternMatchingSwitchWhen_mEFD80A251F05B76A64C1AB826D6F61C430198B8E_MetadataUsageId); s_Il2CppMethodInitialized = true; } RuntimeObject * V_0 = NULL; int32_t V_1 = 0; int32_t V_2 = 0; RuntimeObject * V_3 = NULL; { RuntimeObject * L_0 = ___o0; V_0 = L_0; RuntimeObject * L_1 = V_0; if (!L_1) { goto IL_001e; } } { RuntimeObject * L_2 = V_0; RuntimeObject * L_3 = L_2; V_3 = L_3; if (!((RuntimeObject *)IsInstSealed((RuntimeObject*)L_3, Int32_t585191389E07734F19F3156FF88FB3EF4800D102_il2cpp_TypeInfo_var))) { goto IL_001e; } } { RuntimeObject * L_4 = V_3; V_1 = ((*(int32_t*)((int32_t*)UnBox(L_4, Int32_t585191389E07734F19F3156FF88FB3EF4800D102_il2cpp_TypeInfo_var)))); int32_t L_5 = V_1; V_2 = L_5; int32_t L_6 = V_2; if ((((int32_t)L_6) <= ((int32_t)0))) { goto IL_001e; } } { int32_t L_7 = V_2; return L_7; } IL_001e: { return ((int32_t)20); } }
Most of this should look familiar. All the way up through the UnBox
call it's exactly like the regular switch
test function. At that point, we see the where
expression execute and check if the value is less than or equal to zero. This is the opposite of what we wrote, but fits with IL2CPP's goto
-based control flow method by jumping down to the end of the function for the default
section.
Let's check out the assembly now:
push {r4, r5, r7, lr} add r7, sp, #8 movw r5, :lower16:(__ZZ81TestClass_TestPatternMatchingSwitchWhen_mEFD80A251F05B76A64C1AB826D6F61C430198B8EE25s_Il2CppMethodInitialized-(LPC25_0+4)) mov r4, r0 movt r5, :upper16:(__ZZ81TestClass_TestPatternMatchingSwitchWhen_mEFD80A251F05B76A64C1AB826D6F61C430198B8EE25s_Il2CppMethodInitialized-(LPC25_0+4)) LPC25_0: add r5, pc ldrb r0, [r5] cbnz r0, LBB25_2 movw r0, :lower16:(L_TestClass_TestPatternMatchingSwitchWhen_mEFD80A251F05B76A64C1AB826D6F61C430198B8E_MetadataUsageId$non_lazy_ptr-(LPC25_1+4)) movt r0, :upper16:(L_TestClass_TestPatternMatchingSwitchWhen_mEFD80A251F05B76A64C1AB826D6F61C430198B8E_MetadataUsageId$non_lazy_ptr-(LPC25_1+4)) LPC25_1: add r0, pc ldr r0, [r0] ldr r0, [r0] bl __ZN6il2cpp2vm13MetadataCache24InitializeMethodMetadataEj movs r0, #1 strb r0, [r5] LBB25_2: cbz r4, LBB25_6 movw r0, :lower16:(L_Int32_t585191389E07734F19F3156FF88FB3EF4800D102_il2cpp_TypeInfo_var$non_lazy_ptr-(LPC25_2+4)) movt r0, :upper16:(L_Int32_t585191389E07734F19F3156FF88FB3EF4800D102_il2cpp_TypeInfo_var$non_lazy_ptr-(LPC25_2+4)) ldr r2, [r4] LPC25_2: add r0, pc ldr r0, [r0] ldr r1, [r0] cmp r2, r1 bne LBB25_6 mov r0, r4 bl __Z5UnBoxP12Il2CppObjectP11Il2CppClass ldr r0, [r0] cmp r0, #0 ble LBB25_6 pop {r4, r5, r7, pc} LBB25_6: movs r0, #20 pop {r4, r5, r7, pc}
This again looks very similar to the previous function. The only differences are at the very end where we see the comparison (cmp
) and conditional jump when less than or equal to zero (ble
).
Conclusion: when
expressions are implemented in a straightforward manner: just after the type check succeeds and the variable is available.
Pattern Matching switch
Without Type Checking
The presence of when
seems to be tied to type checking, but is that really necessary? What if we were to write code that pattern matched the same type so no type checking is necessary? Would we still incur type checking just to get access to when
? Let's try!
static class TestClass { static int TestPatternMatchingSwitchWhenWithoutTypeCheck(int i) { switch (i) { case int i2 when i2 > 0: return i; default: return 20; } } }
Here the function takes an int
and we pattern match against int
. Let's look at the C++ for this:
extern "C" IL2CPP_METHOD_ATTR int32_t TestClass_TestPatternMatchingSwitchWhenWithoutTypeCheck_mF6E40E4DBF68E15955FFFBDD4CD5FD0B1FF2B2C1 (int32_t ___i0, const RuntimeMethod* method) { int32_t V_0 = 0; { int32_t L_0 = ___i0; V_0 = L_0; int32_t L_1 = V_0; if ((((int32_t)L_1) <= ((int32_t)0))) { goto IL_0008; } } { int32_t L_2 = ___i0; return L_2; } IL_0008: { return ((int32_t)20); } }
This is much simpler than the previous functions! The method initialization is gone because the type checking has been completely removed. The null check is gone because it makes no sense with an int
which can't be null
. All we're left with is a simple if
and a bit of goto
-based flow control.
Here's the assembly this compiles to:
cmp r0, #1 it lt movlt r0, #20 bx lr
All we're left with is the bare comparison (cmp
), conditional move operation (it
+ movlt
), and return (bx
). This is a great result!
Conclusion: when
expressions can be used without any type checking with optimal assembly generation.
Pattern Matching switch
With Literals
Next let's try a pattern matching switch
that has some literal cases:
static class TestClass { static int TestPatternMatchingSwitchLiterals(object o) { switch (o) { case null: return 1; case 0: return 2; case 1: return 3; case false: return 4; case "": return 5; case int i: return i; default: return 6; } } }
All of these must be compile-time constants, so ""
is allowed but string.Empty
isn't since it's static readonly
instead of const
. Here we're checking for null
, two int
constants (0
and 1
) in a row, false
, the empty string (""
), and finally the int
type before the default
case.
On to the C++…
extern "C" IL2CPP_METHOD_ATTR int32_t TestClass_TestPatternMatchingSwitchLiterals_mE151901AC22318151F5BAADA244A35014EB265D7 (RuntimeObject * ___o0, const RuntimeMethod* method) { static bool s_Il2CppMethodInitialized; if (!s_Il2CppMethodInitialized) { il2cpp_codegen_initialize_method (TestClass_TestPatternMatchingSwitchLiterals_mE151901AC22318151F5BAADA244A35014EB265D7_MetadataUsageId); s_Il2CppMethodInitialized = true; } RuntimeObject * V_0 = NULL; int32_t V_1 = 0; bool V_2 = false; String_t* V_3 = NULL; RuntimeObject * V_4 = NULL; { RuntimeObject * L_0 = ___o0; V_0 = L_0; RuntimeObject * L_1 = V_0; if (!L_1) { goto IL_005f; } } { RuntimeObject * L_2 = V_0; RuntimeObject * L_3 = L_2; V_4 = L_3; if (!((RuntimeObject *)IsInstSealed((RuntimeObject*)L_3, Int32_t585191389E07734F19F3156FF88FB3EF4800D102_il2cpp_TypeInfo_var))) { goto IL_001f; } } { RuntimeObject * L_4 = V_4; V_1 = ((*(int32_t*)((int32_t*)UnBox(L_4, Int32_t585191389E07734F19F3156FF88FB3EF4800D102_il2cpp_TypeInfo_var)))); int32_t L_5 = V_1; if (!L_5) { goto IL_0061; } } { int32_t L_6 = V_1; if ((((int32_t)L_6) == ((int32_t)1))) { goto IL_0063; } } IL_001f: { RuntimeObject * L_7 = V_0; RuntimeObject * L_8 = L_7; V_4 = L_8; if (!((RuntimeObject *)IsInstSealed((RuntimeObject*)L_8, Boolean_tB53F6830F670160873277339AA58F15CAED4399C_il2cpp_TypeInfo_var))) { goto IL_0035; } } { RuntimeObject * L_9 = V_4; V_2 = ((*(bool*)((bool*)UnBox(L_9, Boolean_tB53F6830F670160873277339AA58F15CAED4399C_il2cpp_TypeInfo_var)))); bool L_10 = V_2; if (!L_10) { goto IL_0065; } } IL_0035: { RuntimeObject * L_11 = V_0; String_t* L_12 = ((String_t*)IsInstSealed((RuntimeObject*)L_11, String_t_il2cpp_TypeInfo_var)); V_3 = L_12; if (!L_12) { goto IL_004a; } } { String_t* L_13 = V_3; if (!L_13) { goto IL_004a; } } { String_t* L_14 = V_3; NullCheck(L_14); int32_t L_15 = String_get_Length_mD48C8A16A5CF1914F330DCE82D9BE15C3BEDD018(L_14, /*hidden argument*/NULL); if (!L_15) { goto IL_0067; } } IL_004a: { RuntimeObject * L_16 = V_0; RuntimeObject * L_17 = L_16; V_4 = L_17; if (!((RuntimeObject *)IsInstSealed((RuntimeObject*)L_17, Int32_t585191389E07734F19F3156FF88FB3EF4800D102_il2cpp_TypeInfo_var))) { goto IL_006b; } } { RuntimeObject * L_18 = V_4; V_1 = ((*(int32_t*)((int32_t*)UnBox(L_18, Int32_t585191389E07734F19F3156FF88FB3EF4800D102_il2cpp_TypeInfo_var)))); goto IL_0069; } IL_005f: { return 1; } IL_0061: { return 2; } IL_0063: { return 3; } IL_0065: { return 4; } IL_0067: { return 5; } IL_0069: { int32_t L_19 = V_1; return L_19; } IL_006b: { return 6; } }
This code is quite verbose and non-idiomatic due to all the goto
and extraneous blocks, but it's still pretty simple. We have the expected method initialization overhead as type checking will occur in this function. Then we have the null check we've seen with other pattern matching switch
examples except that here it jumps to a block that returns 1
since that's what happens in the case null
section.
Afterward, the function continues on down the line of case
and default
checks calling IsInstSealed
for int
, bool
, and string
. One nice optimization is that the adjacent case 0
and case 1
sections don't each have their own IsInstSealed
checks. Instead, there's a check for the first one and the second reuses that result. Also notice that the UnBox
call has been removed in those cases as the value of the int
isn't actually used.
Unfortunately, the IsInstSealed
check is redundantly made when the int
cases stop and then pick up again later with case int i
. Let's see how this all translates into assembly:
push {r4, r5, r7, lr} add r7, sp, #8 movw r5, :lower16:(__ZZ85TestClass_TestPatternMatchingSwitchLiterals_mE151901AC22318151F5BAADA244A35014EB265D7E25s_Il2CppMethodInitialized-(LPC27_0+4)) mov r4, r0 movt r5, :upper16:(__ZZ85TestClass_TestPatternMatchingSwitchLiterals_mE151901AC22318151F5BAADA244A35014EB265D7E25s_Il2CppMethodInitialized-(LPC27_0+4)) LPC27_0: add r5, pc ldrb r0, [r5] cbnz r0, LBB27_2 movw r0, :lower16:(L_TestClass_TestPatternMatchingSwitchLiterals_mE151901AC22318151F5BAADA244A35014EB265D7_MetadataUsageId$non_lazy_ptr-(LPC27_1+4)) movt r0, :upper16:(L_TestClass_TestPatternMatchingSwitchLiterals_mE151901AC22318151F5BAADA244A35014EB265D7_MetadataUsageId$non_lazy_ptr-(LPC27_1+4)) LPC27_1: add r0, pc ldr r0, [r0] ldr r0, [r0] bl __ZN6il2cpp2vm13MetadataCache24InitializeMethodMetadataEj movs r0, #1 strb r0, [r5] LBB27_2: cbz r4, LBB27_11 movw r0, :lower16:(L_Int32_t585191389E07734F19F3156FF88FB3EF4800D102_il2cpp_TypeInfo_var$non_lazy_ptr-(LPC27_2+4)) movt r0, :upper16:(L_Int32_t585191389E07734F19F3156FF88FB3EF4800D102_il2cpp_TypeInfo_var$non_lazy_ptr-(LPC27_2+4)) ldr r1, [r4] LPC27_2: add r0, pc ldr r5, [r0] ldr r2, [r5] cmp r1, r2 beq LBB27_12 LBB27_4: movw r0, :lower16:(L_Boolean_tB53F6830F670160873277339AA58F15CAED4399C_il2cpp_TypeInfo_var$non_lazy_ptr-(LPC27_3+4)) movt r0, :upper16:(L_Boolean_tB53F6830F670160873277339AA58F15CAED4399C_il2cpp_TypeInfo_var$non_lazy_ptr-(LPC27_3+4)) LPC27_3: add r0, pc ldr r0, [r0] ldr r0, [r0] cmp r1, r0 bne LBB27_7 mov r0, r4 bl __Z5UnBoxP12Il2CppObjectP11Il2CppClass ldrb r0, [r0] cmp r0, #1 bne LBB27_15 ldr r1, [r4] LBB27_7: movw r0, :lower16:(L_String_t_il2cpp_TypeInfo_var$non_lazy_ptr-(LPC27_4+4)) movt r0, :upper16:(L_String_t_il2cpp_TypeInfo_var$non_lazy_ptr-(LPC27_4+4)) LPC27_4: add r0, pc ldr r0, [r0] ldr r0, [r0] cmp r1, r0 bne LBB27_10 mov r0, r4 movs r1, #0 bl _String_get_Length_mD48C8A16A5CF1914F330DCE82D9BE15C3BEDD018 cbz r0, LBB27_18 ldr r1, [r4] ldr r0, [r5] cmp r1, r0 itt ne movne r0, #6 popne {r4, r5, r7, pc} mov r0, r4 bl __Z5UnBoxP12Il2CppObjectP11Il2CppClass ldr r0, [r0] pop {r4, r5, r7, pc} LBB27_11: movs r0, #1 pop {r4, r5, r7, pc} LBB27_12: mov r0, r4 mov r1, r2 bl __Z5UnBoxP12Il2CppObjectP11Il2CppClass ldr r0, [r0] cbz r0, LBB27_16 cmp r0, #1 bne LBB27_17 movs r0, #3 pop {r4, r5, r7, pc} LBB27_15: movs r0, #4 pop {r4, r5, r7, pc} LBB27_16: movs r0, #2 pop {r4, r5, r7, pc} LBB27_17: ldr r1, [r4] b LBB27_4 LBB27_18: movs r0, #5 pop {r4, r5, r7, pc}
This is pretty long, but again rather simple. After the method initialization overhead, we see the type checks for int
, bool
, and string
. In the case of bool
and int
, we see the UnBox
call followed by checking of the value. For bool
, it just checks for false
but for int
we see the checks for 0
and 1
immediately followed by essentially an else
for case int i
. This means the double type checking for int
has been removed by the C++ compiler, resulting in more efficient assembly.
Conclusion: A pattern matching switch
with a constant case 0
, for example, results in the same code as case int i when i == 0
. The C++ has redundant type checks, but the C++ compiler may optimize this.
Pattern Matching switch
With Changed Types
Seeing the redundant type checking above made me wonder: is there some scenario where that check is necessary? To find out, I wrote a when
expression that changed the type:
static class TestClass { static int TestPatternMatchingSwitchChangeTypeFollowSameType(object o) { switch (o) { case 0: return 1; case int i when (o = "changed") != null: return 2; case 1: return 3; default: return 4; } } }
This is very likely a bad practice, but it's just an example to see what'll happen. Here the case int i
might change the type because its when
expression sets the switch
variable o
to a string
and then proceeds on to the next case
because "changed"
is never null
.
Let's see what C++ is generated for this:
extern "C" IL2CPP_METHOD_ATTR int32_t TestClass_TestPatternMatchingSwitchChangeTypeFollowSameType_m4A8E72922A8BF83F2732578C24B36EDCEC977B07 (RuntimeObject * ___o0, const RuntimeMethod* method) { static bool s_Il2CppMethodInitialized; if (!s_Il2CppMethodInitialized) { il2cpp_codegen_initialize_method (TestClass_TestPatternMatchingSwitchChangeTypeFollowSameType_m4A8E72922A8BF83F2732578C24B36EDCEC977B07_MetadataUsageId); s_Il2CppMethodInitialized = true; } RuntimeObject * V_0 = NULL; int32_t V_1 = 0; RuntimeObject * V_2 = NULL; { RuntimeObject * L_0 = ___o0; V_0 = L_0; RuntimeObject * L_1 = V_0; if (!L_1) { goto IL_0031; } } { RuntimeObject * L_2 = V_0; RuntimeObject * L_3 = L_2; V_2 = L_3; if (!((RuntimeObject *)IsInstSealed((RuntimeObject*)L_3, Int32_t585191389E07734F19F3156FF88FB3EF4800D102_il2cpp_TypeInfo_var))) { goto IL_0031; } } { RuntimeObject * L_4 = V_2; V_1 = ((*(int32_t*)((int32_t*)UnBox(L_4, Int32_t585191389E07734F19F3156FF88FB3EF4800D102_il2cpp_TypeInfo_var)))); int32_t L_5 = V_1; if (!L_5) { goto IL_0021; } } { goto IL_0023; } IL_001b: { int32_t L_6 = V_1; if ((((int32_t)L_6) == ((int32_t)1))) { goto IL_002f; } } { goto IL_0031; } IL_0021: { return 1; } IL_0023: { String_t* L_7 = _stringLiteral37C6C57BEDF4305EF41249C1794760B5CB8FAD17; ___o0 = L_7; if (!L_7) { goto IL_001b; } } { return 2; } IL_002f: { return 3; } IL_0031: { return 4; } }
The whole beginning part of the function is the usual method initialization, null check, int
check, unboxing to int
, and check for 0
. Then it skips down to IL_0023
where the "changed"
/_stringLiteral...
is assigned to the parameter ___o0
. If it's not null, it jumps back up the function to check for 1
. What it checks for one is L_6
which is assigned from V_1
, one of the many copies of the parameter object
. Since the copy wasn't changed, this check is correct.
Here's what what the C++ compiled to:
push {r4, r5, r7, lr} add r7, sp, #8 movw r5, :lower16:(__ZZ101TestClass_TestPatternMatchingSwitchChangeTypeFollowSameType_m4A8E72922A8BF83F2732578C24B36EDCEC977B07E25s_Il2CppMethodInitialized-(LPC28_0+4)) mov r4, r0 movt r5, :upper16:(__ZZ101TestClass_TestPatternMatchingSwitchChangeTypeFollowSameType_m4A8E72922A8BF83F2732578C24B36EDCEC977B07E25s_Il2CppMethodInitialized-(LPC28_0+4)) LPC28_0: add r5, pc ldrb r0, [r5] cbnz r0, LBB28_2 movw r0, :lower16:(L_TestClass_TestPatternMatchingSwitchChangeTypeFollowSameType_m4A8E72922A8BF83F2732578C24B36EDCEC977B07_MetadataUsageId$non_lazy_ptr-(LPC28_1+4)) movt r0, :upper16:(L_TestClass_TestPatternMatchingSwitchChangeTypeFollowSameType_m4A8E72922A8BF83F2732578C24B36EDCEC977B07_MetadataUsageId$non_lazy_ptr-(LPC28_1+4)) LPC28_1: add r0, pc ldr r0, [r0] ldr r0, [r0] bl __ZN6il2cpp2vm13MetadataCache24InitializeMethodMetadataEj movs r0, #1 strb r0, [r5] LBB28_2: cbz r4, LBB28_4 movw r0, :lower16:(L_Int32_t585191389E07734F19F3156FF88FB3EF4800D102_il2cpp_TypeInfo_var$non_lazy_ptr-(LPC28_2+4)) movt r0, :upper16:(L_Int32_t585191389E07734F19F3156FF88FB3EF4800D102_il2cpp_TypeInfo_var$non_lazy_ptr-(LPC28_2+4)) ldr r2, [r4] LPC28_2: add r0, pc ldr r0, [r0] ldr r1, [r0] cmp r2, r1 beq LBB28_5 LBB28_4: movs r0, #4 pop {r4, r5, r7, pc} LBB28_5: mov r0, r4 bl __Z5UnBoxP12Il2CppObjectP11Il2CppClass ldr r0, [r0] cbz r0, LBB28_8 movw r1, :lower16:(L__stringLiteral37C6C57BEDF4305EF41249C1794760B5CB8FAD17$non_lazy_ptr-(LPC28_3+4)) movt r1, :upper16:(L__stringLiteral37C6C57BEDF4305EF41249C1794760B5CB8FAD17$non_lazy_ptr-(LPC28_3+4)) LPC28_3: add r1, pc ldr r1, [r1] ldr r1, [r1] cbz r1, LBB28_9 movs r0, #2 pop {r4, r5, r7, pc} LBB28_8: movs r0, #1 pop {r4, r5, r7, pc} LBB28_9: cmp r0, #1 bne LBB28_4 movs r0, #3 pop {r4, r5, r7, pc}
As usual, the whole first half is method initialization overhead. Then there's the inlined IsInstSealed
call and the not-inlined UnBox
call. If the unboxed value is zero, 1
is returned. Otherwise the string literal is assigned and checked for null even though there's no way that's possible. Finally there's the check for 1
with the appropriate return values for all those cases.
Conclusion: Changing the type of the switch
variable in a when
expression won't cause errors, but may cause sub-optimal assembly code.
Conclusion
Pattern matching if
and switch
are convenient syntax sugar available to us in C# 7.3. The if
version equivalency looks like this:
////////// // C# 6 // ////////// int i = 0; if (o is int) { i = (int)o // ... #1 } // ... #2 //////////// // C# 7.3 // //////////// if (o is int i) { // ... #1 } // ... #2
The switch
form equivalency looks like this:
////////// // C# 6 // ////////// if (o is int) { int i = (int)o; if (i == 0) { // ... #1 } else if (i == 1) { // ... #2 } // ... #3 } else if (o is string) { string s = (string)o; // ... #4 } else { // ... #5 } //////////// // C# 7.3 // //////////// switch (o) { case int i when i == 0: // ... #1 break; case int i when i == 1: // ... #2 break; case int i: // ... #3 break; case string s: // ... #4 break; default: // ... #5 break; }
The type checking comes with method initialization overhead whenever its used, but this can be avoided with switch
by specifying the same type as the type of the switch
variable. While IL2CPP sometimes outputs redundant code, the C++ compiler (in Xcode 10.1 release builds for ARM64 at least) sometimes catches this and generates more optimal machine code.
#1 by Jes on December 25th, 2018 ·
Hi
In the very beginning you check pattern matching of Object to ValueType and have conclusion that it is equivalent to type checking with ‘is’ then casting with (SomeType)o.
But what about pattern matching Object to ReferenceType say class Base to class Derived1?
I expect it to be equivalent to type checking with ‘as’ then checking for null.
#2 by jackson on December 29th, 2018 ·
Excellent question! I didn’t cover this in the article, so I’ll cover it here.
First, I made two classes:
Then I wrote these test functions:
Here’s the C++ that’s generated by IL2CPP:
In the first case of checking whether
object
isBase
, we getIsInstClass
instead ofIsInstClassSealed
as seen before for the equivalent ofo is Base
. Theas
cast is unnecessary as this returns aBase
pointer.In the second case of checking whether
Base
isDerived
, we get the exact sameIsInstClass
.Let’s see how the assembly looks for
TestPatternMatchingIfBase
:Most of this is method initialization and the null check with just a little bit in the middle for the actual work of the function. Thankfully, all of the
IsInstClass
call being inlined. The assembly forTestPatternMatchingIfDerived
looks essentially the same, so I’ll omit it here.The takeaway here is that the type checking of a reference type is more expensive than with a value type, but still equivalent to what you’d get if you performed the checking manually with
o as MyType
ando != null
as you expected.#3 by MechEthan on January 18th, 2019 ·
Thanks for the update!