В предыдущей статье из серии по IL2CPP мы рассмотрели вызовы методов в генерируемом коде C++. Теперь мы поговорим об одной из самых важных особенностей кода IL2CPP – обобщенной реализации методов, позволяющей существенно уменьшить размер исполняемого файла IL2CPP. Стоит отметить, что обобщенная реализация также используется в средах выполнения Mono и .NET. В IL2CPP она изначально не поддерживалась и была добавлена только со временем.
Итак, мы проанализируем реализацию обобщенных методов для ссылочных типов и типов значений, а также то, как на нее влияют ограничения обобщенных параметров. Не забывайте, что генерируемый код в этой статье может измениться в следующих версиях Unity. Но, как правило, мы обсуждаем подобные изменения сразу после их выхода.
Представьте, что вам нужно написать реализацию для класса List на C#. Будет ли эта реализация зависеть от типа T? Можно ли использовать реализацию метода Add как для List string, так и для List object? А как насчет List DateTime?
Вообще-то, плюс обобщений как раз в том, что их реализации в C# подходят для совместного использования, а значит обобщенный класс List подойдет для любого типа T. Но что если List нужно преобразовать из C# во что-то исполняемое, например, ассемблерный код, как это делает Mono, или код C++ в случае IL2CPP? Сможем ли мы тогда использовать одинаковую реализацию метода Add?
В большинстве случаев да. Как мы увидим далее в этой статье, возможность обобщенной реализации почти полностью от размера типа T. Если это ссылочный тип (string или object), его размер всегда равен размеру указателя. Если же T – тип значения (int или DateTime), его размер может варьироваться, и это немного усложняет вещи. В конечном итоге, чем больше методов имеют общую реализацию, тем меньше размер исполняемого кода.
Марк Пробст, разработчик, внедривший обобщенную реализацию в Mono, написал об этом несколько интереснейших статей. Мы не будем сильно углубляться в сам концепт, а лучше поговорим о том, как и при каких условиях он используется в IL2CPP. Думаю, эта информация сможет дать вам более полную картину о размере исполняемого файла вашего проекта.
IL2CPP поддерживает обобщенную реализацию методов для типа SomeGenericType, если T представляет собой ссылочный тип (string, object или любой пользовательский класс), целочисленный тип или enum. Для типов значений обобщенная реализация не поддерживается, поскольку их размер может варьироваться в зависимости от размера полей.
Это значит, что добавление SomeGenericType, где T – это ссылочный тип, практически не повлияет на размер исполняемого файла. С другой стороны, если T является типом значения, последствия будут более ощутимыми. В Mono и IL2CPP это работает одинаково. Но давайте перейдем непосредственно к деталям реализации.
Я буду использовать версию Unity 5.0.2p1 на Windows для сборки проекта под WebGL. При этом я включу опцию Development Player и задам значение None для Enable Exceptions. Для начала пропишем метод драйвера, чтобы создать экземпляры обобщенных типов, которые мы и будем рассматривать:
Затем определим типы, используемые в этом методе:
Весь код вложен в класс под названием HelloWorld, производный от MonoBehaviour. Вы можете также обратить внимание, что командная строка il2cpp.exe больше не содержит опцию -enable-generic-sharing, как в первой статье этой серии. Тем не менее, обобщенная реализация происходит, но теперь – автоматически.
Для начала рассмотрим самый распространенный случай – ссылочные типы. В управляемом коде такие типы являются производными от System.Object, а в генерируемом коде – от Object_t. Поэтому для их представления в коде C++ можно использовать плейсхолдер Object_t*.
Давайте найдем сгенерированную версию метода DemonstrateGenericSharing. В моем проекте она называется HelloWorld_DemonstrateGenericSharing_m4. Нас интересуют определения четырех методов в классе GenericType. С помощью Ctags мы можем перейти к объявлению метода для GenericType_1__ctor_m8 (конструктора GenericType). Обратите внимание, что это объявление метода является оператором #define, сопоставляющим данный метод с методом GenericType_1__ctor_m10447_gshared.
Теперь давайте найдем объявления методов для типа GenericType. Что интересно, объявление конструктора GenericType_1__ctor_m9 также является оператором #define, связанным с той же функцией – GenericType_1__ctor_m10447_gshared!
Комментарий к коду определения GenericType_1__ctor_m10447_gshared указывает на то, что этот метод соответствует имени управляемого метода HelloWorld/GenericType`1<System.Object>::.ctor(). Это конструктор типа GenericType object, который называется полностью обобщенным – если взять тип GenericType, то для любого ссылочного типа T реализация всех методов будет использовать версию, где T – это object.
Чуть ниже конструктора в генерируемом коде можно увидеть метод UsesGenericParameter:
В обоих случаях, где встречается обобщенный параметр T (тип возвращаемого значения и тип отдельного аргумента), в генерируемом коде используется тип Object_t*. А с учетом того, что все ссылочные типы в таком коде могут быть представлены через Object_t*, эту реализацию метода можно вызвать для любого T, являющегося ссылочным типом.
Во второй статье из этой серии я упоминал, что все определения методов в C++ являются свободными функциями. Утилита il2cpp.exe не использует наследование C++ для генерации переопределенных методов C#, однако использует его для типов. Введя в поиск «AnyClass_t», мы можем увидеть, как тип C# AnyClass выглядит в C++:
Учитывая, что AnyClass_t1 является производным от Object_t, мы можем просто передать ему указатель в качестве аргумента к функции GenericType_1_UsesGenericParameter_m10449_gshared.
Но что насчет возвращаемого значения? Мы не можем возвратить указатель на базовый класс там, где предполагается указатель на производный, разве не так? Взгляните на объявление метода GenericType::UsesGenericParameter:
В генерируемом коде возвращаемое значение (тип Object_t*) фактически становится производным типом AnyClass_t1*. Получается, IL2CPP обманывает компилятор C++, чтобы избежать системы типов C++.
Предположим, нам нужно разрешить вызов некоторых методов для объекта типа Т, но разве использование Object_t* не будет этому препятствовать? Будет, но для начала мы должны сообщить эту идею компилятору C# с помощью обобщенных ограничений.
Взгляните еще раз на скриптовый код, а именно на InterfaceConstrainedGenericType. Этот обобщенный тип использует выражение where, чтобы тип T был производным от интерфейса AnswerFinderInterface, тем самым разрешая вызов метода ComputeAnswer. В предыдущей статье мы говорили о том, что для вызова методов интерфейса требуется поиск в таблице vtable. А поскольку метод FindTheAnswer совершает прямой вызов функции для экземпляра ограниченного типа T (представленного через Object_t*), в коде C++ может использоваться полностью обобщенная реализация.
Перейдя от реализации функции HelloWorld_DemonstrateGenericSharing_m4 к определению функции InterfaceConstrainedGenericType_1__ctor_m11, мы можем увидеть, что и этот метод является оператором #define, связанным с функцией InterfaceConstrainedGenericType_1__ctor_m10456_gshared. Чуть ниже находится реализация функции InterfaceConstrainedGenericType_1_FindTheAnswer_m10458_gshared, которая принимает аргумент Object_t* и также является полностью обобщенной. Вызов функции InterfaceFuncInvoker0::Invoke позволяет совершить вызов управляемого метода ComputeAnswer.
Важно помнить, что IL2CPP рассматривает любой управляемый интерфейс как System.Object. Это правило подходит для любого кода, генерируемого утилитой il2cpp.exe.
Кроме ограничений интерфейса C# допускает наличие ограничений базового класса. Но если IL2CPP не рассматривает базовые классы как System.Object, как в таком случае работает обобщенная реализация?
Так как базовые классы всегда являются ссылочными типами, IL2CPP использует для них полностью обобщенные методы. В любом коде, использующем поле или вызывающем метод для ограниченного типа, происходит приведение типа в C++. Опять-таки компилятор C# обеспечивает корректную реализацию обобщенного ограничения, и мы обманываем компилятор C++ касательно типа.
Давайте вернемся к функции HelloWorld_DemonstrateGenericSharing_m4 и взглянем на реализацию GenericType. Тип DateTime – ссылочный, поэтому GenericType не является обобщенным. Перейдем к объявлению конструктора этого типа, GenericType_1__ctor_m10. Здесь, как и в других случаях, мы видим #define, но он связан с функцией GenericType_1__ctor_m10_gshared, используемой только одним классом – GenericType.
Концепция обобщенной реализации может быть достаточно сложной для понимания. Предметная область полна патологических случаев (тех же рекурсивных шаблонов). Поэтому здесь нужно выделить несколько основных принципов:
Для любого обобщенного типа утилита il2cpp.exe всегда генерирует полностью обобщенные реализации методов. Другие реализации генерируются, только если это необходимо.
Обобщенная реализация используется не только для обобщенных типов, но и для обобщенных методов. Обратите внимание, что в исходном скриптовом коде метод UsesDifferentGenericParameter использует параметр другого типа, нежели класс GenericType. Но при рассмотрении обобщенной реализации для класса GenericType мы не видели этого метода. Введя в поиск «UsesDifferentGenericParameter», мы видим, что реализация этого метода находится в файле GenericMethods0.cpp:
Это полностью обобщенная реализация, принимающая тип Object_t*. И хотя этот метод обобщенного типа, поведение было бы таким же для необобщенного. Можно утверждать, что il2cpp.exe всегда пытается генерировать минимальное количество кода для реализации методов с обобщенными параметрами.
Обобщенная реализация – одно из самых важных улучшений в IL2CPP с момента ее выхода, позволяющее значительно уменьшить размер кода C++ для реализаций методов с одинаковым поведением. Мы продолжаем искать решения для уменьшения размера бинарных файлов и пытаемся использовать больше преимуществ и возможностей обобщенной реализации.
В следующей статье мы поговорим о генерации оберток p/invoke, а также о маршалинге типов между управляемым и неуправляемым кодом.
Итак, мы проанализируем реализацию обобщенных методов для ссылочных типов и типов значений, а также то, как на нее влияют ограничения обобщенных параметров. Не забывайте, что генерируемый код в этой статье может измениться в следующих версиях Unity. Но, как правило, мы обсуждаем подобные изменения сразу после их выхода.
Что такое обобщенная реализация?
Представьте, что вам нужно написать реализацию для класса List на C#. Будет ли эта реализация зависеть от типа T? Можно ли использовать реализацию метода Add как для List string, так и для List object? А как насчет List DateTime?
Вообще-то, плюс обобщений как раз в том, что их реализации в C# подходят для совместного использования, а значит обобщенный класс List подойдет для любого типа T. Но что если List нужно преобразовать из C# во что-то исполняемое, например, ассемблерный код, как это делает Mono, или код C++ в случае IL2CPP? Сможем ли мы тогда использовать одинаковую реализацию метода Add?
В большинстве случаев да. Как мы увидим далее в этой статье, возможность обобщенной реализации почти полностью от размера типа T. Если это ссылочный тип (string или object), его размер всегда равен размеру указателя. Если же T – тип значения (int или DateTime), его размер может варьироваться, и это немного усложняет вещи. В конечном итоге, чем больше методов имеют общую реализацию, тем меньше размер исполняемого кода.
Марк Пробст, разработчик, внедривший обобщенную реализацию в Mono, написал об этом несколько интереснейших статей. Мы не будем сильно углубляться в сам концепт, а лучше поговорим о том, как и при каких условиях он используется в IL2CPP. Думаю, эта информация сможет дать вам более полную картину о размере исполняемого файла вашего проекта.
Особенности обобщенной реализации в IL2CPP
IL2CPP поддерживает обобщенную реализацию методов для типа SomeGenericType, если T представляет собой ссылочный тип (string, object или любой пользовательский класс), целочисленный тип или enum. Для типов значений обобщенная реализация не поддерживается, поскольку их размер может варьироваться в зависимости от размера полей.
Это значит, что добавление SomeGenericType, где T – это ссылочный тип, практически не повлияет на размер исполняемого файла. С другой стороны, если T является типом значения, последствия будут более ощутимыми. В Mono и IL2CPP это работает одинаково. Но давайте перейдем непосредственно к деталям реализации.
Подготовка к работе
Я буду использовать версию Unity 5.0.2p1 на Windows для сборки проекта под WebGL. При этом я включу опцию Development Player и задам значение None для Enable Exceptions. Для начала пропишем метод драйвера, чтобы создать экземпляры обобщенных типов, которые мы и будем рассматривать:
public void DemonstrateGenericSharing() {
var usesAString = new GenericType<string>();
var usesAClass = new GenericType<AnyClass>();
var usesAValueType = new GenericType<DateTime>();
var interfaceConstrainedType = new InterfaceConstrainedGenericType<ExperimentWithInterface>();
}
Затем определим типы, используемые в этом методе:
class GenericType<T> {
public T UsesGenericParameter(T value) {
return value;
}
public void DoesNotUseGenericParameter() {}
public U UsesDifferentGenericParameter<U>(U value) {
return value;
}
}
class AnyClass {}
interface AnswerFinderInterface {
int ComputeAnswer();
}
class ExperimentWithInterface : AnswerFinderInterface {
public int ComputeAnswer() {
return 42;
}
}
class InterfaceConstrainedGenericType<T> where T : AnswerFinderInterface {
public int FindTheAnswer(T experiment) {
return experiment.ComputeAnswer();
}
}
Весь код вложен в класс под названием HelloWorld, производный от MonoBehaviour. Вы можете также обратить внимание, что командная строка il2cpp.exe больше не содержит опцию -enable-generic-sharing, как в первой статье этой серии. Тем не менее, обобщенная реализация происходит, но теперь – автоматически.
Обобщенная реализация ссылочных типов
Для начала рассмотрим самый распространенный случай – ссылочные типы. В управляемом коде такие типы являются производными от System.Object, а в генерируемом коде – от Object_t. Поэтому для их представления в коде C++ можно использовать плейсхолдер Object_t*.
Давайте найдем сгенерированную версию метода DemonstrateGenericSharing. В моем проекте она называется HelloWorld_DemonstrateGenericSharing_m4. Нас интересуют определения четырех методов в классе GenericType. С помощью Ctags мы можем перейти к объявлению метода для GenericType_1__ctor_m8 (конструктора GenericType). Обратите внимание, что это объявление метода является оператором #define, сопоставляющим данный метод с методом GenericType_1__ctor_m10447_gshared.
Теперь давайте найдем объявления методов для типа GenericType. Что интересно, объявление конструктора GenericType_1__ctor_m9 также является оператором #define, связанным с той же функцией – GenericType_1__ctor_m10447_gshared!
Комментарий к коду определения GenericType_1__ctor_m10447_gshared указывает на то, что этот метод соответствует имени управляемого метода HelloWorld/GenericType`1<System.Object>::.ctor(). Это конструктор типа GenericType object, который называется полностью обобщенным – если взять тип GenericType, то для любого ссылочного типа T реализация всех методов будет использовать версию, где T – это object.
Чуть ниже конструктора в генерируемом коде можно увидеть метод UsesGenericParameter:
extern "C" Object_t * GenericType_1_UsesGenericParameter_m10449_gshared (GenericType_1_t2159 * __this, Object_t * ___value, MethodInfo* method)
{
{
Object_t * L_0 = ___value;
return L_0;
}
}
В обоих случаях, где встречается обобщенный параметр T (тип возвращаемого значения и тип отдельного аргумента), в генерируемом коде используется тип Object_t*. А с учетом того, что все ссылочные типы в таком коде могут быть представлены через Object_t*, эту реализацию метода можно вызвать для любого T, являющегося ссылочным типом.
Во второй статье из этой серии я упоминал, что все определения методов в C++ являются свободными функциями. Утилита il2cpp.exe не использует наследование C++ для генерации переопределенных методов C#, однако использует его для типов. Введя в поиск «AnyClass_t», мы можем увидеть, как тип C# AnyClass выглядит в C++:
struct AnyClass_t1 : public Object_t
{
};
Учитывая, что AnyClass_t1 является производным от Object_t, мы можем просто передать ему указатель в качестве аргумента к функции GenericType_1_UsesGenericParameter_m10449_gshared.
Но что насчет возвращаемого значения? Мы не можем возвратить указатель на базовый класс там, где предполагается указатель на производный, разве не так? Взгляните на объявление метода GenericType::UsesGenericParameter:
#define GenericType_1_UsesGenericParameter_m10452(__this, ___value, method) (( AnyClass_t1 * (*) (GenericType_1_t6 *, AnyClass_t1 *, MethodInfo*))GenericType_1_UsesGenericParameter_m10449_gshared)(__this, ___value, method)
В генерируемом коде возвращаемое значение (тип Object_t*) фактически становится производным типом AnyClass_t1*. Получается, IL2CPP обманывает компилятор C++, чтобы избежать системы типов C++.
Обобщенная реализация с ограничениями
Предположим, нам нужно разрешить вызов некоторых методов для объекта типа Т, но разве использование Object_t* не будет этому препятствовать? Будет, но для начала мы должны сообщить эту идею компилятору C# с помощью обобщенных ограничений.
Взгляните еще раз на скриптовый код, а именно на InterfaceConstrainedGenericType. Этот обобщенный тип использует выражение where, чтобы тип T был производным от интерфейса AnswerFinderInterface, тем самым разрешая вызов метода ComputeAnswer. В предыдущей статье мы говорили о том, что для вызова методов интерфейса требуется поиск в таблице vtable. А поскольку метод FindTheAnswer совершает прямой вызов функции для экземпляра ограниченного типа T (представленного через Object_t*), в коде C++ может использоваться полностью обобщенная реализация.
Перейдя от реализации функции HelloWorld_DemonstrateGenericSharing_m4 к определению функции InterfaceConstrainedGenericType_1__ctor_m11, мы можем увидеть, что и этот метод является оператором #define, связанным с функцией InterfaceConstrainedGenericType_1__ctor_m10456_gshared. Чуть ниже находится реализация функции InterfaceConstrainedGenericType_1_FindTheAnswer_m10458_gshared, которая принимает аргумент Object_t* и также является полностью обобщенной. Вызов функции InterfaceFuncInvoker0::Invoke позволяет совершить вызов управляемого метода ComputeAnswer.
extern "C" int32_t InterfaceConstrainedGenericType_1_FindTheAnswer_m10458_gshared (InterfaceConstrainedGenericType_1_t2160 * __this, Object_t * ___experiment, MethodInfo* method)
{
static bool s_Il2CppMethodIntialized;
if (!s_Il2CppMethodIntialized)
{
AnswerFinderInterface_t11_il2cpp_TypeInfo_var = il2cpp_codegen_class_from_type(&AnswerFinderInterface_t11_0_0_0);
s_Il2CppMethodIntialized = true;
}
{
int32_t L_0 = (int32_t)InterfaceFuncInvoker0<int32_t>::Invoke(0 /* System.Int32 HelloWorld/AnswerFinderInterface::ComputeAnswer() */, AnswerFinderInterface_t11_il2cpp_TypeInfo_var, (Object_t *)(*(&___experiment)));
return L_0;
}
}
Важно помнить, что IL2CPP рассматривает любой управляемый интерфейс как System.Object. Это правило подходит для любого кода, генерируемого утилитой il2cpp.exe.
Ограничения базового класса
Кроме ограничений интерфейса C# допускает наличие ограничений базового класса. Но если IL2CPP не рассматривает базовые классы как System.Object, как в таком случае работает обобщенная реализация?
Так как базовые классы всегда являются ссылочными типами, IL2CPP использует для них полностью обобщенные методы. В любом коде, использующем поле или вызывающем метод для ограниченного типа, происходит приведение типа в C++. Опять-таки компилятор C# обеспечивает корректную реализацию обобщенного ограничения, и мы обманываем компилятор C++ касательно типа.
Обобщенная реализация типов значений
Давайте вернемся к функции HelloWorld_DemonstrateGenericSharing_m4 и взглянем на реализацию GenericType. Тип DateTime – ссылочный, поэтому GenericType не является обобщенным. Перейдем к объявлению конструктора этого типа, GenericType_1__ctor_m10. Здесь, как и в других случаях, мы видим #define, но он связан с функцией GenericType_1__ctor_m10_gshared, используемой только одним классом – GenericType.
Концептуальное осмысление обобщенной реализации
Концепция обобщенной реализации может быть достаточно сложной для понимания. Предметная область полна патологических случаев (тех же рекурсивных шаблонов). Поэтому здесь нужно выделить несколько основных принципов:
- Реализация любого метода для обобщенного типа является обобщенной.
- В некоторых случаях реализация методов является обобщенной только для определенного типа (например, вышеупомянутый тип с обобщенным параметром типа значения GenericType).
- Типы с обобщенным параметром ссылочного типа используют полностью обобщенную реализацию, рассматривая параметры всех типов как System.Object.
- Типы с параметрами двух и более типов могут быть частично обобщенными, если как минимум один из типов параметров является ссылочным.
Для любого обобщенного типа утилита il2cpp.exe всегда генерирует полностью обобщенные реализации методов. Другие реализации генерируются, только если это необходимо.
Обобщенные методы
Обобщенная реализация используется не только для обобщенных типов, но и для обобщенных методов. Обратите внимание, что в исходном скриптовом коде метод UsesDifferentGenericParameter использует параметр другого типа, нежели класс GenericType. Но при рассмотрении обобщенной реализации для класса GenericType мы не видели этого метода. Введя в поиск «UsesDifferentGenericParameter», мы видим, что реализация этого метода находится в файле GenericMethods0.cpp:
extern "C" Object_t * GenericType_1_UsesDifferentGenericParameter_TisObject_t_m15243_gshared (GenericType_1_t2159 * __this, Object_t * ___value, MethodInfo* method)
{
{
Object_t * L_0 = ___value;
return L_0;
}
}
Это полностью обобщенная реализация, принимающая тип Object_t*. И хотя этот метод обобщенного типа, поведение было бы таким же для необобщенного. Можно утверждать, что il2cpp.exe всегда пытается генерировать минимальное количество кода для реализации методов с обобщенными параметрами.
Заключение
Обобщенная реализация – одно из самых важных улучшений в IL2CPP с момента ее выхода, позволяющее значительно уменьшить размер кода C++ для реализаций методов с одинаковым поведением. Мы продолжаем искать решения для уменьшения размера бинарных файлов и пытаемся использовать больше преимуществ и возможностей обобщенной реализации.
В следующей статье мы поговорим о генерации оберток p/invoke, а также о маршалинге типов между управляемым и неуправляемым кодом.