チュートリアル: ソースによって生成された P/Invoke でカスタム マーシャラーを使用する

このチュートリアルでは、ソース生成 P/Invokeカスタム マーシャリングを行うマーシャラーを実装し、使用する方法について説明します。

組み込み型のマーシャラーを実装し、特定のパラメーターとユーザー定義型向けにマーシャリングをカスタマイズし、ユーザー定義型に既定のマーシャリングを指定します。

このチュートリアルで使用されるすべてのソース コードは、dotnet/samples リポジトリにあります。

LibraryImport ソース ジェネレーターの概要

System.Runtime.InteropServices.LibraryImportAttribute 型は、.NET 7 で導入されたソース ジェネレーターのユーザー エントリ ポイントです。 このソース ジェネレーターは、実行時ではなくコンパイル時にすべてのマーシャリング コードを生成するように設計されています。 エントリ ポイントは、従来は DllImport を使って指定してきました。しかし、このアプローチには、常に受け入れられるわけではないという代償が伴います。詳細については、P/Invoke のソース生成に関する記事を参照してください。 LibraryImport ソース ジェネレーターは、すべてのマーシャリング コードを生成するので、DllImport に固有の実行時の生成という要件がなくなります。

ランタイムと、ユーザーが独自の型に合わせてカスタマイズするためのマーシャリング コードを生成するのに必要な詳細情報を表現するには、いくつかの型が必要です。 このチュートリアルでは次の型を使用します。

  • MarshalUsingAttribute – 利用サイトでソース ジェネレーターが求める属性で、属性付き変数をマーシャリングする場合のマーシャラーの種類を判定するために使われる属性。

  • CustomMarshallerAttribute – 型と、マーシャリング操作を実行するモード (たとえば、マネージドからアンマネージドへの参照渡し) のマーシャラーを指定するために使用する属性。

  • NativeMarshallingAttribute – 属性付きの型に使用するマーシャラーを指定するために使用する属性。 これは、型と、その型に付属するマーシャラーを提供するライブラリ作成者に役立ちます。

ただし、カスタム マーシャラーの作成者が利用できるメカニズムはこうした属性だけではありません。 ソース ジェネレーターは、マーシャラー自体を調べ、マーシャリングがどのように実行されるかを知ることができるその他のさまざまな兆候を調べます。

設計の詳細については、 dotnet/runtime リポジトリを参照してください。

ソース ジェネレーターのアナライザーとフィクサー

ソース ジェネレーターと共に、アナライザーとフィクサーも提供されています。 アナライザーとフィクサーは、.NET 7 RC1 以降では既定で有効になっており、すぐに使用できます。 アナライザーは、開発者がソース ジェネレーターを適切に利用できるように設計されています。 フィクサーは、数多くある DllImport パターンから適切な LibraryImport シグネチャへの自動変換を提供します。

ネイティブ ライブラリの概要

LibraryImport ソース ジェネレーターを使用すると、ネイティブ (アンマネージド) ライブラリを使用することになります。 ネイティブ ライブラリは、.NET では公開されていないオペレーティング システムの API を直接呼び出す共有ライブラリ (つまり、.dll.so、または dylib) である場合があります。 ライブラリは、.NET 開発者が使用するアンマネージド言語で大幅に最適化されているライブラリである場合もあります。 このチュートリアルでは、C 言語形式の API を公開する独自の共有ライブラリを構築します。 次のコードは、C# で利用するユーザー定義型と 2 つの API を記述しています。 この 2 つの API は "In" モードを記述していますが、サンプルでは追加のモードも見ていきます。

struct error_data
{
    int code;
    bool is_fatal_error;
    char32_t* message;    /* UTF-32 encoded string */
};

extern "C" DLL_EXPORT void STDMETHODCALLTYPE PrintString(char32_t* chars);

extern "C" DLL_EXPORT void STDMETHODCALLTYPE PrintErrorData(error_data data);

上記のコードには、目的の char32_t*error_data という 2 つの型が含まれています。 char32_t* は UTF-32 でエンコードされた文字列です。これは.NET が本来マーシャリングする文字列エンコードではありません。 error_data は、32 ビット整数フィールド、C++ のブール型フィールド、UTF-32 エンコード文字列フィールドを含むユーザー定義型です。 どちらの型にも、ソース ジェネレーターがマーシャリング コードを生成する方法を提供する必要があります。

組み込み型のマーシャリングをカスタマイズする

最初に char32_t* 型を検討します。この型のマーシャリングがユーザー定義型で必要となるためです。 char32_t* はネイティブ側の表現ですが、マネージド コードでの表現も必要です。 .NET では、"文字列" 型は string 1 つだけです。 そのため、ネイティブの UTF-32 でエンコードされた文字列とマネージ コードの string 型との間でマーシャリングすることになります。 string 型には、組み込みのマーシャラーがいくつか用意されており、UTF-8、UTF-16、ANSI、さらには Windows の BSTR 型としてマーシャリングすることができます。 ただ、UTF-32 としてマーシャリングするものはありません。 これが定義する必要があるものです。

Utf32StringMarshaller 型は CustomMarshaller 属性でマークされており、ここでソース ジェネレーターに何を行うかを記述します。 この属性の最初の型引数は string 型で、マーシャリングの対象となるマネージド型を指定します。2 つ目はモードで、マーシャラーを使用するタイミングを指定します。3 つ目は Utf32StringMarshaller 型で、マーシャリングに使用する型です。 CustomMarshaller は、複数回適用 して、さらにモードおよびモードに使用するマーシャラーの種類を指定できます。

この例では、何らかの入力を受け取り、マーシャリングされた形式でデータを返す "ステートレス" マーシャラーを示しています。 Free メソッドは、アンマネージド マーシャリングとの対称性を保つために存在しており、ガベージ コレクターはマネージド マーシャラーを "解放" する操作を行います。 実装者は、入力を出力にマーシャリングするために必要な操作を自由に実行できますが、ソース ジェネレーターが明示的に状態を保持することはないことを覚えておいてください。

namespace CustomMarshalling
{
    [CustomMarshaller(typeof(string), MarshalMode.Default, typeof(Utf32StringMarshaller))]
    internal static unsafe class Utf32StringMarshaller
    {
        public static uint* ConvertToUnmanaged(string? managed)
            => throw new NotImplementedException();

        public static string? ConvertToManaged(uint* unmanaged)
            => throw new NotImplementedException();

        public static void Free(uint* unmanaged)
            => throw new NotImplementedException();
    }
}

このマーシャラーが string から char32_t* への変換を実行する詳細については、サンプルを参照してください。 .NET の API を利用できることに注意してください (例: Encoding.UTF32)。

状態を保つことができると望ましい状況を考えてみましょう。 追加の CustomMarshaller を確認し、より具体的なモードの MarshalMode.ManagedToUnmanagedIn に注目します。 この特化したマーシャラーは "ステートフル" として実装され、相互運用の呼び出し間で状態を保存できます。 より特化して状態を保つことで、モードに合わせた最適化とマーシャリングの調整が可能になります。 たとえば、ソース ジェネレーターでスタック割り当てバッファーを提供し、マーシャリング中に明示的な割り当てしなくても済むようにできます。 スタック割り当てバッファーをサポートしていることを示すために、マーシャラーには BufferSize プロパティと、unmanaged 型の Span を受け取る FromManaged メソッドを実装します。 BufferSize プロパティは、マーシャラーがマーシャリングの呼び出し中に取得するスタック領域 (FromManaged に渡される Span の長さ) を示します。

namespace CustomMarshalling
{
    [CustomMarshaller(typeof(string), MarshalMode.Default, typeof(Utf32StringMarshaller))]
    [CustomMarshaller(typeof(string), MarshalMode.ManagedToUnmanagedIn, typeof(ManagedToUnmanagedIn))]
    internal static unsafe class Utf32StringMarshaller
    {
        //
        // Stateless functions removed
        //

        public ref struct ManagedToUnmanagedIn
        {
            public static int BufferSize => 0x100;

            private uint* _unmanagedValue;
            private bool _allocated; // Used stack alloc or allocated other memory

            public void FromManaged(string? managed, Span<byte> buffer)
                => throw new NotImplementedException();

            public uint* ToUnmanaged()
                => throw new NotImplementedException();

            public void Free()
                => throw new NotImplementedException();
        }
    }
}

これで、UTF-32 文字列のマーシャラーを使用して、2 つのネイティブ関数のうちの最初のものを呼び出せるようになりました。 次の宣言では、DllImport のように LibraryImport 属性を使用しますが、MarshalUsing 属性に依存しており、これでネイティブ関数を呼び出すときに使用するマーシャラーをソース ジェネレーターに伝えます。 ステートレス マーシャラーとステートフル マーシャラーのどちらを使用する必要があるかどうかを明示する必要はありません。 これには、実装者がマーシャラーの CustomMarshaller 属性の MarshalMode を定義することで対処します。 ソース ジェネレーターでは、MarshalUsing が適用されたコンテキストに合わせて最も適切なマーシャラーが選択され、ほかに何もなければ MarshalMode.Default が使用されます。

// extern "C" DLL_EXPORT void STDMETHODCALLTYPE PrintString(char32_t* chars);
[LibraryImport(LibName)]
internal static partial void PrintString([MarshalUsing(typeof(Utf32StringMarshaller))] string s);

ユーザー定義型のマーシャリングをカスタマイズする

ユーザー定義型をマーシャリングするには、マーシャリングのロジックだけでなく、C# の型を定義してマーシャリングを行う必要があります。 マーシャリングしようとしているネイティブ型を思い出してください。

struct error_data
{
    int code;
    bool is_fatal_error;
    char32_t* message;    /* UTF-32 encoded string */
};

次に、これを C# で定義するにはどうすればよいかを考えます。 int は、最新の C++ と .NET のどちらでも同じサイズです。 bool は、.NET でブール値として使用する標準的な例です。 Utf32StringMarshaller をベースに、char32_t* を .NET の string としてマーシャリングできます。 .NET の形式を踏襲すると、結果的に C# の定義は次のようになります。

struct ErrorData
{
    public int Code;
    public bool IsFatalError;
    public string? Message;
}

命名規則に従って、マーシャラーに ErrorDataMarshaller という名前を付けます。 MarshalMode.Default にマーシャラーを指定する代わりに、一部のモードにのみマーシャラーを定義します。 この場合、提供されていないモードにマーシャラーを使用すると、ソース ジェネレーターは実行に失敗します。 最初に、"In" 方向のマーシャラーを定義します。 マーシャラー自体は static 関数のみで構成されるため、"ステートレス" マーシャラーです。

namespace CustomMarshalling
{
    [CustomMarshaller(typeof(ErrorData), MarshalMode.ManagedToUnmanagedIn, typeof(ErrorDataMarshaller))]
    internal static unsafe class ErrorDataMarshaller
    {
        // Unmanaged representation of ErrorData.
        // Should mimic the unmanaged error_data type at a binary level.
        internal struct ErrorDataUnmanaged
        {
            public int Code;        // .NET doesn't support less than 32-bit, so int is 32-bit.
            public byte IsFatal;    // The C++ bool is defined as a single byte.
            public uint* Message;   // This could be as simple as a void*, but uint* is closer.
        }

        public static ErrorDataUnmanaged ConvertToUnmanaged(ErrorData managed)
            => throw new NotImplementedException();

        public static void Free(ErrorDataUnmanaged unmanaged)
            => throw new NotImplementedException();
    }
}

ErrorDataUnmanaged は、アンマネージド型の形式を踏襲してします。 ErrorData から ErrorDataUnmanaged への変換は、Utf32StringMarshaller で簡単になりました。

int のマーシャリングは、アンマネージド コードとマネージド コードで表現が同じなので不要です。 bool 値のバイナリ表現は .NET では定義されていないため、現在の値を使用して、アンマネージド型で 0 と 0 以外の値を定義します。 次に、UTF-32 マーシャラーを再利用して、string フィールドを uint* に変換します。

public static ErrorDataUnmanaged ConvertToUnmanaged(ErrorData managed)
{
    return new ErrorDataUnmanaged
    {
        Code = managed.Code,
        IsFatal = (byte)(managed.IsFatalError ? 1 : 0),
        Message = Utf32StringMarshaller.ConvertToUnmanaged(managed.Message),
    };
}

このマーシャラーを "In" として定義しているので、マーシャリング中に実行された割り当てをクリーンアップする必要があることを思い出してください。 int フィールドと bool フィールドはメモリを割り当てませんでしたが、Message フィールドはメモリを割り当てました。 Utf32StringMarshaller を再利用して、マーシャリングされた文字列をクリーンアップします。

public static void Free(ErrorDataUnmanaged unmanaged)
    => Utf32StringMarshaller.Free(unmanaged.Message);

"Out" のシナリオについて簡単に考えてみましょう。 error_data の 1 つまたは複数のインスタンスが返された場合を考えてみましょう。

extern "C" DLL_EXPORT error_data STDMETHODCALLTYPE GetFatalErrorIfNegative(int code)

extern "C" DLL_EXPORT error_data* STDMETHODCALLTYPE GetErrors(int* codes, int len)
[LibraryImport(LibName)]
internal static partial ErrorData GetFatalErrorIfNegative(int code);

[LibraryImport(LibName)]
[return: MarshalUsing(CountElementName = "len")]
internal static partial ErrorData[] GetErrors(int[] codes, int len);

コレクションではない単一のインスタンス型を返す P/Invoke は、MarshalMode.ManagedToUnmanagedOut に分類されます。 通常、コレクションは複数の要素を返す場合に使用します。この場合は、Array を使用します。 MarshalMode.ElementOut モードに対応するコレクションのマーシャラーは、複数の要素を返します。これは後ほど説明します。

namespace CustomMarshalling
{
    [CustomMarshaller(typeof(ErrorData), MarshalMode.ManagedToUnmanagedIn, typeof(ErrorDataMarshaller))]
    [CustomMarshaller(typeof(ErrorData), MarshalMode.ElementOut, typeof(Out))]
    internal static unsafe class ErrorDataMarshaller
    {
        //
        // Other marshallers removed
        //

        public static class Out
        {
            public static ErrorData ConvertToManaged(ErrorDataUnmanaged unmanaged)
                => throw new NotImplementedException();

            public static ErrorDataUnmanaged ConvertToUnmanaged(ErrorData managed)
                => throw new NotImplementedException();

            public static void Free(ErrorDataUnmanaged unmanaged)
                => throw new NotImplementedException();
        }
    }
}

ErrorDataUnmanaged から ErrorData への変換は、"In" モードで行ったことの逆です。 また、アンマネージド環境で実行する必要がある割り当てをクリーンアップする必要もあります。 また、ここでの関数は static とマークされているため、"ステートレス" であることに注意することも重要です。ステートレスは、すべての "要素" モードの要件です。 また、"in" モードのような ConvertToUnmanaged メソッドがあることに注目してください。 すべての "Element" モードでは、"in" と "out" の両モードを処理する必要があります。

マネージドからアンマネージドへの "Out" マーシャラーの場合は、特別な操作を行います。 マーシャリングするデータ型の名前は error_data と呼ばれ、.NET では通常、エラーを例外として表現します。 エラーの中には他よりも影響が大きいエラーがあり、通常、"致命的" と識別されるエラーは、致命的または回復不可能なエラーを指します。 error_data には、エラーが致命的かどうかをチェックするフィールドがあることに注目してください。 error_data をマネージド コードにマーシャリングしたが、致命的なエラーがあった場合は、単に ErrorData に変換して返すのではなく、例外をスローします。

namespace CustomMarshalling
{
    [CustomMarshaller(typeof(ErrorData), MarshalMode.ManagedToUnmanagedIn, typeof(ErrorDataMarshaller))]
    [CustomMarshaller(typeof(ErrorData), MarshalMode.ElementOut, typeof(Out))]
    [CustomMarshaller(typeof(ErrorData), MarshalMode.ManagedToUnmanagedOut, typeof(ThrowOnFatalErrorOut))]
    internal static unsafe class ErrorDataMarshaller
    {
        //
        // Other marshallers removed
        //

        public static class ThrowOnFatalErrorOut
        {
            public static ErrorData ConvertToManaged(ErrorDataUnmanaged unmanaged)
                => throw new NotImplementedException();

            public static void Free(ErrorDataUnmanaged unmanaged)
                => throw new NotImplementedException();
        }
    }
}

"Out" パラメーターはアンマネージド コンテキストからマネージド コンテキストに変換されるため、ConvertToManaged メソッドを実装します。 アンマネージドの呼び出し先が ErrorDataUnmanaged オブジェクトを返す場合は、ElementOut モードのマーシャラーを使用して、致命的なエラーとしてマークされているかをチェックできます。 マークされていれば、単に ErrorData を返すのではなく、例外をスローするべきだというしるしです。

public static ErrorData ConvertToManaged(ErrorDataUnmanaged unmanaged)
{
    ErrorData data = Out.ConvertToManaged(unmanaged);
    if (data.IsFatalError)
        throw new ExternalException(data.Message, data.Code);

    return data;
}

ネイティブ ライブラリを利用するだけでなく、コミュニティと成果物を共有し、相互運用ライブラリを提供したいと思われるかもしれません。 その場合はErrorData の定義に [NativeMarshalling(typeof(ErrorDataMarshaller))] を追加することで、P/Invoke で使用されるたびに暗黙的にマーシャラーが指定される ErrorData を提供できます。 そうすることで、LibraryImport の呼び出しでこの型の定義を使用するすべてのユーザーがマーシャラーの恩恵を受けることができるようになります。 ユーザーは、利用サイトでいつでも MarshalUsing を使ってマーシャラーをオーバーライドすることができます。

[NativeMarshalling(typeof(ErrorDataMarshaller))]
struct ErrorData { ... }

関連項目