2015年10月8日 星期四

深度技術文章-IL2CPP 深入講解系列介紹之5:泛型共享

作者:JOSH PETERSON
原文:http://blogs.unity3d.com/2015/06/16/il2cpp-internals-generic-sharing-implementation/
翻譯:IndieAce論壇-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」。在這篇文章的例子程式碼中,有一個驅動函數在一開始就把我們要分析的泛型類型的實例創建好。
1
2
3
4
5
6
public void DemonstrateGenericSharing() {
  var usesAString = new GenericType<string>();
  var usesAClass = new GenericType<AnyClass>();
  var usesAValueType = new GenericType<DateTime>();
  var interfaceConstrainedType = new InterfaceConstrainedGenericType<ExperimentWithInterface>();
}
接下來我們定義在這個函數中用到的泛型類:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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++實現:
1
2
3
4
5
6
7
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,會發現如下程式碼:
1
2
3
struct  AnyClass_t1  : public Object_t
{
};

因為AnyClass_t1是從Object_t繼承而來,我們就能合法的傳遞一個 AnyClass_t1的指針給GenericType_1_UsesGenericParameter_m10449_gshared函數。

那函數的返回值又是個什麼情況呢?如果函數需要返回一個繼承類的指針,那我們就不能返回它的基類對吧。那就讓我們看看GenericType<AnyClass>::UsesGenericParameter的方法:
1
#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程式碼。

1
2
3
4
5
6
7
8
9
10
11
12
13
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;
  }
}

因為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中:
1
2
3
4
5
6
7
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++程式碼)中的。我們將檢視各種類型轉換所需要的開銷,並且嘗試除錯有問題的數據轉換程式碼。

沒有留言:

張貼留言

著作人