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++代碼)中的。我們將檢視各種類型轉換所需要的開銷,并且嘗試調試有問題的數據轉換代碼。