この記事は ILPostProcessor 入門シリーズの第4回です。
IL におけるメソッドボディ以外の部分を「メタデータ」と呼びます。メタデータには型やメソッドなどの情報が含まれ、ILPostProcessor ではメタデータを編集することもできます。
アセンブリとモジュール
.NET において「アセンブリ」は複数の型定義を束ねた単位として意識されますが、実はアセンブリと型定義の間には、もう一つ「モジュール」という単位が存在します。アセンブリには複数のモジュールを内包することができ、型定義は直接的にはモジュールによって内包されます。普段目にするアセンブリは、大抵は一つの「メイン」モジュールのみを持っているアセンブリだったりします。
AssemblyDefinition assembly = /* */; foreach (var module in assembly.Modules) // moduleは大抵一つ (AssemblyDefinition.MainModuleでもアクセス可能) foreach (var type in module.Types) foreach (var method in type.Methods) { }
実際のところ、複数のモジュールを持つアセンブリというのはほぼ全く使用されていないので(私は一度も見たことがありません)、普通の C# プログラミングにおいてモジュールを意識することはほとんどありません。ただ、.NET の仕様としてモジュールは存在していて、Mono.Cecil でメタデータを扱う時も概念として登場します。ほとんど「アセンブリ=モジュール」と考えて問題ありませんので、覚えておきましょう。
TypeDefinition
と TypeReference
Mono.Cecil において、型は TypeDefinition
と TypeReference
によって表現されます。
TypeDefinition
はある型の定義を表すクラスで、1つの型を表す TypeDefinition
のインスタンスは1つです。TypeDefinition
にはフィールドやメソッド、プロパティなど、型に関する様々な情報が保存されています。
対して、TypeReference
は TypeDefinition
への参照を表すクラスで、そのインスタンスは様々な箇所で自由に作成されます。前回記事で登場した initobj
命令など、IL 命令のオペランドに型を指定する場合はTypeReference
を使用します。
var initobj = Instruction.Create(OpCodes.Initobj, /* TypeReference */);
TypeReference
は様々な方法で作ることができます。以下にいくつかの例を紹介します。
TypeSystem
からビルトイン型の TypeReference
を取得する
int
や string
などのビルトイン型の TypeReference
は、Module.TypeSystem
から得られるTypeSystem
インスタンスを通して簡単に取得することができます。
TypeReference
から TypeDefinition
を取得する
TypeReference.Resolve()
を使用すると、TypeReference
から TypeDefinition
を取得することができます。TypeReference
からは型の名前などの最低限の情報しか取れませんが、TypeDefinition
にすることで型のメンバなどの情報をフルに得ることができます。
TypeDefinition typeDef = module.TypeSystem.Int32.Resolve(); var fields = typeDef.Fields; var methods = typeDef.Methods: var attrs = typeDef.CustomAttributes;
TypeDefinition
から TypeReference
を取得する
実は、TypeDefinition
自体が TypeReference
の派生クラスなので、TypeDefinition
をそのまま TypeReference
にアップキャストできます。
注意が必要な点として、別のアセンブリを参照元とする TypeReference
を使用する際は、一度 ModuleDefinition.ImportReference()
というメソッドを通す必要があります。
var typeRef = module.ImportReference(typeDef);
TypeReference
には参照元のモジュールの情報が含まれており、異なるモジュールを参照元とする TypeReference
をそのまま使うと、アセンブリを書き出す際にエラーになります。 ModuleDefinition.ImportReference()
を通すと、そのアセンブリに所属する TypeReference
のインスタンスを返してくれます。
無から TypeReference
を作成する
コンストラクタから TypeReference
のインスタンスを作成できます。
var type = new TypeReference("Namespace.To.Class", "C", null, new AssemblyNameReference("Name.Of.Referenced.Assembly")); type = module.ImportReference(type);
BCLの名前について
System
名前空間など、.NET の BCL (Base Class Library) に含まれる型を参照する際は注意が必要です。これらの型はターゲットとするプロファイルによって指定するべきアセンブリ名が異なります。
- .NET Framework:
mscorlib
- .NET Standard:
netstandard
- .NET:
System.Runtime
またはSystem.Private.CoreLib
Unity の場合、Player Settings の API Compatibility Level を .NET Framework にすると mscorlib
, .NET Standard にすると netstandard
になります。ただし、エディタ専用のアセンブリでは、API Compatibility Level に関わらず mscorlib
になるという仕様があったりするので、注意が必要です。
これらの問題に対処するためには、TypeSystem.CoreLibrary
を使用して、そのアセンブリで使用されている BCL を取得するのがおすすめです。
💡 Mono.Cecilに関する記事ではよく
ModuleDefinition.ImportReference(System.Type)
というオーバーロードが使用されていますが、Unity での使用はあまりおすすめしません。
var typeRef = module.ImportReference(typeof(System.Console));
このオーバーロードでは、渡されたSystem.Typeの属するアセンブリが参照先のアセンブリ名として使用されます。これは ILPostProcessor の実行環境におけるアセンブリ名になるため、上記のような BCL の違いを反映することができず、実行時にエラーとなる可能性があります。
TypeSpecification
とジェネリクス
配列やポインタなど、ある型をベースにしてアドホックに作成される類の型を表現するには、 TypeSpecification
というクラスから派生するクラス群を使用します。TypeSpecification
もTypeReference
の派生型です。
TypeSpecification
には次のような派生型があります。
ArrayType
- 配列
ByReferenceType
- C# における
ref
,in
,out
- C# における
FunctionPointerType
- 関数ポインタ型 (
delegate*
)
- 関数ポインタ型 (
GenericInstanceType
- 型引数が与えられたジェネリック型
PinnedType
fixed
でアドレスを固定する際に使用される型
PointerType
- ポインタ型 (
T*
)
- ポインタ型 (
RequiredModifierType
modreq
が付与された型
OptionalModifierType
modopt
が付与された型
SentinelType
- 可変長引数を表現するための型。ほぼ使用されません
例えば、int[]
は次のようなコードで表現できます。
var intArray = new ArrayType(module.TypeSystem.Int32);
int[]
における int
のように、TypeSpecification
のベースとなる型を Element Type と呼び、TypeSpecification.ElementType
で取得できます。
var elementType = intArray.ElementType; // int
マネージドポインタ型 (ByReferenceType
)
C# における ref ローカル変数、ref
, in
, out
引数などの型を表現するには ByReferenceType
を使用します。スタック上ではマネージドポインタ (&
)にあたります。
var byRefInt = new ByReferenceType(module.TypeSystem.Int32); // ref int
ByReferenceType
自体は、その型が in
なのか out
なのか、あるいは ref readonly
なのかという情報を持っていません。これは状況によって異なる方法で表現されます。
メソッドの引数の場合、MethodDefinition.Parameters
の要素である ParameterDefinition
の IsIn
, IsOut
プロパティで判定できます。
var parameter = method.Parameters[i]; Console.WriteLine(parameter.IsIn); Console.WriteLine(parameter.IsOut);
関数ポインタの場合、同様に FunctionPointerType.Parameters
の要素として ParameterDefinition
が取れますが、こちらの IsIn
/ IsOut
は正しい情報を返さないので注意が必要です。これは関数ポインタのシグネチャの表現方法がちょっと特殊であることが関係しています。関数ポインタのIL表現を見てみましょう。
public unsafe static class C { // .field private static method void *( // int32&, // int32& modreq([System.Runtime]System.Runtime.InteropServices.InAttribute), // int32& modreq([System.Runtime]System.Runtime.InteropServices.OutAttribute)) _m static delegate*<ref int, in int, out int, void> _m; }
IL で型名の末尾に &
がついているのは ByReferenceType
であることを意味しています。また、in
や out
の場合は、型の後に modreq
という句と共に属性が追加されています。
💡
modopt
とmodreq
は、シグネチャ内の型に属性を付与できる機能で、C# における Attribute とは異なる仕組みです。
modreq
や modopt
が付いた型は RequiredModifierType
OptionalModifierType
としてさらにラップされます。
var parameter = functionPointerType.Parameters[i]; if (parameter.ParameterType is RequiredModifierType modreqType) { var isIn = modreqType.ModifierType.FullName == "System.Runtime.InteropServices.InAttribute"; var isOut = modreqType.ModifierType.FullName == "System.Runtime.InteropServices.OutAttribute"; }
関数ポインタに限らず、既存のメタデータから取ってきた TypeReference
の種類を判定しようとしたら RequiredModifierType
でラップされていてうまく判定できなかった…という事故が時々起こるので、注意しましょう。
💡 同様の理由で注意が必要な例として
PinnedType
があります。これはfixed
を使用して変数のアドレスを固定する際、固定中の参照を格納するローカル変数の型として使用されます。
ジェネリクス
型引数が与えられたジェネリック型は TypeSpecification
の派生型である GenericInstanceType
として表現されます。
GenericInstanceType
を作成するには、TypeReference
の拡張メソッド MakeGenericInstanceType()
が便利です。
例えば、次のようなコードで List<int>
を作成できます:
var list = module.ImportReference(new TypeReference("System.Collections.Generic", "List`1", null, module.TypeSystem.CoreLibrary)); // List<> var intList = list.MakeGenericInstanceType(module.TypeSystem.Int32); // List<int>
ジェネリック型においても、型引数が確定されていない状態では TypeReference
が使われ、型引数が確定された状態では GenericInstanceType
が使われる、という関係になります。
GenericInstanceType.GenericArguments
を使うと、具体的に指定された型引数を取得できます。
また、TypeReference.GenericParameters
を使うと、具体的に指定される前の型引数(T
など)を取得・設定できます。こちらの戻り値は GenericParameter
型のコレクションで、GenericParameter
は TypeReference
の派生型です。
public sealed class GenericParameter : TypeReference, ICustomAttributeProvider, IMetadataTokenProvider;
メソッドへの応用
型と同じように、メソッドにも MethodDefinition
と MethodReference
があります。IL 命令では、例えば call
命令のオペランドとして MethodReference
を指定します。
var methodRef = new MethodReference("NameOfMethod", /* 戻り値の型のTypeReference */, /* メソッドを宣言する型のTypeReference */) { HasThis = true, // インスタンスメソッドかどうか Parameters = { new ParameterDefinition(/* 引数型のTypeReference */), /* ... */ } }; var call = Instruction.Create(OpCodes.Call, methodRef);
同様に MethodSpecification
もありますが、こちらは GenericInstanceMethod
が唯一の派生型です。
var genericInstanceMethod = new GenericInstanceMethod(methodRef) { GenericArguments = { /* 型引数のTypeReference */ /* ... */ } };
メソッドにおけるジェネリクスは、メソッドの属する型に対する型引数と、メソッド自体への型引数の二つを意識する必要があるので複雑です。実際に C# をコンパイルしてどのような表現になるのか確認してみました。
using System; using UnityEngine; public class Example { private void Test() { // ① 非ジェネリッククラスの非ジェネリックメソッド呼び出し // call void C::M() // オペランドの型: MethodDefinition // DeclaringType: TypeDefinition "C" { } // ReturnType: TypeReference "System.Void" { } // Parameters: [ ] C.M(); // ② 非ジェネリッククラスのジェネリックメソッド呼び出し // call U C::M_Generic<string>(U) // オペランドの型: GenericInstanceMethod // DeclaringType: TypeDefinition "C" { } // GenericArguments: [ TypeReference "System.String" { } ] // ReturnType: GenericParameter "U" { } // Parameters: [ GenericParameter "U" { } ] C.M_Generic<string>(default); // ③ ジェネリッククラスの非ジェネリックメソッド呼び出し // call !0 C_Generic`1<int32>::M(!0) // オペランドの型: GenericInstanceMethod // DeclaringType: GenericInstanceType "C_Generic`1" { GenericArguments: [ TypeReference "System.Int32" { } ] } // ReturnType: GenericParameter "T" { } // Parameters: [ GenericParameter "T" { } ] C_Generic<int>.M(default); // ④ ジェネリッククラスのジェネリックメソッド呼び出し // call !!0 C_Generic`1<int32>::M_Generic<string>(!0, !!0) // オペランドの型: GenericInstanceMethod // DeclaringType: GenericInstanceType "C_Generic`1" { GenericArguments: [ TypeReference "System.Int32" { } ] } // GenericArguments: [ TypeReference "System.String" { } ] // ReturnType: GenericParameter "!!0" { } // Parameters: [ GenericParameter "T" { }, GenericParameter "!!0" { } ] C_Generic<int>.M_Generic<string>(default, default); } } public class C { public static void M() { } public static U M_Generic<U>(U u) => default; } public static class C_Generic<T> { public static T M(T t) => default; public static U M_Generic<U>(T t, U u) => default; }
-
📝 IL のテキスト表現では、型引数を
!0
や!!0
のようなフォーマットで書くことがあります。!
は型定義とともに宣言されている型引数で、!!
はメソッドの定義とともに宣言されている型引数です。後に続く数字は型引数のインデックスを示しています。
こうして見てみると、Mono.Cecil におけるシグネチャの表現は、IL テキスト表現のそれと概ね一致しているのが分かります。注目すべきは、GenericInstanceMethod
においても、ReturnType
や Parameters
の型は具体的な型にはならず、元となる MethodReference
の ReturnType
・Parameters
を引き継いだ GenericParameter
が使用されるという点です。これはジェネリックなメソッドの MethodReference
を新しく作成しようとした際にハマりやすいポイントなので注意しましょう。
// これは間違い // call string C_Generic`1<int32>::M_Generic<string>(int32, string) // これが正解 // call !!0 C_Generic`1<int32>::M_Generic<string>(!0, !!0)
-
💡 ジェネリック型は、メタデータ上は
C_Generic`1
のように名前の後ろに型引数の個数 (arity) がつきます。同名だが型引数の数が異なる型は、ランタイム的には全く異なる型として扱われているわけです。一方、ジェネリックメソッドにはこうした命名規則がありません。そもそも .NET において同名のメソッドは複数定義することができ、それらはシグネチャによってオーバーロード解決されますので、名前による区別を行う必要がないのです。
メタデータについての解説は以上です。前回で IL 命令、今回でメタデータの説明が終わったので、これで DLL 全体の操作があらかたできるようになったと思います。とはいえ、個別の細かい例についてはこの説明ではカバーできないので、実際に作ってみながら検証を行っていく必要が生じます。
次回は、そうした細かい検証を行ううえで便利なツールなども紹介していきます。