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.