IL2CPP Internals: Реализация общего доступа

JOSH PETERSON / UNITY TECHNOLOGIESSenior Software Engineer
Jun 16, 2015|12 Мин
IL2CPP Internals: Реализация общего доступа
Эта веб-страница была переведена с помощью машинного перевода для вашего удобства. Мы не можем гарантировать точность или надежность переведенного контента. Если у вас есть вопросы о точности переведенного контента, обращайтесь к официальной английской версии веб-страницы.

Это пятый пост в серии статей IL2CPP Internals.

В прошлой заметке мы рассмотрели, как вызываются методы в коде C++, сгенерированном для бэкенда сценариев IL2CPP. В этом посте мы рассмотрим, как они реализуются. В частности, мы постараемся лучше понять одну из самых важных особенностей кода, сгенерированного с помощью IL2CPP, - общий доступ. Совместное использование общих методов позволяет многим общим методам иметь одну общую реализацию. Это приводит к значительному уменьшению размера исполняемых файлов для бэкенда сценариев IL2CPP.

Обратите внимание, что общий доступ - это не новая идея, как в Mono, так и в .Net также используется общий доступ. Изначально IL2CPP не осуществлял общего доступа. Последние усовершенствования сделали его еще более надежным и полезным. Поскольку il2cpp.exe генерирует код C++, мы можем увидеть, где реализации методов являются общими.

Мы изучим, как общие реализации общих методов разделяются (или нет) для ссылочных типов и типов значений. Мы также изучим, как ограничения общих параметров влияют на общий обмен.

Помните, что все, что обсуждается в этой серии, - это детали реализации. Темы и код, обсуждаемые здесь , могут измениться в будущем. Нам нравится раскрывать и обсуждать такие детали, когда это возможно!

Что такое общий доступ?

Представьте, что Вы пишете реализацию для класса List<T> в C#. Будет ли эта реализация зависеть от типа T? Могли бы Вы использовать одну и ту же реализацию метода Add для List<string> и List<object>? Как насчет List<DateTime>?

На самом деле, сила дженериков как раз и заключается в том, что эти реализации на C# могут быть общими, и дженериковый класс List<T> будет работать для любого T. Но что произойдет, если перевести List с C# на что-то исполняемое, например, на ассемблер (как это делает Mono) или на C++ (как это делает IL2CPP)? Можем ли мы поделиться реализацией метода Add?

Да, в большинстве случаев мы можем разделить его. Как мы узнаем из этого поста, возможность совместного использования реализации родового метода почти полностью зависит от размера типа T. Если T - это любой ссылочный тип (например, строка или объект), то его размер всегда будет равен размеру указателя. Если T - это тип значения (например, int или DateTime), его размер может варьироваться, и все становится немного сложнее. Чем больше реализаций методов, которые можно совместно использовать, тем меньше получается исполняемый код.

Марк Пробст (Mark Probst), разработчик, реализовавший в Mono функцию общего доступа, написал отличную серию постов о том, как в Mono осуществляется общий доступ. Здесь мы не будем так подробно рассматривать общий доступ. Вместо этого мы посмотрим, как и когда IL2CPP выполняет общий доступ. Надеюсь, эта информация поможет Вам лучше проанализировать и понять исполняемый размер Вашего проекта.

Что разделяет IL2CPP?

В настоящее время IL2CPP разделяет реализации общих методов для общего типа SomeGenericType<T>, когда T является:

- Любой тип ссылки (например, строка, объект или любой класс, определенный пользователем)

- Любой целочисленный или перечислительный тип

IL2CPP не разделяет реализации общих методов, когда T является типом значения, потому что размер каждого типа значения будет отличаться (на основе размера его полей).

На практике это означает, что добавление нового использования SomeGenericType<T>, где T - ссылочный тип, окажет минимальное влияние на размер исполняемого файла. Однако, если T - это тип значения, размер исполняемого файла будет изменен. Это поведение одинаково для бэкендов скриптов Mono и IL2CPP. Если Вы хотите узнать больше, читайте дальше, пришло время вникнуть в некоторые детали реализации!

Настройка

Я буду использовать Unity 5.0.2p1 под Windows и создавать для платформы WebGL. Я включил опцию "Development Player" в настройках сборки, а для опции "Enable Exceptions" установлено значение "None". Код сценария для этой заметки начинается с метода драйвера для создания экземпляров общих типов, которые мы будем исследовать:

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, все ссылочные типы в сгенерированном коде C++ происходят от типа Object_t. Все ссылочные типы могут быть представлены в коде C++ с использованием типа Object_t* в качестве заполнителя. Сейчас мы увидим, почему это важно.

Давайте поищем сгенерированную версию метода DemonstrateGenericSharing. В моем проекте он называется HelloWorld_DemonstrateGenericSharing_m4. Мы ищем определения методов для четырех методов в классе GenericType. Используя Ctags, мы можем перейти к объявлению метода для конструктора GenericType<string>, GenericType_1__ctor_m8. Обратите внимание, что это объявление метода на самом деле представляет собой оператор #define, отображающий метод на другой метод, GenericType_1__ctor_m10447_gshared.

Давайте перепрыгнем назад, назад и затем найдем объявления методов для типа GenericType<AnyClass>. Если мы перейдем к объявлению конструктора, GenericType_1__ctor_m9, то увидим, что это также оператор #define, сопоставленный с той же самой функцией, GenericType_1__ctor_m10447_gshared!

Если мы перейдем к определению GenericType_1__ctor_m10447_gshared, то из комментария кода к определению метода увидим, что этот метод соответствует имени управляемого метода HelloWorld/GenericType`1<System.Object>::.ctor(). Это конструктор для типа GenericType<объект>. Этот тип называется полностью разделяемым типом, а это значит, что при наличии типа GenericType<T>, для любого T, являющегося ссылочным типом, реализация всех методов будет использовать эту версию, где T - объект.

Посмотрите чуть ниже конструктора в сгенерированном коде, и Вы увидите код C++ для метода 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++. Однако il2cpp.exe использует наследование типов в C++. Если мы поищем в сгенерированном коде строку "AnyClass_t", то найдем представление в C++ типа AnyClass из C#:

struct  AnyClass_t1  : public Object_t
{
};

Поскольку AnyClass_t1 происходит от Object_t, мы можем без проблем передать указатель на AnyClass_t1 в качестве аргумента функции GenericType_1_UsesGenericParameter_m10449_gshared.

А как насчет возвращаемого значения? Мы не можем возвращать указатель на базовый класс там, где ожидается указатель на производный класс, верно? Посмотрите на объявление метода GenericType<AnyClass>::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++. Поскольку компилятор C# уже убедился, что никакой код в UsesGenericParameter не делает ничего необоснованного с типом T, то IL2CPP может спокойно лгать компилятору C++.

Общий доступ с ограничениями

Предположим, что мы хотим разрешить вызов некоторых методов на объекте типа T? Не помешает ли этому использование Object_t*, ведь у нас не так много методов на System.Object? Да, это верно. Но сначала нам нужно выразить эту идею компилятору C# с помощью родовых ограничений.

Посмотрите еще раз в коде сценария к этой заметке на тип с именем InterfaceConstrainedGenericType. Этот общий тип использует предложение where, чтобы потребовать, чтобы тип T был получен из заданного интерфейса AnswerFinderInterface. Это позволяет вызвать метод ComputeAnswer. Вспомните из предыдущей статьи в блоге о вызове методов, что вызов методов интерфейса требует поиска в структуре vtable. Поскольку метод FindTheAnswer будет выполнять прямой вызов функции на ограниченном экземпляре типа T, то в коде C++ можно использовать полностью общую реализацию метода, где тип T представлен Object_t*.

Если мы начнем с реализации функции 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 *)(*(&amp;amp;___experiment)));
return L_0;
}
}

В сгенерированном коде на языке C++ все это работает вместе, потому что IL2CPP рассматривает все управляемые интерфейсы как System.Object. Это полезное эмпирическое правило, которое поможет понять код, генерируемый il2cpp.exe, и в других случаях.

Ограничения с базовым классом

В дополнение к интерфейсным ограничениям, C# позволяет ограничениям быть базовым классом. IL2CPP не относится ко всем базовым классам, как System.Object, поэтому как работает общий доступ для ограничений базового класса?

Поскольку базовые классы всегда являются ссылочными типами, IL2CPP использует полностью разделяемую версию общих методов для этих типов. Любой код, которому необходимо использовать поле или вызвать метод ограниченного типа, выполняет приведение в C++ к соответствующему типу. Опять же, здесь мы полагаемся на то, что компилятор C# правильно применит родовое ограничение, и обманываем компилятор C++ относительно типа.

Общий доступ к типам значений

Давайте теперь вернемся к функции HelloWorld_DemonstrateGenericSharing_m4 и посмотрим на реализацию GenericType<DateTime>. Тип DateTime - это тип значения, поэтому GenericType<DateTime> не является общим. Мы можем перейти к объявлению конструктора для этого типа, GenericType_1__ctor_m10. Здесь мы видим #define, как и в других случаях, но #define относится к функции GenericType_1__ctor_m10_gshared, которая специфична для класса GenericType<DateTime> и не используется никаким другим классом.

Размышляя о концептуальном совместном использовании

Реализация общего доступа может быть сложной для понимания и следования. Само проблемное пространство изобилует патологическими случаями (например, любопытно повторяющийся шаблон). Это может помочь вспомнить несколько концепций:

- Каждая реализация метода на общем типе является общей

- Некоторые родовые типы разделяют реализацию методов только с самими собой (например, родовые типы с общим параметром типа значения, GenericType выше).

- Общие типы с общим параметром ссылочного типа полностью разделяются - они всегда используют реализацию с System.Object для всех параметров типа.

- Родовые типы с двумя или более параметрами типа могут быть частично общими, если хотя бы один из этих параметров типа является ссылочным типом.

Утилита il2cpp.exe всегда генерирует полностью разделяемые реализации методов для любого общего типа. Он генерирует другие реализации методов только тогда, когда они используются.

Совместное использование общих методов

Точно так же, как реализации методов для общих типов могут быть общими, так же могут быть общими и реализации методов для общих методов. В исходном коде сценария обратите внимание, что метод UsesDifferentGenericParameter использует параметр другого типа, чем класс GenericType. Когда мы просмотрели реализации общих методов для класса GenericType, мы не увидели метода UsesDifferentGenericParameter. Если я найду в сгенерированном коде "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 и как типы передаются из управляемого кода в родной. Мы сможем увидеть стоимость маршалинга различных типов и отладить проблемы с кодом маршалинга.