🧞‍♂️

TypeScriptからC++のコードを呼び出す

2024/06/14に公開

C++のコードをTypeScriptで実行したいという稀?な機会に遭遇したので,その方法をまとめてみました.

言語の壁を越える

TypeScriptでプログラムを書くときは,npmやyarnなどのパッケージマネージャーを利用してオンラインで公開されているJavaScript/TypeScriptライブラリを使用することができます.しかし,利用したい機能を備えたライブラリがJavaScript/TypeScriptではなくCやC++で書かれている場合はどうでしょうか.そのままではnpmではインストールできないのはもちろん,モジュールとしてインポートすることもできません.

一つの解決策として,自分でJavaScript/TypeScriptのコードに書き直す方法があります.ライブラリの規模が小さければこの方法でも十分対処できますが,ファイルの数やコードの行数が数百,数千にもなるとすべて対応するのは難しくなります.

今回はEmscriptenを使ってC++のコードをTypeScriptで実行する方法を試してみました.

Emscripten

EmscriptenはC++のコードをTypeScriptで実行できる形式にトランスコンパイルします.この方法では,C++側で少しコードを書き加えるだけで,C++のコードに対応したWebAssemblyモジュールファイル (WASM) とそれを読みだす処理を実装したJavaScriptのES6モジュール,そしてモジュールの型定義ファイルを作成することができます.C++のコードはWASMにコンパイルされるので,JavaScriptで実装する場合よりも高速に動作することが期待できます.またJavaScriptによるWASMの読み込み処理もEmscriptenが専用モジュールを自動で生成してくれるので,非常に便利です.

C++側の準備

C++で書かれたコードをTypeScriptで読み出せるようにするには,あらかじめC++側でEmscriptenにコンパイル対象とする関数やクラスを明示的に知らせる必要があります.コンパイル対象であると明示する方法はいくつかありますが,今回はembindを用いた方法を紹介します.

コンパイルするファイルにemscripten/bind.hをインクルードしておきます.このヘッダーファイルにembindで使用するオブジェクトが用意されています.

Emscriptenによって生成されるWASMで公開したい関数やクラスは,コンパイルするファイルのEMSCRIPTEN_BINDINGSで囲まれたコードブロック中でその名前,型情報などをembindで用意されたオブジェクトに登録にしていきます.

#include <iostream>

#ifdef __EMSCRIPTEN__
#include <emscripten/bind.h>
#endif

void my_func() {
  std::cout << "function called" << std::endl;
}

int my_calc(int v) {
  return v + 1;
}

#ifdef __EMSCRIPTEN__
EMSCRIPTEN_BINDINGS(my_module) {
  emscripten::function("my_func", &my_func);
  emscripten::function("my_calc", &my_calc);
}
#endif

上記の例は2つの関数 (my_func, my_calc) をWASMで公開します.公開する関数のポインターをemscripten::functionの第2引数に渡し,第1引数にはTypeScript側で使用するときの関数名を設定します.

クラスも同様にEMSCRIPTEN_BINDINGSコードブロックの中でクラスの構造を登録していきます.

class MyClass {
 private:
  int hoge_;

 public:
  MyClass(int value) noexcept : hoge_(value) {}
  int getHoge() const noexcept { return hoge_; }
  void hogeHoge() noexcept { ++hoge_; }
};

#ifdef __EMSCRIPTEN__
EMSCRIPTEN_BINDINGS(my_module) {
  emscripten::class_<MyClass>("MyClass")
    .constructor<int>()
    .property("hoge", &MyClass::getHoge)
    .function("hogeHoge", &MyClass::hogeHoge);
}
#endif

emscripten::class_<T>オブジェクトにクラスの型とTypeScript側で公開するときの名前を渡します.またこのオブジェクトのメソッドでクラスのメンバー変数やメンバー関数を定義していきます.

メソッド 説明
.constructor<T> クラスのコンストラクター.ジェネリクスにコンストラクターが受け取る引数の型を列挙する.
.property クラスのメンバー変数.第1引数はTypeScript側でのプロパティ名.第2引数はgetter関数.第3引数はsetter関数.第3引数を省略するとreadonlyなプロパティになる.
.function クラスのメンバー関数.第1引数はTypeScript側でのプロパティ名.第2引数はメンバー関数の関数ポインター.
.class_function クラスの静的メンバー関数.第1引数はTypeScript側でのプロパティ名.第2引数はメンバー関数の関数ポインター.

なお,.propertyはメンバー変数のポインターを直接指定する方法もあります.そのほかの指定方法はbind.hのリファレンスに記載されています.

Emscriptenの導入

Emscriptenは公式マニュアルにダウンロードとインストール方法が書かれていますが,今回私はスポット的に使いたかったため,公式が用意しているSDK入りのDockerイメージ (emscripten/emsdk) を利用しました.

https://hub.docker.com/r/emscripten/emsdk

pullでイメージを取得します.

docker pull emscripten/emsdk

Emscriptenの実行コマンド

Emscriptenの基本的なコンパイルコマンドを以下に示します.

docker run \
  --rm \
  -v $(pwd):/src \
  -u $(id -u):$(id -g) \
  emscripten/emsdk \
  emcc hello.cpp -o hello.js

コンパイルしたいC++のコードを特定のディレクトリに配置し,そのディレクトリをコンテナの/srcにマウントしておきます.またコンテナの実行時ユーザーをホスト側のユーザーに指定しておくことで,コンパイルされたファイルがrootユーザーで作成されることを防ぎます.

emccコマンドにコンパイル対象のC++ファイルを引数に渡し,-oオプションでコンパイル後のファイル名(JavaScriptファイル)を指定します.なお,このコンテナ内のカレントディレクトリは/srcになります.

TypeScriptで使用するためのコンパイルオプション

emcc-sオプションではEmscriptenによるビルド時の動作を設定します.コンパイルしたWASMをモジュールとして使用するために,以下のオプションが用意されています.

オプション 説明
WASM コンパイルした後にWASMを作成するか設定する.
"0"で生成しない(JSファイルのみ).
"1"でWASMとそれをロードする処理を実装したJSファイルを作成する.
"2"は"0"と"1"で作られる全ファイルを作成する.
デフォルトは"1".
MODULARIZE モジュール化するかどうか.
"0"でモジュール化しない(関数やクラスをグローバルスコープに展開).
"1"でモジュール化する(モジュール作成関数によって読み込む).
デフォルトは"0".
EXPORT_ES6 ES6のモジュール形式でモジュール化するかどうか.
"1"の時にES6モジュールを作成する(自動でMODULARIZE=1となる).また出力ファイル名の拡張子を"mjs"にすると自動で有効になる.
デフォルトは"0".

今回はWASMを作成し,ES6モジュールとして読み込みたいので-sEXPORT_ES6=1と設定します.

作成されるモジュールをTypeScriptで使用する際に,モジュールの型定義ファイルがあると,コンパイラによる型チェックやIDEやエディターの補完機能の恩恵を受けることができます.--emit-tsd <path>オプションを使用すると,Emscriptenが指定したパスに作成したモジュールの型定義ファイルを自動で作成してくれます.

なお,型定義ファイルの作成にはTypeScriptコンパイラー (tsc) が必要です.今回使用するDockerイメージにはtscが用意されていないので,コンパイル前に別途インストールが必要です.

また先述のembindを利用して関数やクラスを公開する場合は-lembindオプションが必要になります.

今回のコンパイルの最終的なコマンドは以下のようになりました.これでsrcディレクトリ内にhello.wasmとhello.js,hello.d.tsファイルが作成されます.

docker run --rm -it -v ./src:/src -u $(id -u):$(id -g) \
  emscripten/emsdk bash -c "npm i -g typescript && \
  emcc hello.cpp -o hello.js -lembind -sEXPORT_ES6=1 --emit-tsd hello.d.ts"
dockerではまったところ

今回の私の作業はWindowsのWSL2 (Ubuntu) で行っていました.ここで上記のコマンドを実行すると,npm i -g typescriptの箇所でエラー (npm ERR! code EAI_AGAIN) が発生することがありました.

これはDockerコンテナー内でDNSの名前解決に失敗していることが原因です.解決方法はいくつかありますが,今回は以下の記事を参考に,コンテナー起動時に--network=hostオプションをつけて解決しました.

https://qiita.com/georgeOsdDev@github/items/83a5f8381db3276f031b

修正後のコマンド:

docker run --rm -it -v ./src:/src -u $(id -u):$(id -g) --network=host \
  emscripten/emsdk bash -c "npm i -g typescript && \
  emcc hello.cpp -o hello.js -lembind -sEXPORT_ES6=1 --emit-tsd hello.d.ts"

TypeScript側での呼び出しかた

コンパイルしたC++の関数,クラスをTypeScriptのコード内で使用するには,作成したモジュールファイルをインポートします.モジュールではモジュールの実体を非同期処理で作成する関数がデフォルトエクスポートされているので,それを受け取り実行します.関数の戻り値 (Promise) のthenメソッドのコールバックの引数にモジュールの実体が渡されるので,そこからC++で実装した関数やクラスを呼び出すことができます.

import createHello from "./hello";

createHello().then((mod) => {
  mod.my_func();  // "function called"
  console.log(mod.my_calc(1));  // "2"
});

モジュール作成関数はPromiseを返すので,async/awaitでモジュールの実体を受け取ることもできます.

(async () => {
  const mod = await createHello();
  mod.my_func();
})();

なおC++で定義したクラスを使用する場合は,インスタンスが不要になった際にdeleteメソッドの実行が必要です.実行し忘れるとメモリリークが発生するので注意しましょう.

createModule().then((mod) => {
  const myClass = new mod.MyClass(1);

  console.log(myClass.hoge);  // "1"
  myClass.hogeHoge();
  console.log(myClass.hoge);  // "2"

  myClass.delete();  // 忘れずに!
});

まとめ

今回はEmscriptenを用いてC++で実装した関数やクラスをTypeScriptで実行してみました.特に難しい設定をせずサクサクと進めることができました.

WASMを作成することが目的であればRustを用いるのが主流だと思いますが,過去のC/C++の資産を活用したい場合には今回の手法が有用だと思います.

Discussion