Today we conclude the series by looking at all the remaining features in C# 7.3 that we get access to in Unity 2018.3. Read on to learn about new kinds of structs, in parameters, new where constraints, discards, default literals, generalized async returns, and new preprocessor symbols!

readonly structs

There are two new kinds of structs. First, let’s see a readonly struct:

readonly struct TestReadonlyStruct
{
    public readonly int X;
    public readonly int Y;
}

The compiler requires all fields, X and Y in this case, to be readonly. Here’s how to use it:

static class TestClass
{
    static int TestReadonlyStructParameter(TestReadonlyStruct trs)
    {
        return trs.X + trs.Y;
    }
}

So far this looks exactly like how you’d use a normal struct, so let’s move on to the IL2CPP output from Unity 2018.3.0f2:

extern "C" IL2CPP_METHOD_ATTR int32_t TestClass_TestReadonlyStructParameter_m5D1F08A67A7E86EDE4253E84CE9750DCB6D4588B (TestReadonlyStruct_tBCAA7EF5832671DD85B28E505DC53261B8274721  ___trs0, const RuntimeMethod* method)
{
    {
        TestReadonlyStruct_tBCAA7EF5832671DD85B28E505DC53261B8274721  L_0 = ___trs0;
        int32_t L_1 = L_0.get_X_0();
        TestReadonlyStruct_tBCAA7EF5832671DD85B28E505DC53261B8274721  L_2 = ___trs0;
        int32_t L_3 = L_2.get_Y_1();
        return ((int32_t)il2cpp_codegen_add((int32_t)L_1, (int32_t)L_3));
    }
}

This makes a couple of pointless copies of the parameter (___trs0) to local variables: L_0 and L_2. Otherwise it’s just calling the accessors to get the X and Y fields and then using il2cpp_codegen_add to add them together and return. Let’s see how this is compiled by Xcode 10.1 for a 64-bit iOS release build:

add  r0, r1
bx   lr

All of the copies and function calls have been removed, leaving only a single addition and return.

Conclusion: readonly struct is just syntax sugar to help enforce that all fields are readonly.

ref structs

Next is the other new kind of struct: ref struct. This kind of struct can only exist on the stack, can’t be boxed, can’t implement interfaces, can’t be a field of a class or non-ref struct, can’t be a local variable of an async or iterator function, and can’t be captured by lambdas or local functions. That’s a lot of restrictions, but they do allow for types like Span<T> to exist. Let’s see how one looks:

ref struct TestRefStruct
{
    public int X;
    public int Y;
}

So far it’s just like a normal struct, so let’s look at the usage:

static class TestClass
{
    static int TestRefStructParameter(TestRefStruct trs)
    {
        return trs.X + trs.Y;
    }
}

Again, this is just like a normal struct. Here’s how this looks in the IL2CPP output:

extern "C" IL2CPP_METHOD_ATTR int32_t TestClass_TestRefStructParameter_m79CD70407049A535AE7F3A31A41EC3A43EF9145D (TestRefStruct_t86907733947A6376E02A405573CC2D2225A3B076  ___trs0, const RuntimeMethod* method)
{
    {
        TestRefStruct_t86907733947A6376E02A405573CC2D2225A3B076  L_0 = ___trs0;
        int32_t L_1 = L_0.get_X_0();
        TestRefStruct_t86907733947A6376E02A405573CC2D2225A3B076  L_2 = ___trs0;
        int32_t L_3 = L_2.get_Y_1();
        return ((int32_t)il2cpp_codegen_add((int32_t)L_1, (int32_t)L_3));
    }
}

This looks exactly like we saw with the readonly struct example, so let’s confirm the assembly output:

add  r0, r1
bx   lr

As expected, this is identical to the readonly struct example.

Conclusion: ref struct allows for advanced types like Span<T> by placing many restrictions on it, but the resulting assembly is identical to other struct types.

in parameters

C# has always had three types of parameters: regular value parameters, ref parameters, and out parameters. Regular value parameters are simply copied. ref parameters are a like a pointer to the parameter. out parameters are also like a pointer to the parameter, but the caller doesn’t need to initialize them before calling and the function must set them before returning.

Now there is a new kind of parameter: in parameters. These are also like a pointer to the parameter, but the function can’t set them. These are useful when you want to pass a pointer for efficiency but don’t want the function to be able to modify the parameter as would be the case with ref parameters. They can be thought of as the equivalent of a ref readonly parameter, were there such a thing.

Here’s how this looks in C#:

static class TestClass
{
    static float TestInParameter(in Vector3 vec)
    {
        return vec.x + vec.y + vec.z;
    }
}

And here’s how it looks in C++ in the IL2CPP output:

extern "C" IL2CPP_METHOD_ATTR float TestClass_TestInParameter_mD3A2D342AD817021022E1F6F63EBEF24160D8425 (Vector3_tDCF05E21F632FE2BA260C06E0D10CA81513E6720 * ___vec0, const RuntimeMethod* method)
{
    {
        Vector3_tDCF05E21F632FE2BA260C06E0D10CA81513E6720 * L_0 = ___vec0;
        float L_1 = L_0->get_x_0();
        Vector3_tDCF05E21F632FE2BA260C06E0D10CA81513E6720 * L_2 = ___vec0;
        float L_3 = L_2->get_y_1();
        Vector3_tDCF05E21F632FE2BA260C06E0D10CA81513E6720 * L_4 = ___vec0;
        float L_5 = L_4->get_z_2();
        return ((float)il2cpp_codegen_add((float)((float)il2cpp_codegen_add((float)L_1, (float)L_3)), (float)L_5));
    }
}

Notice that the parameter is a pointer: Vector3_t... * ___vec0.

Here’s what the C++ compiler outputs:

vldr      s0, [r0]
vldr      s2, [r0, #4]
vldr      s4, [r0, #8]
vadd.f32  s0, s0, s2
vadd.f32  s0, s0, s4
vmov      r0, s0
bx        lr

The first three loads (vldr) dereference the pointer (r0) with the second two being offset to get the Y and Z fields. Then there are two adds (vadd.f32) to sum the components before returning. This is a straightforward implementation that we’d expect.

Conclusion: in parameters provide syntax sugar equivalent to a ref readonly parameter.

New where constraints

C# generics have always had very limited where constraints on type parameters, but now they’re a little less limited. It’s now possible to use where T : Enum, where T : Delegate, and where T : unmanaged to require that a type parameter is an enum, delegate, or unmanaged type. The first two are straightforward, but the third is a new concept. To be unmanaged, a type must be a value type and must contain only other value types at any level of nesting. Here are some examples:

class MyClass {} // NOT unmanaged
string // NOT unmanaged
int // unmanaged
enum MyEnum {} // unamanaged
struct MyStructA {} // unmanaged
struct MyStructB { int X; } // unmanaged
struct MyStructC { string X; } // NOT unmanaged
struct MyStructD { MyStructC X; } // NOT unmanaged

Now let’s see this in action:

static class TestClass
{
    static T TestGenericConstraintEnum<T>(T t) where T : Enum
    {
        return t;
    }
 
    static T TestGenericConstraintDelegate<T>(T t) where T : Delegate
    {
        return t;
    }
 
    static T TestGenericConstraintUnmanaged<T>(T t) where T : unmanaged
    {
        return t;
    }
}

Here’s the C++ output:

extern "C" IL2CPP_METHOD_ATTR RuntimeObject * TestClass_TestGenericConstraintEnum_TisRuntimeObject_m8CF12861DF84FF0AC5642FBF79875DE87ED1E3A7_gshared (RuntimeObject * ___t0, const RuntimeMethod* method)
{
    {
        RuntimeObject * L_0 = ___t0;
        return L_0;
    }
}
 
extern "C" IL2CPP_METHOD_ATTR RuntimeObject * TestClass_TestGenericConstraintDelegate_TisRuntimeObject_m202AE05904088B25BDE025DD030B18D5EC08A726_gshared (RuntimeObject * ___t0, const RuntimeMethod* method)
{
    {
        RuntimeObject * L_0 = ___t0;
        return L_0;
    }
}
 
extern "C" IL2CPP_METHOD_ATTR int32_t TestClass_TestGenericConstraintUnmanaged_TisInt32_t585191389E07734F19F3156FF88FB3EF4800D102_m7C43C8B5497FE5110C0D861459E0E5FC04260DE6_gshared (int32_t ___t0, const RuntimeMethod* method)
{
    {
        int32_t L_0 = ___t0;
        return L_0;
    }
}

All three functions simply return their parameter and show no sign of the where constraint. Note that the unmanaged case is a version generated for a call where T is an int.

As expected, the assembly for all three is just a “return” instruction.

# Enum
bx  lr
 
# Delegate
bx  lr
 
# Unmanaged
bx  lr

Conclusion: All where constraints have no impact on the generated C++, so these are only useful on the C# side to narrow down acceptable type parameters.

Discards

Discards allow us to use _ as a local variable name when we want to “discard” it. Unlike normal local variables, there can be as many named _ as we want and none of them can ever be used. These come in handy when we’re required to provide a local variable but don’t really want to use it.

These can be used in several ways:

  • Explicitly: int _ = x;
  • For tuple fields: (_, int y) = t;
  • out parameters: Foo(out _, y)
  • Pattern matching if: if (o is int _)
  • Pattern matching switch: switch (o) { case int _: return 0; }

Let's test out the "explicit" case first:

static class TestClass
{
    static int PrintNextInt(int i)
    {
        i++;
        Debug.Log(i);
        return i;
    }
 
    static int TestDiscardExplicit(int x)
    {
        int _ = PrintNextInt(x);
        return x;
    }
}

Here's how the C++ looks:

extern "C" IL2CPP_METHOD_ATTR int32_t TestClass_PrintNextInt_m06B7CD4BECE1FBC323A68F8908CC80589B2472CB (int32_t ___i0, const RuntimeMethod* method)
{
    static bool s_Il2CppMethodInitialized;
    if (!s_Il2CppMethodInitialized)
    {
        il2cpp_codegen_initialize_method (TestClass_PrintNextInt_m06B7CD4BECE1FBC323A68F8908CC80589B2472CB_MetadataUsageId);
        s_Il2CppMethodInitialized = true;
    }
    {
        int32_t L_0 = ___i0;
        ___i0 = ((int32_t)il2cpp_codegen_add((int32_t)L_0, (int32_t)1));
        int32_t L_1 = ___i0;
        int32_t L_2 = L_1;
        RuntimeObject * L_3 = Box(Int32_t585191389E07734F19F3156FF88FB3EF4800D102_il2cpp_TypeInfo_var, &L_2);
        IL2CPP_RUNTIME_CLASS_INIT(Debug_t7B5FCB117E2FD63B6838BC52821B252E2BFB61C4_il2cpp_TypeInfo_var);
        Debug_Log_m4B7C70BAFD477C6BDB59C88A0934F0B018D03708(L_3, /*hidden argument*/NULL);
        int32_t L_4 = ___i0;
        return L_4;
    }
}
 
extern "C" IL2CPP_METHOD_ATTR int32_t TestClass_TestDiscardExplicit_m73FDF15AFD1F1C7BB968B6B31F61E41CFAD3DE78 (int32_t ___x0, const RuntimeMethod* method)
{
    {
        int32_t L_0 = ___x0;
        TestClass_PrintNextInt_m06B7CD4BECE1FBC323A68F8908CC80589B2472CB(L_0, /*hidden argument*/NULL);
        int32_t L_1 = ___x0;
        return L_1;
    }
}

Even though PrintNextInt returns a value, the C++ function for TestDiscardExplicit simply doesn't save its value. Let's see how this translates into ARM64 assembly:

push  {r4, r7, lr}
add   r7, sp, #4
mov   r4, r0
bl    _TestClass_PrintNextInt_m06B7CD4BECE1FBC323A68F8908CC80589B2472CB
mov   r0, r4
pop   {r4, r7, pc}

The function call is still there, as expected, but there isn't much else.

Now let's look at discaarding a field of a tuple:

static class TestClass
{
    static (int, int) TestCreateTuple()
    {
        return (1, 2);
    }
 
    static int TestDiscardTuple()
    {
        (_, int y) = TestCreateTuple();
        return y;
    }
}
extern "C" IL2CPP_METHOD_ATTR ValueTuple_2_t5A24A9AD1EB9E7A9CDB9C168A09E94B94E849186  TestClass_TestCreateTuple_mB7F9180F25D30B4F025CB7FB81F5FE9A3EECC606 (const RuntimeMethod* method)
{
    static bool s_Il2CppMethodInitialized;
    if (!s_Il2CppMethodInitialized)
    {
        il2cpp_codegen_initialize_method (TestClass_TestCreateTuple_mB7F9180F25D30B4F025CB7FB81F5FE9A3EECC606_MetadataUsageId);
        s_Il2CppMethodInitialized = true;
    }
    {
        ValueTuple_2_t5A24A9AD1EB9E7A9CDB9C168A09E94B94E849186  L_0;
        memset(&L_0, 0, sizeof(L_0));
        ValueTuple_2__ctor_mCDA3078E87F827C6490EBD90430507642CECC6BF((&L_0), 1, 2, /*hidden argument*/ValueTuple_2__ctor_mCDA3078E87F827C6490EBD90430507642CECC6BF_RuntimeMethod_var);
        return L_0;
    }
}
 
extern "C" IL2CPP_METHOD_ATTR int32_t TestClass_TestDiscardTuple_m051A7ADD0C8A84CA095C5EA28A88C17D4F7C6895 (const RuntimeMethod* method)
{
    {
        ValueTuple_2_t5A24A9AD1EB9E7A9CDB9C168A09E94B94E849186  L_0 = TestClass_TestCreateTuple_mB7F9180F25D30B4F025CB7FB81F5FE9A3EECC606(/*hidden argument*/NULL);
        int32_t L_1 = L_0.get_Item2_1();
        return L_1;
    }
}

Here we see the call to get Item2 (a.k.a. Y) of the tuple, but there's no call to get Item1 (X) since it's discarded. Let's see how that affects the assembly:

push  {r7, lr}
mov   r7, sp
sub   sp, #8
mov   r0, sp
bl    _TestClass_TestCreateTuple_mB7F9180F25D30B4F025CB7FB81F5FE9A3EECC606
ldr   r0, [sp, #4]
add   sp, #8
pop   {r7, pc}

Again we see the function call, which isn't inlined due to the method initialization overhead. Then we see the load (ldr) from Y, but no load of X since it was discarded.

Now let's try discarding an out parameter:

static class TestClass
{
    static void OutParamFunction(out int x, out int y)
    {
        x = 10;
        y = 20;
    }
 
    static int TestDiscardOutParam()
    {
        OutParamFunction(out _, out int y);
        return y;
    }
extern "C" IL2CPP_METHOD_ATTR void TestClass_OutParamFunction_mAD30A1FE63B6C79C1101AAA941EF6C2D67BCBC70 (int32_t* ___x0, int32_t* ___y1, const RuntimeMethod* method)
{
    {
        int32_t* L_0 = ___x0;
        *((int32_t*)L_0) = (int32_t)((int32_t)10);
        int32_t* L_1 = ___y1;
        *((int32_t*)L_1) = (int32_t)((int32_t)20);
        return;
    }
}
 
extern "C" IL2CPP_METHOD_ATTR int32_t TestClass_TestDiscardOutParam_mB3531D3E0ACA6452A70402CA0BB29BA9F7F7A97A (const RuntimeMethod* method)
{
    int32_t V_0 = 0;
    int32_t V_1 = 0;
    {
        TestClass_OutParamFunction_mAD30A1FE63B6C79C1101AAA941EF6C2D67BCBC70((int32_t*)(&V_1), (int32_t*)(&V_0), /*hidden argument*/NULL);
        int32_t L_0 = V_0;
        return L_0;
    }
}

The function still needs a parameter, even if it's discarded, so IL2CPP has generated one (V_1) for us but it's never used afterward.

Here's how this looks in assembly:

movs  r0, #20
bx    lr

The function call has been inlined and the unread x parameter write has been removed since it was never read later on. Only setting y to 20 remains.

Next up is the pattern matching if:

static class TestClass
{
    static int TestDiscardPatternMatchingIf(object o)
    {
        if (o is int _)
        {
            return 10;
        }
        return 20;
    }
extern "C" IL2CPP_METHOD_ATTR int32_t TestClass_TestDiscardPatternMatchingIf_mCED9477AE7634DD33C1F6B276A92D9F6A565D06E (RuntimeObject * ___o0, const RuntimeMethod* method)
{
    static bool s_Il2CppMethodInitialized;
    if (!s_Il2CppMethodInitialized)
    {
        il2cpp_codegen_initialize_method (TestClass_TestDiscardPatternMatchingIf_mCED9477AE7634DD33C1F6B276A92D9F6A565D06E_MetadataUsageId);
        s_Il2CppMethodInitialized = true;
    }
    RuntimeObject * V_0 = NULL;
    {
        RuntimeObject * L_0 = ___o0;
        RuntimeObject * L_1 = L_0;
        V_0 = L_1;
        if (!((RuntimeObject *)IsInstSealed((RuntimeObject*)L_1, Int32_t585191389E07734F19F3156FF88FB3EF4800D102_il2cpp_TypeInfo_var)))
        {
            goto IL_0014;
        }
    }
    {
        RuntimeObject * L_2 = V_0;
        return ((int32_t)10);
    }
 
IL_0014:
    {
        return ((int32_t)20);
    }
}

As usual, pattern matching if comes with method initialization overhead and then goes on to call IsInstSealed. The result of it is discarded, so there's no attempt to actually unbox an int from the object.

Here's the assembly for this:

    push    {r4, r5, r7, lr}
    add     r7, sp, #8
    movw    r5, :lower16:(__ZZ80TestClass_TestDiscardPatternMatchingIf_mCED9477AE7634DD33C1F6B276A92D9F6A565D06EE25s_Il2CppMethodInitialized-(LPC40_0+4))
    mov     r4, r0
    movt    r5, :upper16:(__ZZ80TestClass_TestDiscardPatternMatchingIf_mCED9477AE7634DD33C1F6B276A92D9F6A565D06EE25s_Il2CppMethodInitialized-(LPC40_0+4))
LPC40_0:
    add     r5, pc
    ldrb    r0, [r5]
    cbnz    r0, LBB40_2
    movw    r0, :lower16:(L_TestClass_TestDiscardPatternMatchingIf_mCED9477AE7634DD33C1F6B276A92D9F6A565D06E_MetadataUsageId$non_lazy_ptr-(LPC40_1+4))
    movt    r0, :upper16:(L_TestClass_TestDiscardPatternMatchingIf_mCED9477AE7634DD33C1F6B276A92D9F6A565D06E_MetadataUsageId$non_lazy_ptr-(LPC40_1+4))
LPC40_1:
    add     r0, pc
    ldr     r0, [r0]
    ldr     r0, [r0]
    bl      __ZN6il2cpp2vm13MetadataCache24InitializeMethodMetadataEj
    movs    r0, #1
    strb    r0, [r5]
LBB40_2:
    movs    r0, #20
    cbz     r4, LBB40_4
    movw    r1, :lower16:(L_Int32_t585191389E07734F19F3156FF88FB3EF4800D102_il2cpp_TypeInfo_var$non_lazy_ptr-(LPC40_2+4))
    movt    r1, :upper16:(L_Int32_t585191389E07734F19F3156FF88FB3EF4800D102_il2cpp_TypeInfo_var$non_lazy_ptr-(LPC40_2+4))
    ldr     r2, [r4]
LPC40_2:
    add     r1, pc
    ldr     r1, [r1]
    ldr     r1, [r1]
    cmp     r2, r1
    it      eq
    moveq   r0, #10
LBB40_4:
    pop     {r4, r5, r7, pc}

The beginning part of the function is the method initialization overhead, the middle is the IsInstSealed type check, and the end is the returning of either 10 or 20. This is consistent with what we've seen previously.

Finally, let's look at pattern matching switch:

static class TestClass
{
    static int TestDiscardPatternMatchingSwitch(object o)
    {
        switch (o)
        {
            case int _:
                return 10;
            default:
                return 20;
        }
    }
}

Here's the IL2CPP output:

extern "C" IL2CPP_METHOD_ATTR int32_t TestClass_TestDiscardPatternMatchingSwitch_mF59A43B0EDF27979109D2C2FE4D105C10678305B (RuntimeObject * ___o0, const RuntimeMethod* method)
{
    static bool s_Il2CppMethodInitialized;
    if (!s_Il2CppMethodInitialized)
    {
        il2cpp_codegen_initialize_method (TestClass_TestDiscardPatternMatchingSwitch_mF59A43B0EDF27979109D2C2FE4D105C10678305B_MetadataUsageId);
        s_Il2CppMethodInitialized = true;
    }
    RuntimeObject * V_0 = NULL;
    RuntimeObject * V_1 = NULL;
    {
        RuntimeObject * L_0 = ___o0;
        V_0 = L_0;
        RuntimeObject * L_1 = V_0;
        if (!L_1)
        {
            goto IL_0019;
        }
    }
    {
        RuntimeObject * L_2 = V_0;
        RuntimeObject * L_3 = L_2;
        V_1 = L_3;
        if (!((RuntimeObject *)IsInstSealed((RuntimeObject*)L_3, Int32_t585191389E07734F19F3156FF88FB3EF4800D102_il2cpp_TypeInfo_var)))
        {
            goto IL_0019;
        }
    }
    {
        RuntimeObject * L_4 = V_1;
        return ((int32_t)10);
    }
 
IL_0019:
    {
        return ((int32_t)20);
    }
}

This looks nearly the same except that it includes a null check before the IsInstSealed type check. Let's see how that affects the assembly output:

    push    {r4, r5, r7, lr}
    add     r7, sp, #8
    movw    r5, :lower16:(__ZZ84TestClass_TestDiscardPatternMatchingSwitch_mF59A43B0EDF27979109D2C2FE4D105C10678305BE25s_Il2CppMethodInitialized-(LPC41_0+4))
    mov     r4, r0
    movt    r5, :upper16:(__ZZ84TestClass_TestDiscardPatternMatchingSwitch_mF59A43B0EDF27979109D2C2FE4D105C10678305BE25s_Il2CppMethodInitialized-(LPC41_0+4))
LPC41_0:
    add     r5, pc
    ldrb    r0, [r5]
    cbnz    r0, LBB41_2
    movw    r0, :lower16:(L_TestClass_TestDiscardPatternMatchingSwitch_mF59A43B0EDF27979109D2C2FE4D105C10678305B_MetadataUsageId$non_lazy_ptr-(LPC41_1+4))
    movt    r0, :upper16:(L_TestClass_TestDiscardPatternMatchingSwitch_mF59A43B0EDF27979109D2C2FE4D105C10678305B_MetadataUsageId$non_lazy_ptr-(LPC41_1+4))
LPC41_1:
    add     r0, pc
    ldr     r0, [r0]
    ldr     r0, [r0]
    bl      __ZN6il2cpp2vm13MetadataCache24InitializeMethodMetadataEj
    movs    r0, #1
    strb    r0, [r5]
LBB41_2:
    cbz     r4, LBB41_4
    movw    r0, :lower16:(L_Int32_t585191389E07734F19F3156FF88FB3EF4800D102_il2cpp_TypeInfo_var$non_lazy_ptr-(LPC41_2+4))
    movt    r0, :upper16:(L_Int32_t585191389E07734F19F3156FF88FB3EF4800D102_il2cpp_TypeInfo_var$non_lazy_ptr-(LPC41_2+4))
    ldr     r1, [r4]
LPC41_2:
    add     r0, pc
    ldr     r0, [r0]
    ldr     r0, [r0]
    cmp     r1, r0
    beq     LBB41_5
LBB41_4:
    movs    r0, #20
    pop     {r4, r5, r7, pc}
LBB41_5:
    movs    r0, #10
    pop     {r4, r5, r7, pc}

As expected, this is the same except that the null check has rearranged the instructions a little.

Conclusion: Discarding local variables in C# results in the expected C++ where expression results aren't used, tuple fields aren't read, and dummy parameters are passed to functions.

default literals

There's no longer a need to specify default(MyType) when the compiler can infer what MyType is. This is similar to using var to automatically give variables their type. Instead, just use default with no parentheses and no type name.

static class TestClass
{
    static Vector3 TestDefaultLiteral()
    {
        Vector3 ret = default;
        return ret;
    }
}

Previously, we would have written Vector3 ret = default(Vector3); which is somewhat more verbose. However, we also could have previously written var ret = default(Vector3);, so this is only a minor improvement. Still, there are other situations where default literals can save some typing. For example, this function could be just return default;. Likewise, we can call functions like this now: Foo(default, default, default). This is legal because the compiler knows the parameter and return types and can fill in the missing (SomeType) after default.

Let's see how this looks in C++:

extern "C" IL2CPP_METHOD_ATTR Vector3_tDCF05E21F632FE2BA260C06E0D10CA81513E6720  TestClass_TestDefaultLiteral_mFC1DC3D002645A7462A92A3D4F91CA424F912E36 (const RuntimeMethod* method)
{
    Vector3_tDCF05E21F632FE2BA260C06E0D10CA81513E6720  V_0;
    memset(&V_0, 0, sizeof(V_0));
    {
        il2cpp_codegen_initobj((&V_0), sizeof(Vector3_tDCF05E21F632FE2BA260C06E0D10CA81513E6720 ));
        Vector3_tDCF05E21F632FE2BA260C06E0D10CA81513E6720  L_0 = V_0;
        return L_0;
    }
}

This declares a Vector3, uses memset to clear it to all zeroes, initializes it with il2cpp_codegen_initobj, makes a pointless copy, and returns the copy. Here's how the assembly looks:

movs  r1, #0
str   r1, [r0, #4]
str   r1, [r0]
str   r1, [r0, #8]
bx    lr

This really just clears the three float fields of the Vector3 to zero and returns, which is all we'd expect to happen after inlining and trimming down the memset and il2cpp_codegen_initobj calls.

Conclusion: default literals are just syntax sugar to save a little typing.

Generalized async returns

C# 7.0 supports arbitrary return values from async functions as long as they have a GetAwaiter method. For example:

public struct TestOutcome
{
    public string Message;
}
 
public struct TestAwaiter : INotifyCompletion
{
    public int Value;
 
    public void OnCompleted(Action continuation)
    {
        continuation();
    }
 
    public bool IsCompleted
    {
        get
        {
            return false;
        }
    }
 
    public TestOutcome GetResult()
    {
        return new TestOutcome { Message = "You gave me: " + Value };
    }
}
 
public struct TestTask
{
    public int Value;
 
    public TestAwaiter GetAwaiter()
    {
        return new TestAwaiter { Value = Value };
    }
}
 
static class TestClass
{
    static async TestTask TestGeneralizedAsyncReturnType()
    {
        TestTask t = new TestTask { Value = 5 };
        return t;
    }
 
    static async void TestCallGeneralizedAsyncReturnType()
    {
        await TestGeneralizedAsyncReturnType();
    }
}

Unfortunately, TestGeneralizedAsyncReturnType gives a compiler error:

error CS1983: The return type of an async method must be void, Task or Task<T>

The ValueTask<T> type from the System.Threading.Tasks.Extensions NuGet package makes use of this feature, but that package isn't available in Unity. So for now, this feature is unavailable.

Conclusion: Generalized async return types via GetAwaiter currently aren't supported

New preprocessor symbols

Finally for today, there are new preprocessor symbols available in Unity 2018.3. These aren't part of the language, but do give us hints as to what language is available. This helps write code that supports multiple scripting backends and versions of Unity. Here they are:

#if CSHARP_7_OR_NEWER
    // C# 7.0 and all previous versions are available
    // C# 7.1, 7.2, and 7.3 and all newer versions may not be available
    // This is true in Unity 2018.3 and newer
    // This is false in Unity 2018.2 and older
#endif
 
#if CSHARP_7_3_OR_NEWER
    // C# 7.3 and all previous versions are available
    // This is true in Unity 2018.3 and newer
    // This is false in Unity 2018.2 and older
#endif

Using these symbols just to get access to some new syntax sugar would defeat the purpose, but in the case where genuinely useful new features are available it may make sense. For example, types used in native collections like NativeArray<T> must be blittable and this nicely lines up with the definition of the new where T : unmanaged constraint. So we can support the new where constraint in Unity 2018.3 while maintaining backwards compatibility with older versions of Unity using the CSHARP_7_3_OR_NEWER preprocessor symbol:

public unsafe struct MyNativeCollection<T>
    : IEnumerable<T>
    , IEnumerable
    , IDisposable
#if CSHARP_7_3_OR_NEWER
    where T : unmanaged
#else
    where T : struct
#endif
{
    public MyNativeCollection(Allocator allocator)
    {
#if CSHARP_7_3_OR_NEWER
        // Already `unmanaged`: no runtime check required!
#else
        if (!UnsafeUtility.IsBlittable<T>())
        {
            throw new ArgumentException(
                string.Format(
                    "{0} used in MyNativeCollection<{0}> must be blittable",
                    typeof(T)));
        }
#endif
    }
}
Conclusion

Today we've seen some syntax sugar in the forms of readonly struct, discards, in parameters, and default literals. These provide some nice-to-have syntax that should generally make C# a little cleaner. But we've also seen genuinely useful new features such as new where constraints that cut down on runtime checks and errors in native collection types, ref structs that enable types like Span<T>, generalized async return types (presently not working) that reduce GC pressure with types like ValueTask<T>, and preprocessor symbols that allow us to support old and new versions of Unity in the same source files.

Overall, C# 7.0, 7.1, 7.2, and 7.3 represent a big jump forward from the C# 6 we had just one Unity version ago. We're finally up to speed with the rest of the .NET world and we have access to all the latest features that we can use to make our code and the games it powers better.