IL2CPP 深入讲解:泛型共享

IL2CPP 深入讲解:泛型共享

小玉小玉

IL2CPP 深入讲解:泛型共享

IL2CPP INTERNALS: GENERIC SHARING IMPLEMENTATION

作者:JOSH PETERSON

翻译:Bowie

这是 IL2CPP深入讲解的第五篇。在上一篇中,我们有说到由IL2CPP产生的C++代码是如何进行各种不同的方法调用的。而在本篇中,我们则会讲解这些C++方法是如何被实现的。特别的,我们会对一个非常重要的特性 -- 泛型共享 加以诠释。泛型共享使得泛型函数可以共享一段通用的代码实现。这对于减少由IL2CPP产生的可执行文件的尺寸有非常大的帮助。

需要指出的是泛型共享不是一个新鲜事物,Mono和.Net运行时库(译注:这里说的.Net运行时库指的是微软官方的实现)也同样采用泛型共享技术。IL2CPP起初并不支持泛型共享,我们到最近的改进版中才使得泛型共享机制足够的健壮并能使其带来好处。既然il2cpp.exe产生C++代码,我们可以分析这些代码来了解泛型共享机制是如何实现的。

我们将探索对于引用类型或者值类型而言,泛型函数在何种情况下会进行泛型共享,而在何种情况下不会。我们也会讨论泛型参数是如何影响到泛型共享机制的。

请记住,所有以下的讨论都是细节上的实现。这里的讨论和所涉及的代码很有可能在未来发生改变。只要有可能,我们都会对这些细节进行探讨。

什么是泛型共享

思考一下如果你在C#中写一个List<T>的实现。这个List的实现会根据T的类型不同而不同么?对于List的Add函数而言,List<string>和List<object>会是一样的代码么?那如果是List<DateTime>呢?

实际上,泛型的强大之处在于这些C#的实现都是共享的,List<T>泛型类可以适用于任何的T类型。但是当C#代码转换成可执行代码,比如Mono的汇编代码或者由IL2CPP产生的C++代码的时候会发生什么呢?我们能在这两个层面上也实现Add函数的代码共享么?

答案是肯定的,我们能在大多数的情况下做到共享。正如本文后面将要讨论的:泛型函数的泛型共享与否主要取决于这个T的大小如何。如果T是任何的引用类型(像string或者是object),那T的尺寸永远是一个指针的大小。如果T是一个值类型(比如int或者DateTime),大小会不一样,情况也会相对复杂。代码能共享的越多,那么最终可执行文件的尺寸就越小。

在Mono中实现了泛型共享的大师:Mark Probst,有一个关于Mono如何进行泛型共享的很棒的系列文章
。我们在这里不会对Mono的实现深入到那么的底层中去。相反,我们讨论IL2CPP是怎么做的。希望这些信息可以帮助到你们去更好的理解和分析你们项目最终的尺寸。

IL2CPP的共享是啥样子的?

就目前而言, 当SomeGenericType<T>中的T是下面的情况时,IL2CPP会对泛型函数进行泛型共享:

任何引用类型(例如:string,object,或者用户自定义的类)
任何整数或者是枚举类型

当T是其他值类型的时候,IL2PP是不会进行泛型共享的。因为这个时候类型的大小会很不一样。

实际的情况是,对于新加入使用的SomeGenericType<T>,如果T是引用类型,那么它对于最终的可执行代码的尺寸几乎是没有影响的。然而,如果新加入的T是直类型,那就会影响到尺寸。这个逻辑对于Mono和IL2CPP都适用。如果你想知道的更多,请继续往下读,到了说实现细节的时候了!

项目搭建

这里我会在Windows上使用Unity 5.0.2p1版本,并且将平台设置到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。通过CTags工具,我们可以跳到GenericType<string>的构造函数:GenericType_1__ctor_m8。请注意,这个函数实际上是一个#define定义,这个#define又把我们引向另一个函数:GenericType_1__ctor_m10447_gshared。

让我们往回跳两次(译注:使用CTags工具,代码关系往回回溯两次)。可以找到GenericType<AnyClass> 类型的申明。如果我们对其构造函数GenericType_1__ctor_m9进行追溯,我们同样能够看到一个#define定义,而这个定义最终引向了同一个函数:GenericType_1__ctor_m10447_gshared。

如果我们跳到GenericType_1__ctor_m10447_gshared的定义,我们能从代码上面的注释得出一个信息:这个C++函数对应的是C#中的HelloWorld::GenericType`1<System.Object>::.ctor()。这是GenericType<object>类型的标准构造函数。这种类型称之为全共享类型,意味着对于GenericType<T>而言,只要T是引用类型,所有的函数都使用同一份代码。

在这个构造函数往下一点,你应该能够看到UsesGenericParameter函数的C++实现:

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的地方(分别在返回值和函数参数中),C++代码都使用了Object_t。因为任何引用类型都能在C++代码中被Object_t所表示,所以我们也就能够对于任何引用T,调用相同的UsesGenericParameter函数。

在系列的第二篇 中,我们有提到过在C++代码中,所有的函数都是非成员函数。il2cpp.exe不会因为在C#有重载函数而在C++中使用继承。在是在类型的处理上却有所不同:il2cpp.exe确实会在类型的处理上使用继承。如果我们查找代表C#中AnyClass类的C++类型AnyClass_t,会发现如下代码:

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)

C++代码其实是把返回值(Object_t类型)强制转换成了AnyClass_t1类型。因此在这里IL2CPP对C++编译器使了个障眼法。因为C#的编译器会保证UsesGenericParameter中的T是可兼容的类型,因此IL2CPP这里的强转是安全的。

带泛型约束的共享

假设如果我们想要让T能够调用一些特定的函数。因为System.Object只有最基本的一些函数而不存在你想要使用的任何其他函数,那么在C++中使用Object_t*就会造成障碍了,不是嘛?是的,你说的没错!但是我们有必要在此解释一下C#编译器中的泛型约束的概念。

让我们再仔细看看InterfaceConstrainedGenericType的C#代码。这个泛型类型使用了一个‘where’关键字以确保T都是从一个特定的接口(Interface):AnswerFinderInterface继承过来的。这就使得调用ComputeAnswer 函数成为可能。大家还记得上一篇博文中我们讨论的吗:当调用一个接口函数的时候,我们需要在虚表(vtable structure)中进行查找。因为FindTheAnswer可以从约束类型T中被直接调用,所以C++代码依然能够使用全共享的实现机制,也就是说T由Object_t*所代表。

如果我们由HelloWorld_DemonstrateGenericSharing_m4function的实现开始,跳到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把所有的C#中的接口(Interface)都当作System.Object一样处理,其所产生的C++代码也就能说得通了。这个规则在C++代码的其他情况中也同样适用。

基类的约束

除了对接口(Interface)进行约束,C#还允许对基类进行约束。IL2CPP并不是把所有的基类都当成System.Object处理。那么对于有基类约束的泛型共享又是怎样的呢?

因为基类肯定都是引用类型,所以IL2CPP还是使用全共享版本的泛型函数来处理这些受约束的类型。任何有用到约束类型中特定成员变量或者成员函数的地方都会被C++代码进行强制类型转换。再次强调,在这里我们仰仗C#编译器强制检查这些约束类型都符合转换要求,我们就可以放心的蒙蔽C++编译器了。

值类型的泛型共享

让我们回到HelloWorld_DemonstrateGenericSharing_m4函数看下 GenericType<DateTime>的实现。DateTime是个值类型,因此GenericType<DateTime>不会被共享。我们可以看看这个类型的构造函数GenericType_1__ctor_m10。这个函数是GenericType<DateTime>所特有的,不会被其他类使用。

系统概念的思考泛型共享

泛型共享的实现是比较难以理解的,问题的本身在于它自己充满着各种不同的特殊情况(比如:奇特的递归模板模式
)(译注:这是C++中的一个概念,简单的说就是诸如:class derived:public base<derived>这样的形式,使用派生类本身来作为模板参数的特化基类。目的是在编译期通过基类模板参数来得到派生类的行为,由于是编译期绑定而不是运行期绑定,可以增加执行效率)。

从以下几点着手可以帮助我们很好的思考泛型共享:

泛型类中的函数都是共享的
有些泛型类只和他们自己共享代码(比如泛型参数是值的泛型类)
泛型参数是引用的泛型类总是全共享-他们总是使用System.Object来适用于各种参数类型
有两个或者更多泛型参数的泛型类能够被部分共享。前提是在泛型参数中至少有一个参数是引用类型

il2cpp.exe总是先产生全共享代码。其他特别的代码在有用到时才会特别单独产生。

泛型函数的共享

泛型类可以被共享,泛型函数同样也可以。在我们原始的C#示例代码中,有一个UsesDifferentGenericParameter函数,这个函数用了另外一个泛型参数而不是GenericType。我们在GenericType类的C++代码中查找不到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 封装代码是如何产生的。以及托管代码中的类型数据是如何转换到原生代码(C++代码)中的。我们将检视各种类型转换所需要的开销,并且尝试调试有问题的数据转换代码。

建了一个专题:点我
文章被以下专栏收录
还没有评论
推荐阅读