はじめに
これは C# Advent Calendar 2017 の 25 日目の記事です。
C++ で記述したクラスのインスタンスメソッド (インスタンスを生成し、そのインスタンスを指定して実行できるメソッド) を C# から P/Invoke で呼ぶ方法について書いていきます。
元々は 1 年ほど前、私が Unity のネイティブプラグインを開発する際に、どうしてもネイティブ側の実装で C++ のクラスを定義し、そのインスタンスを直接 C# 側で管理する構造にしたかったのが発端になります。
Unity は Mono であり C++/CLI が使えるわけではなく、かといっていちいち一つずつ関数を export してそれを DllImport で読み込ませるのも面倒。また、考え方としてネイティブ側のリソースを一つのクラスにとりまとめ、生存管理をまとまったインスタンス単位で行った方が自然と思いますが、これをなるべく簡潔に行いたい。
とりあえず調べはしたのですが、これが意外と情報がない。決め手にかけるというか。
当時、すでに (大分前から) "CppSharp" というプロジェクトが Mono 管理下で進行していました。
GitHub - mono/CppSharp: Tools and libraries to glue C/C++ APIs to high-level languages
ただ、いろいろ試す時間もなく、元々自前でやる方法のイメージがもやっとしてはいたもののあったので、自力でなんとかする方向で進めて最終的に解決しました。
Windows で .NET Framework のみ、であれば C++/CLI は現状ベストなのですが、昨今はそうも言っていられない雰囲気が結構していますので、 C++/CLI に頼らない方法を把握しておくことはよいことかと思います。
この記事を作成するにあたって、コンパイラの挙動ベースではなく標準化ドキュメントなどをちゃんと調べた上で作成したかったのですが調べきることができてなく、挙動ベースになってしまっているところも多々ありますがご了承ください。
サンプルプログラムは Visual Studio 2017 (C# + Visual C++) で実装しています。パターン 2 については C# 側は Mono でもそのままいけるはずです。 C++ 側は極力 VC++ 依存しないように配慮しましたが、 dllexport がらみで VC++ 固有命令を使ってしまっていますので適宜読み替えてください。
記事をなるべく短くするため、コードの引用は歯抜けになっています。 GitHub に完全版を上げていますのでそちらも合わせて読んでください。
サンプル: aosoft/CppInvokeSample
概要
.NET のマネージドコードから一般的に言ういわゆるプラットフォームのネイティブコードを呼び出す場合、
- P/Invoke 機能を利用する。
- C++/CLI で混在コードを記述し、 C++ のマネージドコードから呼び出す。 (C++ interop)
- RCW (Runtime Callable Wrapper) で COM を呼び出す。
のどれかになります。
このうち P/Invoke が一番利用できる可能性が高い方法です。
P/Invoke はランタイムに実装されている言語非依存の機能であり、どの .NET 言語からも使用することができます。しかし、突き詰めるとこれは C 言語互換の関数を呼び出すだけの機能であり、 C 言語スタイルの Win32 API を呼び出すだけであれば十分ですが、本件の目的のように C++ のインスタンスメソッドを単純に呼ぶことはできません。
C++ のインスタンスメソッドを呼ぶとはどういうことか。これは実際には通常の C 言語の関数を実行するのと変わりません。違いは "暗黙の見えない第一引数に this ポインタが入っている" ということだけです。雰囲気で言えば下記のような感じ。
class Calc
{
int Add(int a, int b); // (a)
};
int Calc_Add(Calc *self, int a, int b); // (b)
Sum *p = new Calc();
int c1 = p->Add(1, 2); // (a)
int c2 = Calc_Add(p, 1, 2); // (b)
ということは C++ のインスタンスメソッドは
- インスタンスのポインタ (this ポインタ) を取得
- パラメーターにインスタンスポインタを追加して P/Invoke で呼び出す
とすれば C# から P/Invoke でインスタンスメソッドの実行をすることが実現できます。
目標
下記のネイティブクラスがあったとして
class CppSample
{
private:
int32_t m_value;
public:
CppSample();
int32_t GetCurrentValue();
void Add(int32_t value);
void Sub(int32_t value);
int32_t AppendChars(const char *chars, int32_t length);
void PrintChars();
};
C++/CLI では下記のようなラッパーの実装をしますが、これを C# で記述する事を目標とします。
(null チェックがあまいですが、例のためです)
ref class CppSampleWrapper
{
private:
CppSample *m_native;
public:
CppSampleWrapper()
{
m_native = new CppSample();
}
~CppSampleWrapper()
{
delete m_native;
}
System::Int32 GetCurrentValue()
{
return m_native->GetCurrentValue();
}
void Add(System::Int32 value)
{
m_native->Add(value);
}
//
// 以下略
//
};
実践
C++ クラスを定義する
C# からの呼び出しパターンを 2 つ紹介します。どちらもネイティブ C++ 側の定義は共通です。
まずメソッド定義だけをした抽象クラスを定義します。
class ICppSample
{
public:
virtual void Destroy() = 0;
virtual int32_t GetCurrentValue() = 0;
// 以下略
};
ここでのポイントは "必ずインスタンスを破棄するメソッド (ここでは Destroy)" を定義すること。 C# 側に管理を全て任せる形にするので、任意のタイミングで破棄できるようにするために破棄メソッドは必ず定義します。
ちなみにクラス名の先頭が I から始まっているのは "pure virtual のみの抽象クラスをインターフェースとする" というオレオレルールのためです。
合わせて、これを実装する CppSample クラスを定義します。 Destroy は delete this; をするだけの実装をします。
パターン 1: Windows (特定プラットフォーム) に決め打ちする
こちらは "C++ の都合に C# を合わせる" パターンになります。
結論から言えばインスタンスのポインターから vtable を引きずり出して、その関数ポインターから Marshal.GetDelegateForFunctionPointer で delegate を作る、ということになります。
インスタンスに vtable が含まれている事が大前提になりますので、必ず仮想メソッドによる定義が必要になります。
まず C++ 側にインスタンス生成する C の関数を実装します。
extern "C" __declspec(dllexport) ICppSample *STDMETHODCALLTYPE CreateCppSampleInstance() try
{
return new CppSample();
} catch(const std::exception&) {
return nullptr;
}
単純に new するだけです。 imoprt する C# 側の定義は
[DllImport("CppInvokeSampleDLL.dll", CallingConvention = CallingConvention.Winapi)]
static extern IntPtr CreateCppSampleInstance();
です。これを C# 側から呼び出すと IntPtr の戻り値に C++ のインスタンスポインターが入っています。
インスタンスポインターの示す先にそのインスタンスが保持している全ての情報が含まれているわけですが、ではどうやって vtable を引きずり出すか、になるのですが C++ ABI は規定がないらしく、コンパイラ (プラットフォーム) 依存になるようです。今回は VC++ でのレイアウトで取得してみます。
_self = CreateCppSampleInstance();
var funcs = new IntPtr[6];
Marshal.Copy(Marshal.ReadIntPtr(_self, 0), funcs, 0, funcs.Length);
まずマジックナンバー 6 ですが、これは 6 個メソッドがあると C++ 側の定義を知っているからです。
そして Marshal.Copy を使ってポインターから関数テーブルの内容をコピーします。インスタンスポインターの先頭に関数テーブルへのポインターが入っているのでここからコピーします。
[UnmanagedFunctionPointer(CallingConvention.ThisCall)]
delegate void FnAction(IntPtr self);
[UnmanagedFunctionPointer(CallingConvention.ThisCall)]
delegate void FnCalc(IntPtr self, int value);
_fnDestroy = Marshal.GetDelegateForFunctionPointer<FnAction>(funcs[0]);
_fnAdd = Marshal.GetDelegateForFunctionPointer<FnCalc>(funcs[2]);
GetDelegateForFunctionPointer で delegate を生成します。 GetDelegateForFunctionPointer は generic delegate が指定できないので一つずつ delegate を定義します。この際、 CallingConvention で ThisCall を指定しておきます。インスタンスメソッドは VC++ では通常は thiscall という呼び出し規約になっているのでそれに合わせておきます。
また、暗黙指定だった this ポインターをここでは明示的に指定する必要がありますので全ての定義の第一引数に IntPtr を付けます。他は通常の P/Invoke 指定と同じです。
ラッパーのメソッドは次のようになります。
public void Dispose()
{
_fnDestroy?.Invoke(_self);
}
public void Add(int value)
{
_fnAdd(_self, value);
}
破棄メソッドは .NET の一般的作法に則り、 IDisposable を実装するのがよいでしょう。
delegate の呼び出しには控えておいた this ポインタを合わせて指定します。
パターン 2: 安全策
パターン 1 とは逆で "C# の都合に C++ を合わせる" パターンになります。
パターン 1 の問題は C++ ABI のレイアウトにアドレスベースで直接さわらなくてはいけないことです。C++ 側でメソッドを一つ追加した場合、関数テーブルのインデックスがずれるかもしれません。また、継承などでテーブル自体が変わってしまうことも考えられます。 C++ ABI の互換性問題により C# 側の実装をプラットフォーム毎に用意しなくてはいけなくなるかもしれません。
これらの問題を回避するため、コンパイラが自動で作る vtable を直接使うのではなく、自前で関数テーブルを用意するのがこのパターンになります。
今回の C++ 側に定義するインスタンス生成関数は次のようになります。
extern "C" __declspec(dllexport) int32_t STDMETHODCALLTYPE CreateCppSampleInstance2(void **buffer, int32_t bufferSize);
引数経由でポインター配列を渡します。ポインター配列の 0 番に this ポインター、それ以降に関数ポインターを設定していきます。
次に関数ポインターのテーブルを作ります。ここで問題となるのが *インスタンスメソッドのポインターは通常のポインター (void ) とキャストができない ということです。
そこで void * にキャストができる C 言語スタイルの関数に変換します。つまり
int32_t CppSample_Add(ICppSample *self, int32_t value)
{
return self->Add(value);
}
として CppSample_Add を関数ポインターとします。これを全てのメソッドでいちいち実装すると面倒くさいので C++ のテンプレートを利用します。
template<class T, typename RetT, typename ... Args>
struct Proxy
{
template<typename RetT(T::*func)(Args...)>
static RetT STDMETHODCALLTYPE Func(T *self, Args... args)
{
return (self->*func)(args...);
}
};
static void *funcs[] =
{
Proxy<ICppSample, void>::Func<&ICppSample::Destroy>,
Proxy<ICppSample, int32_t>::Func<&ICppSample::GetCurrentValue>,
Proxy<ICppSample, void, int32_t>::Func<&ICppSample::Add>,
// 略
};
テンプレート内の Func メソッドが C# 側に直接公開するメソッドになるので呼び出し規約として STDMETHODCALLTYPE をつけておきます。これも C# の都合に合わせる一環です。
C# 側はパターン 1 と大体同じ。配列で取得したインスタンスポインターと関数ポインターから delegate を準備し、実行時はインスタンスポインターを付与して delegate を実行します。
delegate void FnAction(IntPtr self);
delegate void FnCalc(IntPtr self, int value);
int bufferSize = CreateCppSampleInstance2(null, 0);
var buffer = new IntPtr[bufferSize];
CreateCppSampleInstance2(buffer, bufferSize);
_self = buffer[0];
_fnDestroy = Marshal.GetDelegateForFunctionPointer<FnAction>(buffer[1]);
_fnAdd = Marshal.GetDelegateForFunctionPointer<FnCalc>(buffer[3]);
パターン 1 との比較ですが delegate 宣言で CallingConvention をつけていません。 C++ 側で STDMETHODCALLTYPE をつけたので省略できるようになりました。
CreateCppSampleInstance2 は引数にバッファを指定しますが、バッファを null 指定すると必要サイズを返す実装にしたのでこのような手続きでポインターリストを取得します。 0 番に this ポインターが入り、関数ポインターは 1 からになるのでパターン 1 と比較して 1 ずつずれます。
パターン 1 との違いはここまでで、実際の使い方は同じです。
呼び出してみる
呼び出す側からは普通の C# のクラスです。 IDisposable 実装になっているのだけ注意。
using (var cpp1 = new CppSample1())
using (var cpp2 = new CppSample2())
{
for (int i = 0; i < 5; i++)
{
cpp1.Add(10);
Console.WriteLine(cpp1.GetCurrentValue());
}
Console.WriteLine("Length = {0}", cpp1.AppendChars("Hello,"));
Console.WriteLine("Length = {0}", cpp1.AppendChars("CppSample1!"));
cpp1.PrintChars();
Console.WriteLine();
for (int i = 0; i < 5; i++)
{
cpp2.Sub(5);
Console.WriteLine(cpp2.GetCurrentValue());
}
Console.WriteLine("Length = {0}", cpp2.AppendChars("Hello,"));
Console.WriteLine("Length = {0}", cpp2.AppendChars("CppSample2!"));
cpp2.PrintChars();
}
結果:
CppSample::Create
CppSample::Create
10
20
30
40
50
Length = 6
Length = 17
[02C69728] Hello,CppSample1!
-5
-10
-15
-20
-25
Length = 6
Length = 17
[02C69778] Hello,CppSample2!
CppSample::Destroy [02C69778]
CppSample::Destroy [02C69728]
C++ の CppSample::PrintChars, Destroy では this ポインタの値をコンソールに出力するようにしています。
CppSample1 と CppSample2 でそれぞれ内部的には違うインスタンスになっていることが確認できます。
比較
パターン 1 | パターン 2 | |
---|---|---|
ABI | C++ 言語 | C 言語相当 |
パフォーマンス | ---- | 若干遅い (誤差) |
記述量 | 少ない | 若干多い (テーブルを自前で作るから) |
ミス発生率 | 高い。危険。 | 低い。エラーは事前にわかる可能性が高い |
- パターン 1
- C++ のクラスに直接アクセスしているので、踏み台を入れているパターン 2 より実行時のステップ数が確実に少ないです。
- しかし、そのメリットは実際は有意な差を生むとは考えにくい。 1 秒に何万回もコールするとかならともかくですが、高頻度に .NET - ネイティブ間の呼び出しをする事がそもそも間違ってると思います。
- C++ 側の定義変更によりテーブル時代の構造が意図せず変わってしまう可能性もあり危険度が高いです。
- C++ のクラスに直接アクセスしているので、踏み台を入れているパターン 2 より実行時のステップ数が確実に少ないです。
- パターン 2
- 踏み台が入る分、パフォーマンスは若干劣ります。
- 関数テーブルを手作業で作るのが面倒。
- テンプレートを駆使することで手間を最小限に抑えています。
- 関数テーブルによるメリットが大きい。
- C++ ABI に直接触るわけではなく、シンプルな C 関数アクセスになります。 C++ の呼び出し規約もネイティブ側に隠蔽され配慮不要に。
- 明示的に作成することで、メソッドの順番変更や追加でインデックスが変わっても影響がありません。
- 削除や引数の変更があってもコンパイルエラーになるので事前に確認ができ、アクセス違反等の問題が起きる可能性が低い。
- リスト生成に C++ の言語機能のみを使用しているのでポータビリティが高い。
パターン 2 は結局 C 言語の関数を定義しているので一つずつ DllImport でやるのと実質差異はないように思われるかもしれませんが、テンプレート+関数テーブルにすることで C++ 側に踏み台関数とその dllexport の定義をいちいち書かなくてよいのは大きなメリットです。
パターン 1 は CppSharp のような自動生成であれば (生成処理な問題がなけれぱ) 安全ですのでよいと思いますが、手作業で行うにはあまりにリスクが大きいので CppSharp のようなツールを頼らないのであればパターン 2 の方がよいのではないかと思います。
余談: 呼び出し規約について
- .NET でネイティブコードを呼び出す際のデフォルトは CallingConvention.Winapi です。 Winapi となっていますが、これはプラットフォームの標準的な呼び出し規約に従う、の意味です。 Windows では StdCall ですがそれ以外は大体 Cdecl になっています。つまり CallingConvertion は通常は明示指定する必要はありません。 Windows で C ランタイムの関数を直接呼ぶ時とか?
- VC++ の場合、 C++ のインスタンスメソッドの呼び出し規約はデフォルトで thiscall を使用しますが、明示的に指定する事も可能です。つまり STDMETHODCALLTYPE を指定すれば C++ メソッドでも stdcall になり、 CppSample1 で CallingConvertion.ThisCall の指定をする必要がなくなります (というかしてはダメ) 。 Windows の COM はまさにこのパターンですね。
呼び出し規約はこちらも: C/C++ の呼び出し規約を再確認する
おわりに
本当は C++/CLI が進化していって、 Mono など他の .NET 向け、プラットフォーム向けのバイナリがビルドできるようになっていくのがよかったのですが、 VS2017 では C++/CLI はオプションになり、そもそも機能拡張が全くされてこなかった (振り返って考えてみると VS2010 で .NET 4 対応はしたもののそれで終わりで機能拡張はそれ以後も含めて何もなかったような・・・) 状況でこのままフェードアウト確定かなあと思われます。WinRT で追加された C++/CX も廃止してテンプレートライブラリによる C++/WinRT を代替にするようです。 C++ AMP もなあ・・・
マイクロソフトは C++ の言語仕様は標準準拠で独自拡張はやめ、拡張はクラスライブラリで行うという非常に真っ当な方針になったように見えます。これ自体は歓迎こそすれ非難をする要素など全くないのですが C++/CLI はマネージドとネイティブをシームレスに混在できる唯一の言語だったのでなくなってしまうと困るなあと思ってしまいます。
そういうわけで C++/CLI に頼らずに C++ interop (相当) を実現する方法は今後重要性が増していくと思います。
CppSharp が安定して使えるようになればそれを使っていけばよいと思いますが、手作業でも可能であることを示せたと思います。参考にしていただければと思います。