IL2CPP Internos: Aplicación genérica del reparto

JOSH PETERSON / UNITY TECHNOLOGIESSenior Software Engineer
Jun 16, 2015|12 minutos
IL2CPP Internos: Aplicación genérica del reparto
Para tu comodidad, tradujimos esta página mediante traducción automática. No podemos garantizar la precisión ni la confiabilidad del contenido traducido. Si tienes alguna duda sobre la precisión del contenido traducido, consulta la versión oficial en inglés de la página web.

Este es el quinto post de la serie IL2CPP Internals.

En el último post, vimos cómo se llaman los métodos en el código C++ generado para el backend de scripting IL2CPP. En este post, exploraremos cómo se implementan. En concreto, intentaremos comprender mejor una de las características más importantes del código generado con IL2CPP: la compartición genérica. La compartición genérica permite que muchos métodos genéricos compartan una implementación común. Esto conlleva una disminución significativa del tamaño del ejecutable para el backend de scripting IL2CPP.

Tenga en cuenta que la compartición genérica no es una idea nueva, tanto Mono como los tiempos de ejecución .Net utilizan también la compartición genérica. Inicialmente, el IL2CPP no realizaba el reparto genérico. Las recientes mejoras lo han hecho aún más robusto y beneficioso. Dado que il2cpp.exe genera código C++, podemos ver dónde se comparten las implementaciones de los métodos.

Exploraremos cómo se comparten (o no) las implementaciones de métodos genéricos para los tipos de referencia y los tipos de valor. También investigaremos cómo afectan las restricciones de los parámetros genéricos al reparto genérico.

Tenga en cuenta que todo lo tratado en esta serie son detalles de implementación. Es probable que los temas y el código aquí tratados cambien en el futuro. Sin embargo, ¡nos gusta exponer y discutir detalles como éste cuando es posible!

¿Qué es el reparto genérico?

Imagine que está escribiendo la implementación de la clase List<T> en C#. ¿Dependería esa implementación del tipo que sea T? ¿Podría utilizar la misma implementación del método Add para List<string> y List<object>? ¿Qué le parece List<DateTime>?

De hecho, el poder de los genéricos es precisamente que estas implementaciones de C# pueden compartirse, y la clase genérica List<T> funcionará para cualquier T. Pero, ¿qué ocurre cuando List se traduce de C# a algo ejecutable, como código ensamblador (como hace Mono) o código C++ (como hace IL2CPP)? ¿Podemos seguir compartiendo la implementación del método Añadir?

Sí, podemos compartirlo la mayoría de las veces. Como descubriremos en este post, la capacidad de compartir la implementación de un método genérico depende casi por completo del tamaño de ese tipo T. Si T es cualquier tipo de referencia (como cadena u objeto), entonces siempre tendrá el tamaño de un puntero. Si T es un tipo de valor (como int o DateTime), su tamaño puede variar, y las cosas se complican un poco más. Cuantas más implementaciones de métodos puedan compartirse, más pequeño será el código ejecutable resultante.

Mark Probst, el desarrollador que implementó la compartición genérica Mono, tiene una excelente serie de posts sobre cómo Mono realiza la compartición genérica. No profundizaremos tanto aquí sobre el reparto genérico. En su lugar, veremos cómo y cuándo IL2CPP realiza la compartición genérica. Esperemos que esta información le ayude a analizar y comprender mejor el tamaño ejecutable de su proyecto.

¿Qué comparte el IL2CPP?

Actualmente, IL2CPP comparte implementaciones de métodos genéricos para un tipo genérico SomeGenericType<T> cuando T es:

- Cualquier tipo de referencia (por ejemplo, cadena, objeto o cualquier clase definida por el usuario)

- Cualquier tipo entero o enum

IL2CPP no comparte implementaciones de métodos genéricos cuando T es un tipo de valor porque el tamaño de cada tipo de valor diferirá (en función del tamaño de sus campos).

En la práctica, esto significa que añadir un nuevo uso de SomeGenericType<T>, donde T es un tipo de referencia, tendrá un impacto mínimo en el tamaño del ejecutable. Sin embargo, si T es un tipo de valor, el tamaño del ejecutable se verá afectado. Este comportamiento es el mismo para los backends de scripting Mono e IL2CPP. Si quiere saber más, siga leyendo, ¡es hora de profundizar en algunos detalles de la aplicación!

La configuración

Estaré usando Unity 5.0.2p1 en Windows, y construyendo para la plataforma WebGL. He activado la opción "Reproductor de desarrollo" en los ajustes de compilación, y la opción "Activar excepciones" tiene el valor "Ninguna". El código del script para este post comienza con un método controlador para crear instancias de los tipos genéricos que investigaremos:

public void DemonstrateGenericSharing() {
var usesAString = new GenericType<string>();
var usesAClass = new GenericType<AnyClass>();
var usesAValueType = new GenericType<DateTime>();
var interfaceConstrainedType = new InterfaceConstrainedGenericType<ExperimentWithInterface>();
}

A continuación, definimos los tipos utilizados en este método:

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();
}
}

Y todo el código está anidado en una clase llamada HelloWorld derivada de MonoBehaviour.

Si visualiza la línea de comandos de il2cpp.exe, observe que no contiene la opción --enable-generic-sharing, descrita en el primer post de esta serie. Sin embargo, el intercambio genérico sigue produciéndose. Ya no es opcional y ahora ocurre en todos los casos.

Uso compartido genérico para tipos de referencia

Empezaremos analizando el caso de uso compartido genérico más frecuente: los tipos de referencia. Dado que todos los tipos de referencia del código gestionado derivan de System.Object, todos los tipos de referencia del código C++ generado derivan del tipo Object_t. Todos los tipos de referencia pueden representarse entonces en código C++ utilizando el tipo Object_t* como marcador de posición. Veremos por qué esto es importante dentro de un momento.

Busquemos la versión generada del método DemonstrateGenericSharing. En mi proyecto se llama HelloWorld_DemonstrateGenericSharing_m4. Buscamos las definiciones de los cuatro métodos de la clase GenericType. Utilizando Ctags, podemos saltar a la declaración del método para el constructor GenericType<string>, GenericType_1__ctor_m8. Observe que esta declaración de método es en realidad una declaración #define, que asigna el método a otro método, GenericType_1__ctor_m10447_gshared.

Volvamos atrás y encontremos las declaraciones de métodos para el tipo GenericType<AnyClass>. ¡Si saltamos a la declaración del constructor, GenericType_1__ctor_m9, podemos ver que también es una declaración #define, mapeada a la misma función, GenericType_1__ctor_m10447_gshared!

Si saltamos a la definición de GenericType_1__ctor_m10447_gshared, podemos ver en el comentario de código de la definición del método que este método corresponde al nombre del método gestionado HelloWorld/GenericType`1<System.Object>::.ctor(). Este es el constructor para el tipo GenericType<object>. Este tipo se denomina tipo totalmente compartido, lo que significa que dado un tipo GenericType<T>, para cualquier T que sea un tipo de referencia, la implementación de todos los métodos utilizará esta versión, donde T es objeto.

Mire justo debajo del constructor en el código generado, y debería ver el código C++ para el método 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;
}
}

En los dos lugares en los que se utiliza el parámetro genérico T (el tipo de retorno y el tipo del único argumento gestionado), el código generado utiliza el tipo Object_t*. Dado que todos los tipos de referencia pueden representarse en el código generado mediante Object_t*, podemos llamar a esta implementación de método único para cualquier T que sea un tipo de referencia.

En la segunda entrada del blog de esta serie (sobre código generado), mencionamos que todas las definiciones de métodos son funciones libres en C++. La utilidad il2cpp.exe no genera métodos anulados en C# utilizando la herencia C++. Sin embargo, il2cpp.exe sí utiliza la herencia C++ para los tipos. Si buscamos en el código generado la cadena "AnyClass_t" encontraremos la representación en C++ del tipo AnyClass de C#:

struct  AnyClass_t1  : public Object_t
{
};

Dado que AnyClass_t1 deriva de Object_t, podemos pasar un puntero a AnyClass_t1 como argumento a la función GenericType_1_UsesGenericParameter_m10449_gshared sin problemas.

Pero, ¿qué pasa con el valor de retorno? No podemos devolver un puntero a una clase base donde se espera un puntero a una clase derivada, ¿verdad? Eche un vistazo a la declaración del método 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)

El código generado está en realidad casteando el valor de retorno (tipo Object_t*) al tipo derivado AnyClass_t1*. Así que aquí IL2CPP está mintiendo al compilador de C++ para evitar el sistema de tipos de C++. Dado que el compilador de C# ya ha hecho cumplir que ningún código en UsesGenericParameter hace nada irrazonable con el tipo T, entonces IL2CPP está seguro de mentir al compilador de C++ aquí.

Reparto genérico con restricciones

Supongamos que queremos permitir que se llame a algunos métodos en un objeto de tipo T. ¿No lo impedirá el uso de Object_t*, ya que no tenemos muchos métodos sobre System.Object? Sí, esto es correcto. Pero primero tenemos que expresar esta idea al compilador de C# mediante restricciones genéricas.

Eche un vistazo de nuevo en el código del script de este post al tipo llamado InterfaceConstrainedGenericType. Este tipo genérico utiliza una cláusula where para exigir que su tipo T derive de una interfaz dada, AnswerFinderInterface. Esto permite llamar al método ComputeAnswer. Recordemos de la anterior entrada del blog sobre la invocación de métodos que las llamadas a métodos de interfaz requieren una búsqueda en una estructura vtable. Dado que el método FindTheAnswer realizará una llamada directa a una función en la instancia restringida del tipo T, el código C++ puede seguir utilizando la implementación del método totalmente compartido, con el tipo T representado por Object_t*.

Si empezamos en la implementación de la función HelloWorld_DemonstrateGenericSharing_m4, y luego saltamos a la definición de la función InterfaceConstrainedGenericType_1__ctor_m11, podemos ver que este método es de nuevo un #define, mapeando a la función InterfaceConstrainedGenericType_1__ctor_m10456_gshared. Si buscamos justo debajo de esa función la implementación de la función InterfaceConstrainedGenericType_1_FindTheAnswer_m10458_gshared, veremos que, efectivamente, se trata de la versión totalmente compartida de la función, que toma un argumento Object_t*. Llama a la función InterfaceFuncInvoker0::Invoke para realizar realmente la llamada al método gestionado 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;
}
}

Todo esto encaja en el código C++ generado porque IL2CPP trata todas las interfaces gestionadas como System.Object. Esta es una regla general útil para ayudar a entender el código generado por il2cpp.exe también en otros casos.

Restricciones con una clase base

Además de las restricciones de interfaz, C# permite que las restricciones sean una clase base. IL2CPP no trata a todas las clases base como System.Object, así que ¿cómo funciona la compartición genérica para las restricciones de las clases base?

Dado que las clases base son siempre tipos de referencia, IL2CPP utiliza la versión totalmente compartida de los métodos genéricos para estos tipos. Cualquier código que necesite utilizar un campo o llamar a un método del tipo restringido realiza un cast en C++ al tipo adecuado. De nuevo, aquí confiamos en que el compilador de C# aplique correctamente la restricción genérica, y mentimos al compilador de C++ sobre el tipo.

Compartición genérica con tipos de valor

Volvamos ahora a la función HelloWorld_DemonstrateGenericSharing_m4 y veamos la implementación para GenericType<DateTime>. El tipo DateTime es un tipo de valor, por lo que GenericType<DateTime> no se comparte. Podemos saltar a la declaración del constructor para este tipo, GenericType_1__ctor_m10. Ahí vemos un #define, como en los otros casos, pero el #define mapea a la función GenericType_1__ctor_m10_gshared, que es específica de la clase GenericType<DateTime>, y no es utilizada por ninguna otra clase.

Pensar conceptualmente en el reparto genérico

La aplicación del reparto genérico puede ser difícil de entender y de seguir. El propio espacio del problema está plagado de casos patológicos (por ejemplo, el patrón de plantilla curiosamente recurrente). Puede ser útil reflexionar sobre algunos conceptos:

- Cada implementación de método en un tipo genérico es compartida

- Algunos tipos genéricos sólo comparten implementaciones de métodos consigo mismos (por ejemplo, los tipos genéricos con un parámetro genérico de tipo valor, GenericType más arriba)

- Los tipos genéricos con un parámetro genérico de tipo de referencia son totalmente compartidos - siempre utilizan la implementación con System.Object para todos los parámetros de tipo.

- Los tipos genéricos con dos o más parámetros de tipo pueden compartirse parcialmente si al menos uno de esos parámetros de tipo es un tipo de referencia.

La utilidad il2cpp.exe siempre genera las implementaciones de métodos totalmente compartidos para cualquier tipo genérico. Genera otras implementaciones de métodos sólo cuando se utilizan.

Compartir métodos genéricos

Al igual que pueden compartirse las implementaciones de métodos en tipos genéricos, también pueden compartirse las implementaciones de métodos genéricos. En el código del script original, observe que el método UsesDifferentGenericParameter utiliza un parámetro de tipo diferente al de la clase GenericType. Cuando miramos las implementaciones de métodos compartidos para la clase GenericType, no vimos el método UsesDifferentGenericParameter. Si busco "UsesDifferentGenericParameter" en el código generado, veo que la implementación de este método se encuentra en el archivo 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;
}
}

Observe que ésta es la versión totalmente compartida de la implementación del método, que acepta el tipo Object_t*. Aunque este método está en un tipo genérico, el comportamiento sería el mismo para un método genérico en un tipo no genérico también. Efectivamente, il2cpp.exe intenta generar siempre el menor código posible para las implementaciones de métodos que implican parámetros genéricos.

Conclusión

El uso compartido de genéricos ha sido una de las mejoras más importantes del backend de scripts IL2CPP desde su lanzamiento inicial. Permite que el código C++ generado sea lo más pequeño posible, compartiendo implementaciones de métodos cuando no difieren en su comportamiento. A medida que busquemos seguir reduciendo el tamaño de los binarios, trabajaremos para aprovechar más oportunidades de compartir las implementaciones de los métodos.

En el próximo post, exploraremos cómo se generan las envolturas p/invoke, y cómo se marshalan los tipos desde el código gestionado al nativo. Podremos ver el coste de marshaling de varios tipos, y depurar problemas con el código de marshaling.