1. 程式人生 > >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++程式碼)中的。我們將檢視各種型別轉換所需要的開銷,並且嘗試除錯有問題的資料轉換程式碼。



作者:IndieACE
連結:https://www.jianshu.com/p/eb34f911f063
來源:簡書
簡書著作權歸作者所有,任何形式的轉載都請聯絡作者獲得授權並註明出處。