There are a lot of ways to write C# code that has no effect. One common way is to initialize class fields to their default values: public int Value = 0;. Today we’ll go over five types of useless code and see what effect it has on the actual machine code that the CPU executes. Do IL2CPP and the C++ compiler always do the right thing? Let’s find out!

Setting fields to their default values

All fields of a class are initially set to their default value: 0, false, null, 0.0, etc. Still, it’s really common to see programmers explicitly set the initial value to the same default value. For example, consider this class:

public class MyClass
{
    public int ImplicitlyDefault;
    public int SetToDefaultInConstructor;
    public int SetToDefaultInline = 0;
    public int SetToNonDefaultInConstructor;
    public int SetToNonDefaultInline = 123;
 
    public MyClass()
    {
        SetToDefaultInConstructor = 0;
        SetToNonDefaultInConstructor = 123;
    }
}

Here we have five fields initialized in different ways:

  • ImplicitlyDefault: initialized by the runtime system to the default value
  • SetToDefaultInConstructor: initialized to the default value in the constructor
  • SetToDefaultInline: initialized to the default value inline
  • SetToNonDefaultInConstructor: initialized to a non-default value in the constructor
  • SetToNonDefaultInline: initialized to a non-default value inline

Let’s see what IL2CPP generates for the MyClass constructor:

extern "C" IL2CPP_METHOD_ATTR void MyClass__ctor_m2144872410 (MyClass_t3388352440 * __this, const RuntimeMethod* method)
{
    {
        __this->set_SetToNonDefaultInline_4(((int32_t)123));
        Object__ctor_m297566312(__this, /*hidden argument*/NULL);
        __this->set_SetToDefaultInConstructor_1(0);
        __this->set_SetToNonDefaultInConstructor_3(((int32_t)123));
        return;
    }
}

This constructor comes in three stages. First, the inline field initializers are run. In this case we see SetToNonDefaultInline is set to 123 but we don’t see SetToDefaultInline set to 0. Second, the base class (System.Object) constructor is run. Finally, the constructor’s body is run to set SetToDefaultInConstructor to 0 and SetToNonDefaultInConstructor to 123.

So far we’ve seen that IL2CPP didn’t generate any C++ code for the inline setting of a field to its default value, but it did generate C++ code for our setting of a field to its default value in the constructor. Let’s see if that translates into machine code by looking at the assembly output of the C++ compiler. Here’s what Xcode 9.4.1 generates for an iOS ARM64 release build: (cleaned up and annotated by Jackson)

stp   x20, x19, [sp, #-32]!
stp   x29, x30, [sp, #16]
add   x29, sp, #16
mov   x19, x0
mov   w20, #123
str   w20, [x19, #32]             ; SetToNonDefaultInConstructor = 123
mov   x1, #0
bl    _Object__ctor_m297566312    ; Call System.Object's constructor
str   wzr, [x19, #20]             ; SetToDefaultInConstructor = 0
str   w20, [x19, #28]             ; SetToNonDefaultInConstructor = 123
ldp   x29, x30, [sp, #16]
ldp   x20, x19, [sp], #32
ret

The C++ compiler still generated machine code to redundantly and unnecessarily set a field to its default value.

Conclusion: Setting fields to their default value has no effect when done inline but wastes CPU when done in a constructor’s body.

Using the f suffix with doubles

Real numbers can be specified as either double or float literals depending on whether the f suffix is used. 123.456 is a double and 123.456f is a float. Since float is much more common in games than double, adding the f suffix is reflexive for some programmers. What happens when accidentally using the f suffix with a double variable? Let’s try!

public static class TestClass
{
    public static double FloatToDouble()
    {
        return 123.456f;
    }
 
    public static float FloatToFloat()
    {
        return 123.456f;
    }
}

Here we have one function that returns a float literal (123.456f) for a double return type and another function that returns the same float literal for a float return type. Let’s see what IL2CPP generates:

extern "C" IL2CPP_METHOD_ATTR double TestClass_FloatToDouble_m2539055131 (RuntimeObject * __this /* static, unused */, const RuntimeMethod* method)
{
    {
        return (123.45600128173828);
    }
}
 
extern "C" IL2CPP_METHOD_ATTR float TestClass_FloatToFloat_m2528796140 (RuntimeObject * __this /* static, unused */, const RuntimeMethod* method)
{
    {
        return (123.456f);
    }
}

The generated C++ shows that the float literal in FloatToDouble has been converted to a double literal: 123.45600128173828. Meanwhile, the float literal in FloatToFloat is preserved exactly as it was in C#: 123.456f.

Conclusion: Floating point constants with the f suffix are converted at build time to double constants when used as a double.

Empty overrides

A virtual method can be overridden by a derived class using the override keyword. Sometimes programmers or their IDEs will generate an override method that simply calls the base class’ method that’s being overridden. Here’s how that looks:

public class BaseClass
{
    public virtual void EmptyMethod()
    {
    }
}
 
public class DerivedClass : BaseClass
{
    public override void EmptyMethod()
    {
        base.EmptyMethod();
    }
}
 
public static class TestClass
{
    public static void CallBaseEmptyMethod(BaseClass bc)
    {
        bc.EmptyMethod();
    }
 
    public static void CallDerivedEmptyMethod(DerivedClass dc)
    {
        dc.EmptyMethod();
    }
}

Neither CallBaseEmptyMethod nor CallDerivedEmptyMethod do anything regardless of the parameter type they’re passed. Let’s see if IL2CPP can figure this out and generate C++ that doesn’t make any method calls:

extern "C" IL2CPP_METHOD_ATTR void TestClass_CallBaseEmptyMethod_m3638386401 (RuntimeObject * __this /* static, unused */, BaseClass_t296860279 * ___bc0, const RuntimeMethod* method)
{
    {
        BaseClass_t296860279 * L_0 = ___bc0;
        NullCheck(L_0);
        VirtActionInvoker0::Invoke(4 /* System.Void BaseClass::EmptyMethod() */, L_0);
        return;
    }
}
 
extern "C" IL2CPP_METHOD_ATTR void TestClass_CallDerivedEmptyMethod_m657137151 (RuntimeObject * __this /* static, unused */, DerivedClass_t2882573829 * ___dc0, const RuntimeMethod* method)
{
    {
        DerivedClass_t2882573829 * L_0 = ___dc0;
        NullCheck(L_0);
        VirtActionInvoker0::Invoke(4 /* System.Void BaseClass::EmptyMethod() */, L_0);
        return;
    }
}

IL2CPP still generated the virtual method calls for CallBaseEmptyMethod and CallDerivedEmptyMethod, so let’s check if the C++ compiler can figure out that these calls do nothing and remove them:

; CallBaseEmptyMethod
    stp   x20, x19, [sp, #-32]!
    stp   x29, x30, [sp, #16]
    add   x29, sp, #16
    mov   x19, x1
    cbnz  x19, LBB10_2
    mov   x0, #0
    bl    __ZN6il2cpp2vm9Exception27RaiseNullReferenceExceptionEP19Il2CppSequencePoint
LBB10_2:
    ldr   x8, [x19]
    ldp   x2, x1, [x8, #368]
    mov   x0, x19
    ldp   x29, x30, [sp, #16]
    ldp   x20, x19, [sp], #32
    br    x2                  ; bc.EmptyMethod();
 
 
; CallDerivedEmptyMethod
    stp   x20, x19, [sp, #-32]!
    stp   x29, x30, [sp, #16]
    add   x29, sp, #16
    mov   x19, x1
    cbnz  x19, LBB11_2
    mov   x0, #0
    bl    __ZN6il2cpp2vm9Exception27RaiseNullReferenceExceptionEP19Il2CppSequencePoint
LBB11_2:
    ldr   x8, [x19]
    ldp   x2, x1, [x8, #368]
    mov   x0, x19
    ldp   x29, x30, [sp, #16]
    ldp   x20, x19, [sp], #32
    br   x2                   ; dc.EmptyMethod();

The virtual method calls in both functions made it all the way to the machine code that the CPU executes. Now let’s see what the EmptyMethod methods look like:

extern "C" IL2CPP_METHOD_ATTR void BaseClass_EmptyMethod_m1600043815 (BaseClass_t296860279 * __this, const RuntimeMethod* method)
{
    {
        return;
    }
}
 
extern "C" IL2CPP_METHOD_ATTR void DerivedClass_EmptyMethod_m1036491442 (DerivedClass_t2882573829 * __this, const RuntimeMethod* method)
{
    {
        BaseClass_EmptyMethod_m1600043815(__this, /*hidden argument*/NULL);
        return;
    }
}

BaseClass.EmptyMethod is literally empty and DerivedClass.EmptyMethod is a non-virtual call to BaseClass.EmptyMethod. Now let’s see what these turn into when compiled:

; BaseClass.EmptyMethod
ret
 
; DerivedClass.EmptyMethod
ret

Here the C++ compiler was able to generate two completely empty functions. Even the call from DerivedClass.EmptyMethod to BaseClass.EmptyMethod was removed, which is good because it did nothing.

Conclusion: Calling an empty virtual method or one that only calls the base method it overrides still results in a virtual method call. The generated functions for the virtual and override methods are empty.

Redundant type checks

C# provides at least three ways to check types at runtime:

  • (T)x: Check if x is type T, cast if so, throw an exception otherwise
  • x as T: Check if x is type T, cast if so, null otherwise
  • x is T: Check if x is type T, true if so, false otherwise

Sometimes programmers will perform redundant checks like this:

public static class TestClass
{
    public static DerivedClass RedundantTypeCheck(BaseClass bc, DerivedClass or)
    {
        if (bc is DerivedClass)
        {
            return (DerivedClass)bc;
        }
        return or;
    }
}

This code performs the x is T check and then the (T)x check. Here’s the equivalent code that uses just x as T:

public static class TestClass
{
    public static DerivedClass NoRedundantTypeCheck(BaseClass bc, DerivedClass or)
    {
        DerivedClass dc = bc as DerivedClass;
        if (dc != null)
        {
            return dc;
        }
        return or;
    }
}

Let’s look at the IL2CPP output for these methods:

extern "C" IL2CPP_METHOD_ATTR DerivedClass_t2882573829 * TestClass_RedundantTypeCheck_m2601560077 (RuntimeObject * __this /* static, unused */, BaseClass_t296860279 * ___bc0, DerivedClass_t2882573829 * ___or1, const RuntimeMethod* method)
{
    static bool s_Il2CppMethodInitialized;
    if (!s_Il2CppMethodInitialized)
    {
        il2cpp_codegen_initialize_method (TestClass_RedundantTypeCheck_m2601560077_MetadataUsageId);
        s_Il2CppMethodInitialized = true;
    }
    {
        BaseClass_t296860279 * L_0 = ___bc0;
        if (!((DerivedClass_t2882573829 *)IsInstClass((RuntimeObject*)L_0, DerivedClass_t2882573829_il2cpp_TypeInfo_var)))
        {
            goto IL_0012;
        }
    }
    {
        BaseClass_t296860279 * L_1 = ___bc0;
        return ((DerivedClass_t2882573829 *)CastclassClass((RuntimeObject*)L_1, DerivedClass_t2882573829_il2cpp_TypeInfo_var));
    }
 
IL_0012:
    {
        DerivedClass_t2882573829 * L_2 = ___or1;
        return L_2;
    }
}
 
extern "C" IL2CPP_METHOD_ATTR DerivedClass_t2882573829 * TestClass_NoRedundantTypeCheck_m592734819 (RuntimeObject * __this /* static, unused */, BaseClass_t296860279 * ___bc0, DerivedClass_t2882573829 * ___or1, const RuntimeMethod* method)
{
    static bool s_Il2CppMethodInitialized;
    if (!s_Il2CppMethodInitialized)
    {
        il2cpp_codegen_initialize_method (TestClass_NoRedundantTypeCheck_m592734819_MetadataUsageId);
        s_Il2CppMethodInitialized = true;
    }
    DerivedClass_t2882573829 * V_0 = NULL;
    {
        BaseClass_t296860279 * L_0 = ___bc0;
        V_0 = ((DerivedClass_t2882573829 *)IsInstClass((RuntimeObject*)L_0, DerivedClass_t2882573829_il2cpp_TypeInfo_var));
        DerivedClass_t2882573829 * L_1 = V_0;
        if (!L_1)
        {
            goto IL_000f;
        }
    }
    {
        DerivedClass_t2882573829 * L_2 = V_0;
        return L_2;
    }
 
IL_000f:
    {
        DerivedClass_t2882573829 * L_3 = ___or1;
        return L_3;
    }
}

The generated C++ for RedundantTypeCheck calls IsInstClass to perform the x is T check and then CastclassClass to perform the (T)x check and cast. NoRedundantTypeCheck calls the same IsInstClass to perform the x as T check and then simply checks for null.

At this point the IL2CPP-generated C++ code still has the redundant type checking, so let’s look at the C++ compiler’s output to see how the machine code looks:

; RedundantTypeCheck
    stp   x22, x21, [sp, #-48]!
    stp   x20, x19, [sp, #16]
    stp   x29, x30, [sp, #32]
    add   x29, sp, #32
    mov   x19, x2
    mov   x20, x1
    adrp  x21, __ZZ40TestClass_RedundantTypeCheck_m2601560077E25s_Il2CppMethodInitialized@PAGE
    ldrb  w8, [x21, __ZZ40TestClass_RedundantTypeCheck_m2601560077E25s_Il2CppMethodInitialized@PAGEOFF]
    tbnz  w8, #0, LBB14_2
    adrp  x8, _TestClass_RedundantTypeCheck_m2601560077_MetadataUsageId@GOTPAGE
    ldr   x8, [x8, _TestClass_RedundantTypeCheck_m2601560077_MetadataUsageId@GOTPAGEOFF]
    ldr   w0, [x8]
    bl   __ZN6il2cpp2vm13MetadataCache24InitializeMethodMetadataEj
    orr   w8, wzr, #0x1
    strb  w8, [x21, __ZZ40TestClass_RedundantTypeCheck_m2601560077E25s_Il2CppMethodInitialized@PAGEOFF]
LBB14_2:
    cbz   x20, LBB14_4
    adrp  x8, _DerivedClass_t2882573829_il2cpp_TypeInfo_var@GOTPAGE
    ldr   x8, [x8, _DerivedClass_t2882573829_il2cpp_TypeInfo_var@GOTPAGEOFF]
    ldr   x8, [x8]
    ldr   x9, [x20]
    ldrb  w11, [x9, #292]
    ldrb  w10, [x8, #292]
    cmp   w11, w10
    b.hs  LBB14_5
LBB14_4:
    mov   x0, x19
    b     LBB14_6
LBB14_5:
    ldr   x9, [x9, #200]
    add   x9, x9, x10, lsl #3
    ldur  x9, [x9, #-8]
    cmp   x9, x8
    csel  x0, x20, x19, eq
LBB14_6:
    ldp   x29, x30, [sp, #32]
    ldp   x20, x19, [sp, #16]
    ldp   x22, x21, [sp], #48
    ret
 
 
; NoRedundantTypeCheck
    stp   x22, x21, [sp, #-48]!
    stp   x20, x19, [sp, #16]
    stp   x29, x30, [sp, #32]
    add   x29, sp, #32
    mov   x19, x2
    mov   x20, x1
    adrp  x21, __ZZ41TestClass_NoRedundantTypeCheck_m592734819E25s_Il2CppMethodInitialized@PAGE
    ldrb  w8, [x21, __ZZ41TestClass_NoRedundantTypeCheck_m592734819E25s_Il2CppMethodInitialized@PAGEOFF]
    tbnz  w8, #0, LBB16_2
    adrp  x8, _TestClass_NoRedundantTypeCheck_m592734819_MetadataUsageId@GOTPAGE
    ldr   x8, [x8, _TestClass_NoRedundantTypeCheck_m592734819_MetadataUsageId@GOTPAGEOFF]
    ldr   w0, [x8]
    bl    __ZN6il2cpp2vm13MetadataCache24InitializeMethodMetadataEj
    orr   w8, wzr, #0x1
    strb  w8, [x21, __ZZ41TestClass_NoRedundantTypeCheck_m592734819E25s_Il2CppMethodInitialized@PAGEOFF]
LBB16_2:
    cbz   x20, LBB16_4
    adrp  x8, _DerivedClass_t2882573829_il2cpp_TypeInfo_var@GOTPAGE
    ldr   x8, [x8, _DerivedClass_t2882573829_il2cpp_TypeInfo_var@GOTPAGEOFF]
    ldr   x8, [x8]
    ldr   x9, [x20]
    ldrb  w11, [x9, #292]
    ldrb  w10, [x8, #292]
    cmp   w11, w10
    b.hs  LBB16_5
LBB16_4:
    mov   x8, #0
    b     LBB16_6
LBB16_5:
    ldr   x9, [x9, #200]
    add   x9, x9, x10, lsl #3
    ldur  x9, [x9, #-8]
    cmp   x9, x8
    csel  x8, x20, xzr, eq
LBB16_6:
    cmp   x8, #0
    csel  x0, x19, x8, eq
    ldp   x29, x30, [sp, #32]
    ldp   x20, x19, [sp, #16]
    ldp   x22, x21, [sp], #48
    ret

The only difference is that the NoRedundantTypeCheck version has two additional instructions toward the end:

cmp   x8, #0
csel  x0, x19, x8, eq

Not only has the C++ compiler removed the redundant type check, but it’s actually generated slightly more efficient code for the redundant check.

Conclusion: Redundant type checks don’t generate any additional code and can actually generate even more efficient code than without the redundancy.

Immediately overwriting local variables

Similarly to initializing a field to its default value, sometimes C# programmers will initialize a local variable and then immediately overwrite it. This means the initialization has no effect since the value of the variable isn’t read before it’s overwritten. Here’s an example:

public static class TestClass
{
    public static BaseClass ImmediateOverwrite(
        BaseClass a,
        BaseClass b,
        bool choice)
    {
        BaseClass ret = null;
        if (choice)
        {
            ret = a;
        }
        else
        {
            ret = b;
        }
        return ret;
    }
}

So let’s see if IL2CPP still generates the initialization of ret = null:

extern "C" IL2CPP_METHOD_ATTR BaseClass_t296860279 * TestClass_ImmediateOverwrite_m2257400232 (RuntimeObject * __this /* static, unused */, BaseClass_t296860279 * ___a0, BaseClass_t296860279 * ___b1, bool ___choice2, const RuntimeMethod* method)
{
    BaseClass_t296860279 * V_0 = NULL;
    {
        V_0 = (BaseClass_t296860279 *)NULL;
        bool L_0 = ___choice2;
        if (!L_0)
        {
            goto IL_000f;
        }
    }
    {
        BaseClass_t296860279 * L_1 = ___a0;
        V_0 = L_1;
        goto IL_0011;
    }
 
IL_000f:
    {
        BaseClass_t296860279 * L_2 = ___b1;
        V_0 = L_2;
    }
 
IL_0011:
    {
        BaseClass_t296860279 * L_3 = V_0;
        return L_3;
    }
}

The key line here is V_0 = (BaseClass_t296860279 *)NULL where ret is initialized to NULL. Now let’s check the assembly to see if the C++ compiler strips out the initialization:

cmp   w3, #0
csel  x0, x1, x2, ne
ret

The C++ compiler removed the initialization, remaining only the remaining if which it turned into the assembly version of a ternary operator.

Conclusion: No code is generated for initializing a variable if it isn’t read before being overwritten.

Final thoughts

We’ve seen today that useless C# code can result in a wide range of machine code effects. In cases like calling an empty virtual method, expensive code will still end up executing. In other cases like an inline field initializer that assigns the default value, there is no effect at all. At the other end of the spectrum, redundant type checks can actually result in faster code. It’s important to check both the C++ output of IL2CPP and the machine code that the C++ compiler generates for any performance-critical code. You might be surprised what you find!