Today’s article looks at the IL2CPP and C++ compiler output for a variety of C# language features. Do you want to know what happens when you use them? Read on to find out!

readonly

The readonly keyword disallows assigning a field after it’s initially assigned either inline or in a constructor. Does this yield any optimization in the C++ code that IL2CPP outputs? Let’s write a tiny example to see:

public class ClassWithReadonlyInstanceField
{
	public readonly int Field;
}
 
public class ClassWithReadonlyStaticField
{
	public static readonly int Field;
}
 
public static class TestClass
{
	public static int UseReadonlyInstanceField(ClassWithReadonlyInstanceField x)
	{
		return x.Field;
	}
 
	public static int UseReadonlyStaticField()
	{
		return ClassWithReadonlyStaticField.Field;
	}
}

Now let’s build for iOS with Unity 2017.4.1f1 to see the C++ code that gets generated:

struct  ClassWithReadonlyInstanceField_t4047643351  : public RuntimeObject
{
public:
	// System.Int32 ClassWithReadonlyInstanceField::Field
	int32_t ___Field_0;
 
public:
	inline static int32_t get_offset_of_Field_0() { return static_cast<int32_t>(offsetof(ClassWithReadonlyInstanceField_t4047643351, ___Field_0)); }
	inline int32_t get_Field_0() const { return ___Field_0; }
	inline int32_t* get_address_of_Field_0() { return &___Field_0; }
	inline void set_Field_0(int32_t value)
	{
		___Field_0 = value;
	}
};
 
struct ClassWithReadonlyStaticField_t2110888214_StaticFields
{
public:
	// System.Int32 ClassWithReadonlyStaticField::Field
	int32_t ___Field_0;
 
public:
	inline static int32_t get_offset_of_Field_0() { return static_cast<int32_t>(offsetof(ClassWithReadonlyStaticField_t2110888214_StaticFields, ___Field_0)); }
	inline int32_t get_Field_0() const { return ___Field_0; }
	inline int32_t* get_address_of_Field_0() { return &___Field_0; }
	inline void set_Field_0(int32_t value)
	{
		___Field_0 = value;
	}
};
 
extern "C"  int32_t TestClass_UseReadonlyInstanceField_m435387658 (RuntimeObject * __this /* static, unused */, ClassWithReadonlyInstanceField_t4047643351 * ___x0, const RuntimeMethod* method)
{
	{
		ClassWithReadonlyInstanceField_t4047643351 * L_0 = ___x0;
		NullCheck(L_0);
		int32_t L_1 = L_0->get_Field_0();
		return L_1;
	}
}
 
extern "C"  int32_t TestClass_UseReadonlyStaticField_m3367044601 (RuntimeObject * __this /* static, unused */, const RuntimeMethod* method)
{
	static bool s_Il2CppMethodInitialized;
	if (!s_Il2CppMethodInitialized)
	{
		il2cpp_codegen_initialize_method (TestClass_UseReadonlyStaticField_m3367044601_MetadataUsageId);
		s_Il2CppMethodInitialized = true;
	}
	{
		int32_t L_0 = ((ClassWithReadonlyStaticField_t2110888214_StaticFields*)il2cpp_codegen_static_fields_for(ClassWithReadonlyStaticField_t2110888214_il2cpp_TypeInfo_var))->get_Field_0();
		return L_0;
	}
}

Both generated classes have no trace of the readonly keyword. C++ has a more-or-less equivalent const keyword, but that’s not present in the C++ that IL2CPP generated. As for usage, there’s zero difference regardless of whether the field is static or not.

Conclusion: the readonly keyword has no impact on generated code.

sizeof

Let’s use sizeof to get the size (in bytes) of various kinds of C# types:

public struct MyStruct
{
}
 
public enum MyEnum
{
}
 
public static class TestClass
{
	public static int SizofPrimitive()
	{
		return sizeof(int);
	}
 
	public static unsafe int SizofStruct()
	{
		return sizeof(MyStruct);
	}
 
	public static int SizofEnum()
	{
		return sizeof(MyEnum);
	}
 
	public static unsafe int SizofPointer()
	{
		return sizeof(int*);
	}
 
	public static unsafe int SizofIntPtr()
	{
		return sizeof(IntPtr);
	}
 
	public static unsafe int SizofUIntPtr()
	{
		return sizeof(UIntPtr);
	}
}

Now let’s look at the C++ output:

extern "C"  int32_t TestClass_SizofPrimitive_m3143674074 (RuntimeObject * __this /* static, unused */, const RuntimeMethod* method)
{
	{
		return 4;
	}
}
 
extern "C"  int32_t TestClass_SizofStruct_m1791752848 (RuntimeObject * __this /* static, unused */, const RuntimeMethod* method)
{
	static bool s_Il2CppMethodInitialized;
	if (!s_Il2CppMethodInitialized)
	{
		il2cpp_codegen_initialize_method (TestClass_SizofStruct_m1791752848_MetadataUsageId);
		s_Il2CppMethodInitialized = true;
	}
	{
		uint32_t L_0 = il2cpp_codegen_sizeof(MyStruct_t123831593_il2cpp_TypeInfo_var);
		return L_0;
	}
}
 
extern "C"  int32_t TestClass_SizofEnum_m467648496 (RuntimeObject * __this /* static, unused */, const RuntimeMethod* method)
{
	{
		return 4;
	}
}
 
extern "C"  int32_t TestClass_SizofPointer_m2755818356 (RuntimeObject * __this /* static, unused */, const RuntimeMethod* method)
{
	static bool s_Il2CppMethodInitialized;
	if (!s_Il2CppMethodInitialized)
	{
		il2cpp_codegen_initialize_method (TestClass_SizofPointer_m2755818356_MetadataUsageId);
		s_Il2CppMethodInitialized = true;
	}
	{
		uint32_t L_0 = il2cpp_codegen_sizeof(Int32U2A_t2910199323_il2cpp_TypeInfo_var);
		return L_0;
	}
}
 
extern "C"  int32_t TestClass_SizofIntPtr_m459331448 (RuntimeObject * __this /* static, unused */, const RuntimeMethod* method)
{
	static bool s_Il2CppMethodInitialized;
	if (!s_Il2CppMethodInitialized)
	{
		il2cpp_codegen_initialize_method (TestClass_SizofIntPtr_m459331448_MetadataUsageId);
		s_Il2CppMethodInitialized = true;
	}
	{
		uint32_t L_0 = il2cpp_codegen_sizeof(IntPtr_t_il2cpp_TypeInfo_var);
		return L_0;
	}
}
 
extern "C"  int32_t TestClass_SizofUIntPtr_m1437711209 (RuntimeObject * __this /* static, unused */, const RuntimeMethod* method)
{
	static bool s_Il2CppMethodInitialized;
	if (!s_Il2CppMethodInitialized)
	{
		il2cpp_codegen_initialize_method (TestClass_SizofUIntPtr_m1437711209_MetadataUsageId);
		s_Il2CppMethodInitialized = true;
	}
	{
		uint32_t L_0 = il2cpp_codegen_sizeof(UIntPtr_t_il2cpp_TypeInfo_var);
		return L_0;
	}
}

In the case of primitives like int and enums like MyEnum, the sizeof(x) expression is simply replaced with a constant like 4. This is great since zero work needs to be done at runtime to determine this size.

On the other hand, taking the sizeof structs, pointers, IntPtr, and UIntPtr generates method initialization code plus a call to il2cpp_codegen_sizeof which looks like this:

inline uint32_t il2cpp_codegen_sizeof(RuntimeClass* klass)
{
	if (!klass->valuetype)
	{
		return sizeof(void*);
	}
 
	return il2cpp::vm::Class::GetInstanceSize(klass) - sizeof(RuntimeObject);
}

Since these are all value types, the if won’t pass and il2cpp::vm::Class::GetInstanceSize will be called. Here’s what it looks like:

int32_t Class::GetInstanceSize(const Il2CppClass *klass)
{
	IL2CPP_ASSERT(klass->size_inited);
	return klass->instance_size;
}

Given how simple these functions are, we might suspect that the C++ compiler will inline them. To find out, let’s look at the ARM assembly this compiles to:

	push	{r4, r7, lr}
	add	r7, sp, #4
	movw	r4, :lower16:(__ZZ34TestClass_SizofUIntPtr_m1437711209E25s_Il2CppMethodInitialized-(LPC10_0+4))
	movt	r4, :upper16:(__ZZ34TestClass_SizofUIntPtr_m1437711209E25s_Il2CppMethodInitialized-(LPC10_0+4))
LPC10_0:
	add	r4, pc
	ldrb	r0, [r4]
	cbnz	r0, LBB10_2
	movw	r0, :lower16:(L_TestClass_SizofUIntPtr_m1437711209_MetadataUsageId$non_lazy_ptr-(LPC10_1+4))
	movt	r0, :upper16:(L_TestClass_SizofUIntPtr_m1437711209_MetadataUsageId$non_lazy_ptr-(LPC10_1+4))
LPC10_1:
	add	r0, pc
	ldr	r0, [r0]
	ldr	r0, [r0]
	bl	__ZN6il2cpp2vm13MetadataCache24InitializeMethodMetadataEj
	movs	r0, #1
	strb	r0, [r4]
LBB10_2:
	movw	r0, :lower16:(L_UIntPtr_t_il2cpp_TypeInfo_var$non_lazy_ptr-(LPC10_2+4))
	movt	r0, :upper16:(L_UIntPtr_t_il2cpp_TypeInfo_var$non_lazy_ptr-(LPC10_2+4))
LPC10_2:
	add	r0, pc
	ldr	r0, [r0]
	ldr	r0, [r0]
	ldrb.w	r1, [r0, #177]
	lsls	r1, r1, #31
	bne	LBB10_4
	movs	r0, #4
	pop	{r4, r7, pc}
LBB10_4:
	bl	__ZN6il2cpp2vm5Class15GetInstanceSizeEPK11Il2CppClass
	subs	r0, #8
	pop	{r4, r7, pc}

The whole first part is the method initialization code. At the end we see the call to il2cpp::vm::Class::GetInstanceSize. This means that just the call to il2cpp_codegen_sizeof was inlined and we still have function call overhead for the call to il2cpp::vm::Class::GetInstanceSize.

Conclusion: sizeof is free for primitives and enums, but has method initialization and function call overhead for other types.

IntPtr and UIntPtr

IntPtr and UIntPtr are commonly used for interfacing with native plugins (e.g. UnityNativeScripting) to represent pointers without the need for so-called “unsafe” code. Let’s try a few ways of creating a “null” version of them:

public static class TestClass
{
	public static IntPtr IntPtrZeroField()
	{
		return IntPtr.Zero;
	}
 
	public static IntPtr IntPtrDefaultCtor()
	{
		return new IntPtr();
	}
 
	public static IntPtr IntPtrDefaultKeyword()
	{
		return default(IntPtr);
	}
 
	public static IntPtr IntPtrZeroCtor()
	{
		return new IntPtr(0);
	}
 
	public static UIntPtr UIntPtrZeroField()
	{
		return UIntPtr.Zero;
	}
 
	public static UIntPtr UIntPtrDefaultCtor()
	{
		return new UIntPtr();
	}
 
	public static UIntPtr UIntPtrDefaultKeyword()
	{
		return default(UIntPtr);
	}
 
	public static UIntPtr UIntPtrZeroCtor()
	{
		return new UIntPtr(0);
	}
}

Here’s the C++ that’s output:

extern "C"  intptr_t TestClass_IntPtrZeroField_m4289709144 (RuntimeObject * __this /* static, unused */, const RuntimeMethod* method)
{
	static bool s_Il2CppMethodInitialized;
	if (!s_Il2CppMethodInitialized)
	{
		il2cpp_codegen_initialize_method (TestClass_IntPtrZeroField_m4289709144_MetadataUsageId);
		s_Il2CppMethodInitialized = true;
	}
	{
		return (intptr_t)(0);
	}
}
 
extern "C"  intptr_t TestClass_IntPtrDefaultCtor_m2172108580 (RuntimeObject * __this /* static, unused */, const RuntimeMethod* method)
{
	intptr_t V_0;
	memset(&V_0, 0, sizeof(V_0));
	{
		il2cpp_codegen_initobj((&V_0), sizeof(intptr_t));
		intptr_t L_0 = V_0;
		return L_0;
	}
}
 
extern "C"  intptr_t TestClass_IntPtrDefaultKeyword_m3009645941 (RuntimeObject * __this /* static, unused */, const RuntimeMethod* method)
{
	intptr_t V_0;
	memset(&V_0, 0, sizeof(V_0));
	{
		il2cpp_codegen_initobj((&V_0), sizeof(intptr_t));
		intptr_t L_0 = V_0;
		return L_0;
	}
}
 
extern "C"  intptr_t TestClass_IntPtrZeroCtor_m3237535456 (RuntimeObject * __this /* static, unused */, const RuntimeMethod* method)
{
	{
		intptr_t L_0;
		memset(&L_0, 0, sizeof(L_0));
		IntPtr__ctor_m987082960((&L_0), 0, /*hidden argument*/NULL);
		return L_0;
	}
}
 
extern "C"  uintptr_t TestClass_UIntPtrZeroField_m2946046438 (RuntimeObject * __this /* static, unused */, const RuntimeMethod* method)
{
	static bool s_Il2CppMethodInitialized;
	if (!s_Il2CppMethodInitialized)
	{
		il2cpp_codegen_initialize_method (TestClass_UIntPtrZeroField_m2946046438_MetadataUsageId);
		s_Il2CppMethodInitialized = true;
	}
	{
		IL2CPP_RUNTIME_CLASS_INIT(UIntPtr_t_il2cpp_TypeInfo_var);
		return (0);
	}
}
 
extern "C"  uintptr_t TestClass_UIntPtrDefaultCtor_m694697819 (RuntimeObject * __this /* static, unused */, const RuntimeMethod* method)
{
	uintptr_t V_0;
	memset(&V_0, 0, sizeof(V_0));
	{
		il2cpp_codegen_initobj((&V_0), sizeof(uintptr_t));
		uintptr_t L_0 = V_0;
		return L_0;
	}
}
 
extern "C"  uintptr_t TestClass_UIntPtrDefaultKeyword_m1569370405 (RuntimeObject * __this /* static, unused */, const RuntimeMethod* method)
{
	uintptr_t V_0;
	memset(&V_0, 0, sizeof(V_0));
	{
		il2cpp_codegen_initobj((&V_0), sizeof(uintptr_t));
		uintptr_t L_0 = V_0;
		return L_0;
	}
}
 
extern "C"  uintptr_t TestClass_UIntPtrZeroCtor_m4231348176 (RuntimeObject * __this /* static, unused */, const RuntimeMethod* method)
{
	{
		uintptr_t L_0;
		memset(&L_0, 0, sizeof(L_0));
		UIntPtr__ctor_m4250165422((&L_0), 0, /*hidden argument*/NULL);
		return L_0;
	}
}

Regardless of whether Using the default constructor or default keyword results in the same C++ code. Let’s go one step further and look at the generated assembly:

// _TestClass_UIntPtrDefaultCtor_m694697819
	movs	r0, #0
	bx	lr
 
// _TestClass_UIntPtrDefaultKeyword_m1569370405
	movs	r0, #0
	bx	lr

Here we see that the memset and everything else has been removed and replaced with the #0 constant.

The version where we’re passing 0 to the constructor adds on a call to the constructor. Will that get removed by the compiler, too? Let’s find out by looking at the compiled assembly:

// _TestClass_UIntPtrZeroCtor_m4231348176
	push	{r7, lr}
	mov	r7, sp
	sub	sp, #4
	movs	r0, #0
	movs	r1, #0
	str	r0, [sp]
	mov	r0, sp
	movs	r2, #0
	bl	_UIntPtr__ctor_m4250165422
	ldr	r0, [sp], #4
	pop	{r7, pc}

Unfortunately, this version results in a full call to the constructor function.

When it comes to using the Zero field of both types, we get method initialization overhead because these are static readonly fields. Interestingly, the version for IntPtr varies slightly from the UIntPtr version. Everything’s the same, except that there’s an additional call to IL2CPP_RUNTIME_CLASS_INIT(UIntPtr_t_il2cpp_TypeInfo_var); with UIntPtr. Here’s what it looks like:

#define IL2CPP_RUNTIME_CLASS_INIT(klass) do { if((klass)->has_cctor && !(klass)->cctor_finished) il2cpp::vm::Runtime::ClassInit ((klass)); } while (0)

il2cpp::vm::Runtime::ClassInit is a large function involving many thread atomic operations:

void Runtime::ClassInit(Il2CppClass *klass)
{
	// Nothing to do if class has no static constructor.
	if (!klass->has_cctor)
		return;
 
	// Nothing to do if class constructor already ran.
	if (os::Atomic::CompareExchange(&klass->cctor_finished, 1, 1) == 1)
		return;
 
	s_TypeInitializationLock.Lock();
 
	// See if some thread ran it while we acquired the lock.
	if (os::Atomic::CompareExchange(&klass->cctor_finished, 1, 1) == 1)
	{
		s_TypeInitializationLock.Unlock();
		return;
	}
 
	// See if some other thread got there first and already started running the constructor.
	if (os::Atomic::CompareExchange(&klass->cctor_started, 1, 1) == 1)
	{
		s_TypeInitializationLock.Unlock();
 
		// May have been us and we got here through recursion.
		os::Thread::ThreadId currentThread = os::Thread::CurrentThreadId();
		if (os::Atomic::CompareExchange64(&klass->cctor_thread, currentThread, currentThread) == currentThread)
			return;
 
		// Wait for other thread to finish executing the constructor.
		while (os::Atomic::CompareExchange(&klass->cctor_finished, 1, 1) == 0)
		{
			os::Thread::Sleep(1);
		}
	}
	else
	{
		// Let others know we have started executing the constructor.
		os::Atomic::Exchange64(&klass->cctor_thread, os::Thread::CurrentThreadId());
		os::Atomic::Exchange(&klass->cctor_started, 1);
 
		s_TypeInitializationLock.Unlock();
 
		// Run it.
		Il2CppException* exception = NULL;
		const MethodInfo* cctor = Class::GetCCtor(klass);
		if (cctor != NULL)
		{
			vm::Runtime::Invoke(cctor, NULL, NULL, &exception);
		}
 
		// Let other threads know we finished.
		os::Atomic::Exchange(&klass->cctor_finished, 1);
		os::Atomic::Exchange64(&klass->cctor_thread, 0);
 
		// Deal with exceptions.
		if (exception != NULL)
		{
			const Il2CppType *type = Class::GetType(klass);
			std::string n = StringUtils::Printf("The type initializer for '%s' threw an exception.", Type::GetName(type, IL2CPP_TYPE_NAME_FORMAT_IL).c_str());
			Il2CppException* typeInitializationException = Exception::GetTypeInitializationException(n.c_str(), exception);
			Exception::Raise(typeInitializationException);
		}
	}
}

Unfortunately, the if (!klass->has_cctor) check that skips all the expensive work won’t pass because there is a static constructor to set the Zero field. The result is assembly code that has both method initialization overhead and a call to the expensive il2cpp::vm::Runtime::ClassInit function:

	push	{r4, r7, lr}
	add	r7, sp, #4
	movw	r4, :lower16:(__ZZ38TestClass_UIntPtrZeroField_m2946046438E25s_Il2CppMethodInitialized-(LPC15_0+4))
	movt	r4, :upper16:(__ZZ38TestClass_UIntPtrZeroField_m2946046438E25s_Il2CppMethodInitialized-(LPC15_0+4))
LPC15_0:
	add	r4, pc
	ldrb	r0, [r4]
	cbnz	r0, LBB15_2
	movw	r0, :lower16:(L_TestClass_UIntPtrZeroField_m2946046438_MetadataUsageId$non_lazy_ptr-(LPC15_1+4))
	movt	r0, :upper16:(L_TestClass_UIntPtrZeroField_m2946046438_MetadataUsageId$non_lazy_ptr-(LPC15_1+4))
LPC15_1:
	add	r0, pc
	ldr	r0, [r0]
	ldr	r0, [r0]
	bl	__ZN6il2cpp2vm13MetadataCache24InitializeMethodMetadataEj
	movs	r0, #1
	strb	r0, [r4]
LBB15_2:
	movw	r0, :lower16:(L_UIntPtr_t_il2cpp_TypeInfo_var$non_lazy_ptr-(LPC15_2+4))
	movt	r0, :upper16:(L_UIntPtr_t_il2cpp_TypeInfo_var$non_lazy_ptr-(LPC15_2+4))
LPC15_2:
	add	r0, pc
	ldr	r0, [r0]
	ldr	r0, [r0]
	ldrb.w	r1, [r0, #178]
	lsls	r1, r1, #31
	beq	LBB15_5
	ldr	r1, [r0, #96]
	cbnz	r1, LBB15_5
	bl	__ZN6il2cpp2vm7Runtime9ClassInitEP11Il2CppClass
LBB15_5:
	movs	r0, #0
	pop	{r4, r7, pc}

Conclusion: use the default constructor or default keyword. Don’t pass 0 to the constructor or use the Zero field.

typeof

Let’s try using typeof to get a Type for various types:

public class MyClass
{
}
 
public static class TestClass
{
	public static Type TypeofPrimitive()
	{
		return typeof(int);
	}
 
	public static Type TypeofStruct()
	{
		return typeof(MyStruct);
	}
 
	public static Type TypeofEnum()
	{
		return typeof(MyEnum);
	}
 
	public static Type TypeofClass()
	{
		return typeof(MyClass);
	}
}

Here’s the C++ that is generated:

extern "C"  Type_t * TestClass_TypeofPrimitive_m3064036489 (RuntimeObject * __this /* static, unused */, const RuntimeMethod* method)
{
	static bool s_Il2CppMethodInitialized;
	if (!s_Il2CppMethodInitialized)
	{
		il2cpp_codegen_initialize_method (TestClass_TypeofPrimitive_m3064036489_MetadataUsageId);
		s_Il2CppMethodInitialized = true;
	}
	{
		RuntimeTypeHandle_t3027515415  L_0 = { reinterpret_cast<intptr_t> (Int32_t2950945753_0_0_0_var) };
		IL2CPP_RUNTIME_CLASS_INIT(Type_t_il2cpp_TypeInfo_var);
		Type_t * L_1 = Type_GetTypeFromHandle_m1620074514(NULL /*static, unused*/, L_0, /*hidden argument*/NULL);
		return L_1;
	}
}
 
extern "C"  Type_t * TestClass_TypeofStruct_m2510147412 (RuntimeObject * __this /* static, unused */, const RuntimeMethod* method)
{
	static bool s_Il2CppMethodInitialized;
	if (!s_Il2CppMethodInitialized)
	{
		il2cpp_codegen_initialize_method (TestClass_TypeofStruct_m2510147412_MetadataUsageId);
		s_Il2CppMethodInitialized = true;
	}
	{
		RuntimeTypeHandle_t3027515415  L_0 = { reinterpret_cast<intptr_t> (MyStruct_t123831593_0_0_0_var) };
		IL2CPP_RUNTIME_CLASS_INIT(Type_t_il2cpp_TypeInfo_var);
		Type_t * L_1 = Type_GetTypeFromHandle_m1620074514(NULL /*static, unused*/, L_0, /*hidden argument*/NULL);
		return L_1;
	}
}
 
extern "C"  Type_t * TestClass_TypeofEnum_m372310367 (RuntimeObject * __this /* static, unused */, const RuntimeMethod* method)
{
	static bool s_Il2CppMethodInitialized;
	if (!s_Il2CppMethodInitialized)
	{
		il2cpp_codegen_initialize_method (TestClass_TypeofEnum_m372310367_MetadataUsageId);
		s_Il2CppMethodInitialized = true;
	}
	{
		RuntimeTypeHandle_t3027515415  L_0 = { reinterpret_cast<intptr_t> (MyEnum_t2344833737_0_0_0_var) };
		IL2CPP_RUNTIME_CLASS_INIT(Type_t_il2cpp_TypeInfo_var);
		Type_t * L_1 = Type_GetTypeFromHandle_m1620074514(NULL /*static, unused*/, L_0, /*hidden argument*/NULL);
		return L_1;
	}
}
 
extern "C"  Type_t * TestClass_TypeofClass_m3517807824 (RuntimeObject * __this /* static, unused */, const RuntimeMethod* method)
{
	static bool s_Il2CppMethodInitialized;
	if (!s_Il2CppMethodInitialized)
	{
		il2cpp_codegen_initialize_method (TestClass_TypeofClass_m3517807824_MetadataUsageId);
		s_Il2CppMethodInitialized = true;
	}
	{
		RuntimeTypeHandle_t3027515415  L_0 = { reinterpret_cast<intptr_t> (MyClass_t3388352440_0_0_0_var) };
		IL2CPP_RUNTIME_CLASS_INIT(Type_t_il2cpp_TypeInfo_var);
		Type_t * L_1 = Type_GetTypeFromHandle_m1620074514(NULL /*static, unused*/, L_0, /*hidden argument*/NULL);
		return L_1;
	}
}

The result is the same for each of these. We’ve already seen IL2CPP_RUNTIME_CLASS_INIT, so let’s look at Type_GetTypeFromHandle_m1620074514:

extern "C"  Type_t * Type_GetTypeFromHandle_m1620074514 (RuntimeObject * __this /* static, unused */, RuntimeTypeHandle_t3027515415  ___handle0, const RuntimeMethod* method)
{
	static bool s_Il2CppMethodInitialized;
	if (!s_Il2CppMethodInitialized)
	{
		il2cpp_codegen_initialize_method (Type_GetTypeFromHandle_m1620074514_MetadataUsageId);
		s_Il2CppMethodInitialized = true;
	}
	{
		intptr_t L_0 = RuntimeTypeHandle_get_Value_m1525396455((&___handle0), /*hidden argument*/NULL);
		bool L_1 = IntPtr_op_Equality_m408849716(NULL /*static, unused*/, L_0, (intptr_t)(0), /*hidden argument*/NULL);
		if (!L_1)
		{
			goto IL_0018;
		}
	}
	{
		return (Type_t *)NULL;
	}
 
IL_0018:
	{
		intptr_t L_2 = RuntimeTypeHandle_get_Value_m1525396455((&___handle0), /*hidden argument*/NULL);
		IL2CPP_RUNTIME_CLASS_INIT(Type_t_il2cpp_TypeInfo_var);
		Type_t * L_3 = Type_internal_from_handle_m3156085815(NULL /*static, unused*/, L_2, /*hidden argument*/NULL);
		return L_3;
	}
}

RuntimeTypeHandle_get_Value_m1525396455 is just an accessor and IntPtr_op_Equality_m408849716 is just an ==, but Type_internal_from_handle_m3156085815 does more:

extern "C"  Type_t * Type_internal_from_handle_m3156085815 (RuntimeObject * __this /* static, unused */, intptr_t ___handle0, const RuntimeMethod* method)
{
	typedef Type_t * (*Type_internal_from_handle_m3156085815_ftn) (intptr_t);
	using namespace il2cpp::icalls;
	return  ((Type_internal_from_handle_m3156085815_ftn)mscorlib::System::Type::internal_from_handle) (___handle0);
}

This really just calls into mscorlib::System::Type::internal_from_handle, which looks like this:

Il2CppReflectionType * Type::internal_from_handle(intptr_t ptr)
{
	const Il2CppType* type = (const Il2CppType*)ptr;
	Il2CppClass *klass = Class::FromIl2CppType(type);
 
	return il2cpp::vm::Reflection::GetTypeObject(klass->byval_arg);
}

Class::FromIl2CppType looks like this:

	Il2CppClass* Class::FromIl2CppType(const Il2CppType* type)
	{
#define RETURN_DEFAULT_TYPE(fieldName) do { IL2CPP_ASSERT(il2cpp_defaults.fieldName); return il2cpp_defaults.fieldName; } while (false)
 
		switch (type->type)
		{
			case IL2CPP_TYPE_OBJECT:
				RETURN_DEFAULT_TYPE(object_class);
			case IL2CPP_TYPE_VOID:
				RETURN_DEFAULT_TYPE(void_class);
			case IL2CPP_TYPE_BOOLEAN:
				RETURN_DEFAULT_TYPE(boolean_class);
			case IL2CPP_TYPE_CHAR:
				RETURN_DEFAULT_TYPE(char_class);
			case IL2CPP_TYPE_I1:
				RETURN_DEFAULT_TYPE(sbyte_class);
			case IL2CPP_TYPE_U1:
				RETURN_DEFAULT_TYPE(byte_class);
			case IL2CPP_TYPE_I2:
				RETURN_DEFAULT_TYPE(int16_class);
			case IL2CPP_TYPE_U2:
				RETURN_DEFAULT_TYPE(uint16_class);
			case IL2CPP_TYPE_I4:
				RETURN_DEFAULT_TYPE(int32_class);
			case IL2CPP_TYPE_U4:
				RETURN_DEFAULT_TYPE(uint32_class);
			case IL2CPP_TYPE_I:
				RETURN_DEFAULT_TYPE(int_class);
			case IL2CPP_TYPE_U:
				RETURN_DEFAULT_TYPE(uint_class);
			case IL2CPP_TYPE_I8:
				RETURN_DEFAULT_TYPE(int64_class);
			case IL2CPP_TYPE_U8:
				RETURN_DEFAULT_TYPE(uint64_class);
			case IL2CPP_TYPE_R4:
				RETURN_DEFAULT_TYPE(single_class);
			case IL2CPP_TYPE_R8:
				RETURN_DEFAULT_TYPE(double_class);
			case IL2CPP_TYPE_STRING:
				RETURN_DEFAULT_TYPE(string_class);
			case IL2CPP_TYPE_TYPEDBYREF:
				RETURN_DEFAULT_TYPE(typed_reference_class);
			case IL2CPP_TYPE_ARRAY:
			{
				Il2CppClass* elementClass = FromIl2CppType(type->data.array->etype);
				return Class::GetBoundedArrayClass(elementClass, type->data.array->rank, true);
			}
			case IL2CPP_TYPE_PTR:
				return Class::GetPtrClass(type->data.type);
			case IL2CPP_TYPE_FNPTR:
				NOT_IMPLEMENTED(Class::FromIl2CppType);
				return NULL; //mono_fnptr_class_get (type->data.method);
			case IL2CPP_TYPE_SZARRAY:
			{
				Il2CppClass* elementClass = FromIl2CppType(type->data.type);
				return Class::GetArrayClass(elementClass, 1);
			}
			case IL2CPP_TYPE_CLASS:
			case IL2CPP_TYPE_VALUETYPE:
				return Type::GetClass(type);
			case IL2CPP_TYPE_GENERICINST:
				return GenericClass::GetClass(type->data.generic_class);
			case IL2CPP_TYPE_VAR:
				return Class::FromGenericParameter(Type::GetGenericParameter(type));
			case IL2CPP_TYPE_MVAR:
				return Class::FromGenericParameter(Type::GetGenericParameter(type));
			default:
				NOT_IMPLEMENTED(Class::FromIl2CppType);
		}
 
		return NULL;
 
#undef RETURN_DEFAULT_TYPE
	}

il2cpp::vm::Reflection::GetTypeObject looks like this:

Il2CppReflectionType* Reflection::GetTypeObject(const Il2CppType *type)
{
	il2cpp::os::FastAutoLock lock(&s_ReflectionICallsMutex);
 
	Il2CppReflectionType* object = NULL;
	if (s_TypeMap->TryGetValue(type, &object))
		return object;
 
	Il2CppReflectionType* typeObject = (Il2CppReflectionType*)Object::New(il2cpp_defaults.monotype_class);
	typeObject->type = type;
 
	s_TypeMap->Add(type, typeObject);
 
	return typeObject;
}

We could keep following the function calls, but at this point we’ve seen enough to draw conclusions.

Conclusion: avoid typeof in performance-critical code. Cache its result to avoid duplicating the work.

GetType

Finally, let’s look at object.GetType() to see what differences there are to typeof:

public static class TestClass
{
	public static Type GetTypePrimitive(int x)
	{
		return x.GetType();
	}
 
	public static Type GetTypeStruct(MyStruct x)
	{
		return x.GetType();
	}
 
	public static Type GetTypeEnum(MyEnum x)
	{
		return x.GetType();
	}
 
	public static Type GetTypeClass(MyClass x)
	{
		return x.GetType();
	}
}

Here’s the IL2CPP output:

extern "C"  Type_t * TestClass_GetTypePrimitive_m349042583 (RuntimeObject * __this /* static, unused */, int32_t ___x0, const RuntimeMethod* method)
{
	static bool s_Il2CppMethodInitialized;
	if (!s_Il2CppMethodInitialized)
	{
		il2cpp_codegen_initialize_method (TestClass_GetTypePrimitive_m349042583_MetadataUsageId);
		s_Il2CppMethodInitialized = true;
	}
	{
		RuntimeObject * L_0 = Box(Int32_t2950945753_il2cpp_TypeInfo_var, (&___x0));
		NullCheck(L_0);
		Type_t * L_1 = Object_GetType_m88164663(L_0, /*hidden argument*/NULL);
		___x0 = *(int32_t*)UnBox(L_0);
		return L_1;
	}
}
 
extern "C"  Type_t * TestClass_GetTypeStruct_m1184800290 (RuntimeObject * __this /* static, unused */, MyStruct_t123831593  ___x0, const RuntimeMethod* method)
{
	static bool s_Il2CppMethodInitialized;
	if (!s_Il2CppMethodInitialized)
	{
		il2cpp_codegen_initialize_method (TestClass_GetTypeStruct_m1184800290_MetadataUsageId);
		s_Il2CppMethodInitialized = true;
	}
	{
		RuntimeObject * L_0 = Box(MyStruct_t123831593_il2cpp_TypeInfo_var, (&___x0));
		NullCheck(L_0);
		Type_t * L_1 = Object_GetType_m88164663(L_0, /*hidden argument*/NULL);
		___x0 = *(MyStruct_t123831593 *)UnBox(L_0);
		return L_1;
	}
}
 
extern "C"  Type_t * TestClass_GetTypeEnum_m118029280 (RuntimeObject * __this /* static, unused */, int32_t ___x0, const RuntimeMethod* method)
{
	static bool s_Il2CppMethodInitialized;
	if (!s_Il2CppMethodInitialized)
	{
		il2cpp_codegen_initialize_method (TestClass_GetTypeEnum_m118029280_MetadataUsageId);
		s_Il2CppMethodInitialized = true;
	}
	{
		RuntimeObject * L_0 = Box(MyEnum_t2344833737_il2cpp_TypeInfo_var, (&___x0));
		NullCheck(L_0);
		Type_t * L_1 = Object_GetType_m88164663(L_0, /*hidden argument*/NULL);
		___x0 = *(int32_t*)UnBox(L_0);
		return L_1;
	}
}
 
extern "C"  Type_t * TestClass_GetTypeClass_m1216189806 (RuntimeObject * __this /* static, unused */, MyClass_t3388352440 * ___x0, const RuntimeMethod* method)
{
	{
		MyClass_t3388352440 * L_0 = ___x0;
		NullCheck(L_0);
		Type_t * L_1 = Object_GetType_m88164663(L_0, /*hidden argument*/NULL);
		return L_1;
	}
}

These are all mostly the same. They’re mostly a call to Object_GetType_m88164663. When value types (i.e. primitives, enums, and structs) are used, boxing also occurs and creates garbage for the GC. Let’s have a look at Object_GetType_m88164663 to see what work is being done:

extern "C"  Type_t * Object_GetType_m88164663 (RuntimeObject * __this, const RuntimeMethod* method)
{
	typedef Type_t * (*Object_GetType_m88164663_ftn) (RuntimeObject *);
	using namespace il2cpp::icalls;
	return  ((Object_GetType_m88164663_ftn)mscorlib::System::Object::GetType) (__this);
}

This is another wrapper function, this time calling mscorlib::System::Object::GetType. Let’s check it out:

Il2CppReflectionType* Object::GetType(Il2CppObject* obj)
{
	return il2cpp::vm::Reflection::GetTypeObject(obj->klass->byval_arg);
}

This in turn calls il2cpp::vm::Reflection::GetTypeObject, which is the big expensive function we saw above with typeof. So there’s not much difference between calling GetType and using typeof, other than the boxing for value types.

Conclusion: avoid GetType in performance-critical code, especially with value types. Cache its result to avoid duplicating the work.