IL2CPP Internals:ジェネリック共有の実装

なお、ジェネリック共有は新しいアイデアではなく、Mono や .Net のランタイムでもジェネリック共有が採用されています。当初、IL2CPP はジェネリック共有を行っていませんでした。それが最近改良され、IL2CPP はより強固で有益なものになりました。il2cpp.exe は C++ のコードを生成するので、メソッドのどの部分の実装が共有されているのかがわかります。
ここでは、参照型と値型でジェネリックメソッドの実装がどのように共有されているか(あるいは共有されていないか)を調べてみましょう。また、ジェネリックなパラメーターの制約がジェネリック共有にどのような影響を与えるかについても調べます。
なお、本シリーズで取り上げた内容はすべて実装に関するものです。この記事で説明されているトピックやコードに対しては、将来的に変更が加わる可能性があります。私たちは、可能な限りこのような詳細を明らかにし、議論したいと思っています。
ジェネリック共有とは
List<T> クラスの実装を C# で書いているとします。その実装は、T の型によって変わるでしょうか。List<string> と List<object> の両方に、同じ実装の Add メソッドを使えるでしょうか。List<DateTime> ではどうでしょうか。
実のところ、ジェネリックでできるのはこれらの C# での実装を共有できるというだけのことで、ジェネリッククラス List<T> は、どの T でも動作します。しかし、List が C# からアセンブリコード(Mono が行う)や C++ コード(IL2CPP が行う)のような実行可能なものに翻訳された場合はどうなるのでしょうか。Add メソッドの実装はまだ共有可能でしょうか。
ほとんどの場合は共有することができます。この記事で見ていくように、ジェネリックメソッドの実装を共有できるかどうかは、完全に T 型のサイズに依存します。T が何らかの参照型(string や object など)の場合、そのサイズは常にポインターのサイズになります。T が値型(int や DateTime など)の場合、そのサイズは可変で、もう少し複雑になります。共有できるメソッド実装が多ければ多いほど、結果的に実行コードは小さくなります。
Mono のジェネリック共有を実装した開発者である Mark Probst 氏は、Mono がジェネリック共有をどのように行うかについて解説した一連の素晴らしい記事を書いています。ジェネリック共有については、ここではそこまで深くは触れません。代わりに、IL2CPP がどのように、どのような場合にジェネリック共有を行うかを見ていきます。この情報が、皆さんがプロジェクトの実行可能ファイルのサイズをよりよく分析し、理解するうえで役立つことを願っています。
IL2CPP が共有するもの
現在 IL2CPP は、T が以下の型のジェネリック型 SomeGenericType<T> について、ジェネリックメソッドの実装を共有します。
- 任意の参照型(例:
string、object、またはユーザー定義クラス) - 任意の整数型または列挙型
IL2CPP では、T が値型の場合、各値型のサイズが(そのフィールドのサイズに基づいて)異なるため、ジェネリックメソッドの実装を共有しません。
実用上、T が参照型の SomeGenericType<T> を新しく使うようにしても、実行可能ファイルのサイズへの影響は最小限であるということです。ただし、T が値型の場合は、実行可能ファイルのサイズに無視できない影響を及ぼします。この挙動は、Mono と IL2CPP のスクリプトバックエンドの両方で同じです。もっと詳しく知りたい方はぜひ続きをお読みください。以下では、実装の詳細について掘り下げていきます。
セットアップ
Windows の Unity 5.0.2p1 を使用し、WebGL プラットフォーム用にビルドします。ビルド設定で「Development Player」を有効にし、「Enable Exceptions」の値を「None」に設定しました。
この記事のスクリプトコードは、これから調査するジェネリック型のインスタンスを作成するドライバーメソッドから始まります。
public void DemonstrateGenericSharing() {
var usesAString = new GenericType(); var usesAClass = new GenericType(); var usesAValueType = new GenericType(); var interfaceConstrainedType = new InterfaceConstrainedGenericType(); }
次に、このメソッドで使う型を定義します。
class GenericType<T> { public T UsesGenericParameter(T value) { return value; } public void DoesNotUseGenericParameter() {} public T2 UsesDifferentGenericParameter<T2>(T2 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 という名前になっています。GenericType クラスの 4 つのメソッドのメソッド定義を探しています。Ctags を使って、GenericType<string> コンストラクターである GenericType_1__ctor_m8 のメソッド宣言にジャンプすることができます。なお、このメソッド宣言は、実際には #define 文であり、このメソッドを別のメソッド GenericType_1__ctor_m10447_gshared にマッピングしています。
大きく戻って、GenericType<AnyClass> 型のメソッド宣言を探してみましょう。コンストラクターの宣言 GenericType_1__ctor_m9 に飛ぶと、これも #define 文で、同じ関数 GenericType_1__ctor_m10447_gshared にマッピングされていることがわかります。
GenericType_1__ctor_m10447_gshared の定義に目を移せば、メソッド定義のコードコメントから、このメソッドがマネージドメソッド名 HelloWorld/GenericType`1<System.Object>::.ctor() に対応していることがわかります。これは、GenericType<object> 型のコンストラクターです。この型は完全共有された型と呼ばれ、GenericType<T> が与えられた場合、参照型である任意の T に対して、すべてのメソッドの実装はこのバージョンを使用し、T は object であることを意味します。
生成されたコードのコンストラクターのすぐ下を見ると、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 が使用されている両方の場所(戻り値の型と単一のマネージド引数の型)で、生成されたコードは Object_t* 型を使用しています。生成されたコードでは、すべての参照型は Object_t* で表現できるので、参照型である任意の T に対して、この単一のメソッド実装を呼び出すことができます。
このシリーズの第 2 回のブログ記事(生成されたコードに関する内容)では、C++ ではすべてのメソッド定義がフリー関数であることを述べました。il2cpp.exe ユーティリティは、C++ の継承を使用する C# でオーバーライドされたメソッドを生成しません。しかし、il2cpp.exe は、型に対して C++ の継承を使用しています。生成されたコードで文字列「AnyClass_t」を検索すると、C# 型 AnyClassの C++ 表現が見つかります。
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)
生成されたコードは、実際には、戻り値(型 Object_t*)を派生型 AnyClass_t1* にキャストしています。つまり、ここでは IL2CPP は C++ の型システムを避けるために C++ コンパイラーに嘘をついているのです。C# コンパイラーが UsesGenericParameter のコードが T の型に対して不合理なことをしないことを強制してくれているので、IL2CPP はここで C++ コンパイラーに嘘をついても大丈夫です。
制約を伴うジェネリック共有
T 型のオブジェクトに対して、いくつかのメソッドの呼び出しを許可したいとします。Object_t* を使うと、System.Object に対するメソッドがあまりないので、これが妨げられてしまわないでしょうか。確かにそれはその通りです。しかし、私たちはまず、ジェネリック制約を使ってこのアイデアを C# コンパイラーに伝える必要があります。
この記事のスクリプトコードの中の、InterfaceConstrainedGenericType という名前の型をもう一度見てみましょう。このジェネリック型は、where 節を使って、型 T が与えられたインターフェース AnswerFinderInterface から派生したものであることを要求します。これにより、ComputeAnswer メソッドを呼び出すことができます。メソッド呼び出しについて解説した前回のブログ記事で、インターフェースメソッドの呼び出しには vtable 構造体のルックアップが必要であることを思い出してください。FindTheAnswer メソッドは、T 型の制約されたインスタンスに対して直接関数呼び出しを行うため、C++ コードでは、T 型を Object_t* で表現して、完全共有されたメソッドの実装を使用することができます。
HelloWorld_DemonstrateGenericSharing_m4 関数の実装から始めて、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; } }
生成された C++ コードのコードでは、このすべてがまとまっています。これは、IL2CPP がすべてのマネージドインターフェースを System.Object のように扱うためです。これは、他のケースでも il2cpp.exe が生成するコードを理解するのに役立つ経験則です。
基底クラスを持つ制約
インターフェイス制約に加えて、C# では基底クラスとなる制約が認められています。IL2CPP はすべての基底クラスを System.Object のようには扱いません。そのため、基底クラスの制約に対してジェネリック共有はどのように機能するのでしょうか。
基底クラスは常に参照型であるため、IL2CPP はこれらの型のジェネリックメソッドの完全共有バージョンを使用します。制約された型のフィールドを使用したり、メソッドを呼び出したりする必要があるコードは、C++ で適切な型へのキャストを行います。ここでも、C# コンパイラーがジェネリック制約を正しく適用してくれることに依存して、C++ コンパイラーには型について嘘をついています。
値型を持つジェネリック共有
ここで、HelloWorld_DemonstrateGenericSharing_m4 関数に戻って、GenericType<DateTime> の実装を見てみましょう。DateTime 型は値型なので、GenericType<DateTime> は共有されません。この型のコンストラクターである、GenericType_1__ctor_m10 の宣言にジャンプすることができます。ここでは、他のケースと同様に #define がありますが、#define は GenericType_1__ctor_m10_gshared 関数に対応しています。これは GenericType<DateTime> クラスに固有のもので、他のクラスでは使用されません。
ジェネリック共有を概念的に考える
ジェネリック共有の実装は、理解したり追いかけたりするのが難しい場合があります。問題空間自体が病的なケースに満ちています(例:奇妙に再帰したテンプレートパターン)。いくつかのコンセプトを考えてみるといいでしょう。
- ジェネリック型のすべてのメソッドの実装が共有されます
- ジェネリック型の中には、自分自身としかメソッドの実装を共有しないものがあります(例:値型のジェネリックパラメーターを持つ、上で示したジェネリック型
GenericType) - 参照型のジェネリックパラメーターを持つジェネリック型は完全共有になります。すべての型パラメーターに対して、常に
System.Objectによる実装を使用します。 - 2 つ以上の型パラメーターを持つジェネリック型は、それらの型パラメーターの少なくとも 1 つが参照型である場合、部分的に共有されることがあります。
il2cpp.exe ユーティリティは、どのようなジェネリック型に対しても、常に完全共有メソッドの実装を生成します。他のメソッドの実装は、それが使用されるときにのみ生成されます。
ジェネリックメソッドの共有
ジェネリック型のメソッド実装が共有できるように、ジェネリックメソッドのメソッド実装も共有できます。オリジナルのスクリプトコードでは、UsesDifferentGenericParameter メソッドが、GenericType クラスとは異なる型のパラメーターを使用していることに注目してください。GenericType クラスの共有メソッドの実装を見てみると、UsesDifferentGenericParameter メソッドが見当たりませんでした。生成されたコードで「UseDifferentGenericParameter」を検索すると、このメソッドの実装が 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 スクリプティングバックエンドの最初のリリース以来、最も重要な改良点の 1 つです。これにより、生成される C++ コードは可能な限り小さくなり、動作に違いのないメソッドの実装を共有することができます。今後もバイナリサイズを小さくしていく活動を進めていく中で、メソッドの実装について解説する機会を増やしていきたいと考えています。
次回の記事では、p/invoke ラッパーの生成方法と、マネージドコードからネイティブコードへの型のマーシャリング方法についてご紹介します。様々な型をマーシャリングする際のコストを確認したり、マーシャリングコードの問題をデバッグしたりすることができます。
