💰

dart:ffiで既存のC++の資産をDart/Flutterから使用する方法

sangotaro2022/11/09に公開1件のコメント

1. はじめに

こんにちは、TURING でエンジニアをしているsangotaroです。
TURINGは完全自動運転システムを搭載した"EV"の販売を目標とする会社です

この記事では既存のC++で書かれたソフトウェアの資産をDartから利用する方法についてある程度の知見が得られたのでそれをまとめたいと思います。

2. なぜC++の資産をDartから使用する必要があったか

hokkaidoプロジェクトの記事でもお伝えした通り、TURINGでは現時点でも以下のソフトウェア資産を持っています

  • カメラからの映像を入力として経路の推定を行い自動運転を行う
  • 自動運転の推論の結果得られた経路やカメラ画像の表示、CAN (自動車の様々なデータが流れるネットワーク)から得られた情報などを車載モニタに表示する

現状のモニタに表示されている情報でも運転手に必要な情報は表示されているのですが、今後さらに車載モニタに表示される情報や機能を拡張していく(例えばナビ機能の追加など)にあたって、車に搭載する予定のLinux準拠のハードウェアに柔軟に対応する UI 描画フレームワークとして Flutter の検討をすすめていました。

なぜC++の資産をDartから使用する必要があったかに対する答えが、現状のソフトウェア資産の大部分はC++で書かれているため、それを Flutter で使用されている言語でもある Dart からも使用したい動機があったからです。

3. dart:ffi とは

Dart言語からC言語のネイティブAPIを呼ぶための機構です。

3-1. 基本的な使い方

構文やサンプルコードは上の公式ページや、実装例にも載っているのでここでは基本的な使い方だけ紹介します。例えば以下のような Cで書かれた関数を Dart から使用したい場合、

C
int sum(int a, int b) {
    return a + b;
}

以下のような記述だけで Dart からもCの関数を呼ぶことができます。

Dart
typedef SumNative = Int32 Function(Int32 a, Int32 b);
typedef Sum = int Function(int a, int b);
void main() {
  ...
  final dylib = DynamicLibrary.open('libprimitives.so');
  final sumPointer = dylib.lookup<NativeFunction<SumNative>>('sum');
  final sum = sumPointer.asFunction<Sum>();
  print('3 + 5 = ${sum(3, 5)}');
  ...
}

3-2. C++ の関数を呼ぶ場合

ただ、C++の関数を呼ぶ場合には少し工夫が必要になります。なぜなら

  • dart:ffi はそもそも CのAPIを呼ぶことを前提にして作られているためC++は直接はサポートされていない
  • C++の関数はマングリングされるために、関数のシンボルを名前だけからは解決できない

ためです。

ではどうするか。下記のようにextern "C"キーワードで関数をマングルしないように指定できるので、関数名からシンボルを探せるようになります。

C++
extern "C" int sum(int a, int b) {
   return a + b;
}

同様にC++のクラスメソッドをdart:ffi経由でそのまま呼び出すことはできないので、例えば下記の create メソッドを Dartから呼びたい場合は

C++
class Context {
 public:
  virtual void *getRawContext() = 0;
  static Context *create();
  virtual ~Context(){};
};

createメソッドを呼び出しをCでラップした関数を新たに定義して、それを Dart から呼ぶ必要があります。

C++
extern "C" Context* messaging_context_create() { 
  return Context::create(); 
}

3-3. C++のクラスインスタンスをDartでどのように表現するか

ただ、上記のようなContextクラスのインスタンスを作成する関数をDartから呼べたとしてもそれをDartでどのように受け取るのが良いでしょうか。
dart:ffiにはStructというクラスもありますが、これはCの構造体を表すのに使用するものなのでC++のクラスインスタンスには使用できません。

Pointerクラスは定義されているので、作成されたインスタンスのアドレスをポインタとして受け取ることはできそうです。

ただ、ポインタとして受け取ったとしてもそれ以降のあらゆる操作をすべてポインタのアドレス経由で行うのはあまりに辛そう…

結果的にはTURINGでは現段階では下記のような定義を作っています。

Dart
abstract class StructWrapper {
  abstract final Pointer<Void> nativeInstance;
}
class Context extends StructWrapper {
  
  final Pointer<Void> nativeInstance;
  // _newContext()はC++側でContextを作成してそのアドレスを返している
  Context()
      : nativeInstance = Bindings()._newContext()
}
typedef _CreateContext = Pointer<Void> Function();
class Bindings {
  factory Bindings() {
    _instance ??= Bindings._();
    return _instance!;
  }
  Bindings._() {
    _sharedLib = DynamicLibrary.open('../libmessaging_shared.so');
  }
  static Bindings? _instance;
  late DynamicLibrary _sharedLib;
  late final Pointer<Void> Function() _newContext = _sharedLib
      .lookupFunction<_CreateContext, _CreateContext>('messaging_context_create');
}

作成されたインスタンスのアドレスは一旦Pointer<Void>という汎用ポインタとして受け取ってます。これは、以降の操作はすべてDartのラッパークラスから行うためです。
例えば、Contextクラスを利用して別のSubSocketクラスを作成するときは以下のように使用します。

Dart
class SubSocket extends StructWrapper {
  
  final Pointer<Void> nativeInstance;
  SubSocket(Context context, String endPoint)
      : nativeInstance = Bindings()
            ._newSubSocket(context.nativeInstance, endPoint.toNativeUtf8());
}
typedef _CreateSubSocket = Pointer<Void> Function(
    Pointer<Void> cotext, Pointer<Utf8> endPoint);
class Bindings {
  ...
  late final Pointer<Void> Function(
    Pointer<Void> context, Pointer<Utf8> endPoint) _newSubSocket = _sharedLib.lookupFunction<_CreateSubSocket, _CreateSubSocket>(
          'messaging_subsocket_create');
  ...
}
C++
extern "C" SubSocket* messaging_subsocket_create(Context* context, const char* endpoint) {
  return SubSocket::create(context, std::string(endpoint));
}

こうすることでラッパークラスを使用する側からはポインタやアドレスを意識しないで済みますね。

Dart
final context = Context();
final subSocket = SubSocket(context, 'controlsState');

3-4. インスタンスの解放をどのように記述するか

上記まででC++のクラスインスタンスをDartから使用できるようになりましたが、今のままだとインスタンスを作成するだけで解放する処理が書かれていません。メモリーリークだらけのコードになってしまいそうですね。

インスタンスを解放するコードも書きたいのですが、ここで問題があります。

  • インスタンスを作成したのはC++側
  • インスタンスを利用するのはDart側

という状況なので、C++側だけで済むならunique_ptrなどを使用して、インスタンスが作成されたスコープを外れたら解放してもらえればすみますが、C++側からはDart側でどのように使用されているかは知り得ないのでいつ解放すればいいかはわかりません。

ここでdart:ffiのパッケージを見てみるとNativeFinalizerというクラスがあります。

When attached to a Dart object, this finalizer's native callback is called after the Dart object is garbage collected or becomes inaccessible for other reasons.

クラスの説明にこのようにある通り、Dartのオブジェクトがガベージコレクションされるか、アクセス不能になったら呼ばれるコールバックを定義できるようです。これを使用して、C++のインスタンスの解放を定義できるでしょうか。

先程定義したStructWrapperクラスをこのように定義しなおして、

Dart
abstract class StructWrapper implements Finalizable {
  abstract final Pointer<Void> nativeInstance;
  NativeFinalizer? _finalizer;
  StructWrapper({Pointer<NativeFinalizerFunction>? finalizerFunction}) {
    if (finalizerFunction != null) {
      _finalizer = NativeFinalizer(finalizerFunction);
      _finalizer?.attach(this, nativeInstance, detach: this);
    }
  }
}

C++側でもインスタンスを delete する処理を書いて、

C++
extern "C" void messaging_context_delete(Context* context) { 
  delete context; 
}

DartのContextクラスにもFinalizerがコールされたらC++でインスタンスを delete する関数を呼ぶための処理を追加します。

Dart
 class Context extends StructWrapper {
  
  final Pointer<Void> nativeInstance;
  Context()
      : nativeInstance = Bindings()._newContext(),
        super(finalizerFunction: Bindings()._deleteContext);
}
class Bindings {
  ...
  late final _deleteContext =
      _sharedLib.lookup<NativeFunction<Void Function(Pointer<Void>)>>(
          'messaging_context_delete');
  ...
}

念の為デストラクタがちゃんと呼ばれているか標準出力に書いて、テストを実行してみると...

C++
Context::~Context() {
  std::cout << "Context::~Context() " << this << std::endl;
  ...
}

テスト実行後にちゃんと解放されていることが確認できました!

Log
...
Context::~Context() 0x55c60fb79440
...

これでDartからC++のインスタンス作成から解放までのコードを書ける検証ができました。後はこれを他クラスにも拡張していけば良さそうです。

3-5. 解放コードの他実装案

前章までで、C++のインスタンスを解放するコードも書けそうですが、この実装だとC++のクラス毎に解放する関数を定義する必要があるので、少し面倒かもしれません。

C++
extern "C" void messaging_context_delete(Context* context) { delete context; }
extern "C" void messaging_subsocket_delete(SubSocket* socket) { delete socket; }

他の実装案として以下のようにC++のクラスにマーカーインターフェースの様な構造体を継承させて

C++
struct DartFinalizeable {};
class Context : DartFinalizeable

共通の解放コードを一つ定義して、複数クラスの解放コードをまとめられないかと試してみましたが、インスタンスの型情報が失われるので各クラスのデストラクタがうまく呼ばれないようなので断念しました。

C++
extern "C" void finalizeable_delete(DartFinalizeable* finalizeable) {
  delete finalizeable;
}

他に良い実装案をお持ちの方は是非コメントやSNSでお知らせください!

4. 終わりに

今回はdart:ffiを使用してC++の既存の資産をDartから使用する方法について紹介しました。
再度になりますが、TURINGは完全自動運転システムを搭載した"EV"の販売を目標とする会社です
このような大きな目標のためには車載システムを作るエンジニア以外にも様々なレイヤーの人材が活躍できるチャンスがあります。

一緒に新しい自動車の形を作りませんか?

詳しくは下記の採用ページをご覧ください。
https://www.wantedly.com/companies/turing-motors/projects

共同代表山本・青木どちらもDMを開放しておりますのでお気軽にご質問、お問い合わせをお送りください
@issei_y, @aoshun7

Technical blog - TURING

LV5完全自動運転EVを開発するAIテクノロジースタートアップ・TURING株式会社の公式テックブログです。

Discussion

FFIではなく、C++の部分をサーバー化してDartプロセスとRPCする方法は今回のユースケースには合いませんか?

ログインするとコメントできます