IL2CPP Output for C# 7.3: Everything Else
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.
#1 by Stephen Hodgson on January 14th, 2019 ·
Thanks!
Great write up as always :)
#2 by Zach Kamsler on November 27th, 2019 ·
There is one perf advantage to readonly structs in some circumstances. The compiler makes defensive copies when methods are called non-readonly structs in readonly fields, since it doesn’t know if the method changes anything. Readonly structs can avoid this extra copy. The advantage of that will vary with the size of the struct.
#3 by jackson on December 1st, 2019 ·
Good point, though I wonder if these copies would be optimized away by Burst or a C++ compiler for IL2CPP. It’s probably worth writing a quick test to verify. Thanks!