CORETECH ENGINEER BLOG

株式会社サイバーエージェント SGEコア技術本部 技術ブログ

ILPostProcessor 入門 第4回目「メタデータ」

この記事は 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メタデータを扱う時も概念として登場します。ほとんど「アセンブリ=モジュール」と考えて問題ありませんので、覚えておきましょう。

TypeDefinitionTypeReference

Mono.Cecil において、型は TypeDefinitionTypeReference によって表現されます。

TypeDefinition はある型の定義を表すクラスで、1つの型を表す TypeDefinitionインスタンスは1つです。TypeDefinition にはフィールドやメソッド、プロパティなど、型に関する様々な情報が保存されています。

対して、TypeReferenceTypeDefinition への参照を表すクラスで、そのインスタンスは様々な箇所で自由に作成されます。前回記事で登場した initobj 命令など、IL 命令のオペランドに型を指定する場合はTypeReference を使用します。

var initobj = Instruction.Create(OpCodes.Initobj, /* TypeReference */);

TypeReference は様々な方法で作ることができます。以下にいくつかの例を紹介します。

TypeSystem からビルトイン型の TypeReference を取得する

intstring などのビルトイン型の 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 Frameworkmscorlib
  • .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 というクラスから派生するクラス群を使用します。TypeSpecificationTypeReference の派生型です。

TypeSpecification には次のような派生型があります。

  • ArrayType
    • 配列
  • ByReferenceType
    • C# におけるref, in, out
  • 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 の要素である ParameterDefinitionIsIn, 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 であることを意味しています。また、inout の場合は、型の後に modreq という句と共に属性が追加されています。

💡 modoptmodreq は、シグネチャ内の型に属性を付与できる機能で、C# における Attribute とは異なる仕組みです。

modreqmodopt が付いた型は 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() が便利です。

cecil/rocks/Mono.Cecil.Rocks/TypeReferenceRocks.cs at 8e1ae7b4ea67ccc38cb8db3ded6802643109ffd7 · jbevain/cecil · GitHub

例えば、次のようなコードで 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 型のコレクションで、GenericParameterTypeReference の派生型です。

  public sealed class GenericParameter : 
    TypeReference,
    ICustomAttributeProvider,
    IMetadataTokenProvider;

メソッドへの応用

型と同じように、メソッドにも MethodDefinitionMethodReference があります。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 においても、ReturnTypeParameters の型は具体的な型にはならず、元となる MethodReferenceReturnTypeParameters を引き継いだ 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 全体の操作があらかたできるようになったと思います。とはいえ、個別の細かい例についてはこの説明ではカバーできないので、実際に作ってみながら検証を行っていく必要が生じます。

次回は、そうした細かい検証を行ううえで便利なツールなども紹介していきます。