Xamarin iOSのP/Invokeでコールバックを使うときの制限

C#でコードが書きたいという一心で、Xamarin iOSを使おうと考えているんですが、基本的に僕の各コードは、C/C++で基本的なAPIセットを用意して、それのラッパーをC#で書き、さらにそのラッパーを呼び出すコードをC#で書く or 書いて貰うという感じになります。

で、C#の何が便利って、例えば、C/C++側で、

typedef int (*MyCallback)();
int MyFunction(MyCallback callback);

なんていうやる気のないコールバックが用意されていて、普通にC++やっていると、これじゃコンテキストわたせねぇというような場合でも、

[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
delegate int MyCallbackDelegate();

[DllImport("MyDll.dll")]
public static extern int MyFunction(MyCallback callback);

なんて定義をしてやれば、何でも渡し放題になるところですね。

MyFunction(() => { /*何でも出来る!*/ });

みたいにラムダすら渡せるので、何にも困らない。C++だとbindなんて使ったところで、本質的に関数オブジェクトと関数は別物なので、いわゆるサンクとかトランポリンという汚い方法を使わざるを得ません。

Xamarin iOSでのP/Invokeに関する制約

ところが、Xamarin iOSでは、

Xamarin iOS Limitations 2.2. Reverse Callbacksには、

In standard Mono it is possible to pass C# delegate instances to unmanaged code in lieu of a function pointer. The runtime would typically transform those function pointers into a small thunk that allows unmanaged code to call back into managed code.

In Mono these bridges are implemented by the Just-in-Time compiler. When using the ahead-of-time compiler required by the iPhone there are two important limitations at this point:

  • You must flag all of your callback methods with the MonoPInvokeCallbackAttribute
  • The methods have to be static methods, there is no support for instance methods.

などという記述があります。要は、JITじゃないからいろいろ出来ないことが多くて、

という制限がありますと。

別に、MonoPInvokeCallbackなんていう属性を付けないと行けないなんていうのは付ければ良いかぁという話ではあるんですが、staticメソッドしか呼び出せないって事は、結局、C#でもコンテキスト渡せないやんけっていう・・・。

まともな設計のライブラリなら何とかなる

もちろん、C++側のライブラリが、

typedef int (*MyCallback2)(void* context);
int MyFunction2(void* context, MyCallback2 callback);

となっているならば問題はありません。これだと、C#側は、

[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
delegate int MyCallback2Delegate(IntPtr context);

[DllImport("MyDll.dll")]
public static extern int MyFunction2(IntPtr context, MyCallback2Delegate callback);

となるわけですが、例えば、Xamarin iOSを無視するなら、呼び出す側のコードは、

MyFunction2(IntPtr.Zero, context => { /*contextは使わない。何でも出来る!*/ });

みたいに、やっぱり、contextは無視できます。でも、Xamarin iOSを相手にするとこれはダメなコードになります。どうするかといえば、GCHandleを使って、コンテキストをIntPtrの形で渡すわけですね。

一方で、本当は、C#では上に書いたように、contextなんて要らないわけです。C#的には、delegateがコンテキストを暗黙的に持ってくれるので、delegateだけ渡せば十分。他は要らない。
なので、最終的に、

public static int MyFunction2(Func<int> callback)

みたいな感じで見えるメソッドにする方が便利です。

public static int MyFunction2(Func<int> callback)
{
  // FIXME:潜在的な例外については無視・・・
  var gcHandle = GCHandle.Alloc(callback, GCHandleType.Normal);
  var ret = MyFunction2(GCHandle.ToIntPtr(gcHandle), myCallback2);
  gcHandle.Free();
  return ret;
}

[MonoPInvokeCallback(typeof(MyCallback2Delegate))]
private static int myCallback2(IntPtr context)
{
  var gcHandle = GCHandle.FromIntPtr(context);
  var callback = (Func<int>)gcHandle.Target;
  return callback();
}

これだとmyCallback2は、staticなメソッドなのでXamarin iOSでも問題はありません。まぁ、他の環境のことを考えると、#ifで、

#if XAMARIN_IOS
public static int MyFunction2(Func<int> callback)
{
  // FIXME:潜在的な例外については無視・・・
  var gcHandle = GCHandle.Alloc(callback, GCHandleType.Normal);
  var ret = MyFunction2(GCHandle.ToIntPtr(gcHandle), myCallback2);
  gcHandle.Free();
  return ret;
}

[MonoPInvokeCallback(typeof(MyCallback2Delegate))]
private static int myCallback2(IntPtr context)
{
  var gcHandle = GCHandle.FromIntPtr(context);
  var callback = (Func<int>)gcHandle.Target;
  return callback();
}
#else
public static int MyFunction2(Func<int> callback)
{
  return MyFunction2(IntPtr.Zero, context => callback());
}
#endif

とでもしておけば良いですね。で、元々のP/Invokeによるエントリは誤爆されることを防ぐためにprivateなりinternalなりにして置けば、C#側のプログラマは好きにやれば良いという感じになります。ハッピーです。