Emscripten の embind

JS を wasm にバインドします。

前回の wasm に関する記事では、C ライブラリを wasm にコンパイルしてウェブで使用できるようにする方法について説明しました。私(と多くの読者)が特に気になったのが、使用する wasm モジュールの関数を手動で宣言する方法が粗雑で少し扱いにくいことです。以下に、ここで説明するコード スニペットを示します。

const api = {
    version
: Module.cwrap('version', 'number', []),
    create_buffer
: Module.cwrap('create_buffer', 'number', ['number', 'number']),
    destroy_buffer
: Module.cwrap('destroy_buffer', '', ['number']),
};

ここでは、EMSCRIPTEN_KEEPALIVE でマークした関数の名前、戻り値の型、引数の型を宣言します。その後、api オブジェクトのメソッドを使用してこれらの関数を呼び出すことができます。ただし、このように wasm を使用すると文字列はサポートされず、メモリのチャンクを手動で移動する必要があるため、多くのライブラリ API の使用が非常に面倒になります。もっと良い方法はないでしょうか。はい、あります。そうでなければ、この記事は何について書かれているのか?

C++ 名前マングリング

開発者の利便性だけでも、これらのバインディングに役立つツールを構築する理由になりますが、実際にはもっと切迫した理由があります。C コードまたは C++ コードをコンパイルすると、各ファイルが個別にコンパイルされます。その後、リンカーがこれらのいわゆるオブジェクト ファイルをすべてまとめて、wasm ファイルに変換します。C では、関数名はオブジェクト ファイルで引き続き使用でき、リンカーが使用できます。C 関数を呼び出すために必要なのは名前だけです。この名前は、cwrap() に文字列として提供します。

一方、C++ は関数のオーバーロードをサポートしています。つまり、シグネチャが異なる限り(パラメータの型が異なるなど)、同じ関数を複数回実装できます。コンパイラ レベルでは、add などのわかりやすい名前は、リンカーの関数名にシグネチャをエンコードする名前にマングリングされます。その結果、名前で関数を検索できなくなります。

embind を入力します。

embind は Emscripten ツールチェーンの一部であり、C++ コードにアノテーションを付けることができる C++ マクロを多数提供します。JavaScript から使用する関数、列挙型、クラス、値型を宣言できます。簡単な関数から始めましょう。

#include <emscripten/bind.h>

using namespace emscripten;

double add(double a, double b) {
   
return a + b;
}

std
::string exclaim(std::string message) {
   
return message + "!";
}

EMSCRIPTEN_BINDINGS
(my_module) {
    function
("add", &add);
    function
("exclaim", &exclaim);
}

前回の記事と比較して、関数に EMSCRIPTEN_KEEPALIVE のアノテーションを付ける必要がなくなったため、emscripten.h は含まれていません。代わりに、EMSCRIPTEN_BINDINGS セクションがあり、ここで関数を JavaScript に公開する名前を指定します。

このファイルをコンパイルするには、前の記事と同じ設定(必要に応じて同じ Docker イメージ)を使用できます。embind を使用するには、--bind フラグを追加します。

$ emcc --bind -O3 add.cpp

残す作業は、新しく作成した wasm モジュールを読み込む HTML ファイルを作成することだけです。

<script src="/a.out.js"></script>
<script>
Module.onRuntimeInitialized = _ => {
    console
.log(Module.add(1, 2.3));
    console
.log(Module.exclaim("hello world"));
};
</script>

ご覧のとおり、cwrap() は使用されなくなりました。開封してすぐに使用できます。さらに重要なのは、文字列を機能させるためにメモリのチャンクを手動でコピーする必要がないことです。embind では、型チェックとともにこの処理が自動的に行われます。

引数の数が間違っているか、引数の型が間違っている関数を呼び出すと、DevTools でエラーが発生する

これは非常に便利です。場合によっては扱いにくい wasm エラーに対処するのではなく、エラーを早期にキャッチできます。

オブジェクト

多くの JavaScript コンストラクタと関数はオプション オブジェクトを使用します。これは JavaScript では優れたパターンですが、wasm で手動で実現するのは非常に面倒です。embind もここで役立ちます。

たとえば、文字列を処理する非常に便利な C++ 関数を思いつきました。この関数をウェブですぐに使用したいと思っています。手順は次のとおりです。

#include <emscripten/bind.h>
#include <algorithm>

using namespace emscripten;

struct ProcessMessageOpts {
   
bool reverse;
   
bool exclaim;
   
int repeat;
};

std
::string processMessage(std::string message, ProcessMessageOpts opts) {
    std
::string copy = std::string(message);
   
if(opts.reverse) {
    std
::reverse(copy.begin(), copy.end());
   
}
   
if(opts.exclaim) {
    copy
+= "!";
   
}
    std
::string acc = std::string("");
   
for(int i = 0; i < opts.repeat; i++) {
    acc
+= copy;
   
}
   
return acc;
}

EMSCRIPTEN_BINDINGS
(my_module) {
    value_object
<ProcessMessageOpts>("ProcessMessageOpts")
   
.field("reverse", &ProcessMessageOpts::reverse)
   
.field("exclaim", &ProcessMessageOpts::exclaim)
   
.field("repeat", &ProcessMessageOpts::repeat);

    function
("processMessage", &processMessage);
}

processMessage() 関数のオプションの構造体を定義しています。EMSCRIPTEN_BINDINGS ブロックで value_object を使用すると、JavaScript はこの C++ 値をオブジェクトとして認識します。この C++ 値を配列として使用したい場合は、value_array を使用することもできます。また、processMessage() 関数もバインドします。残りは embind のマジックです。これで、ボイラープレート コードなしで JavaScript から processMessage() 関数を呼び出せるようになりました。

console.log(Module.processMessage(
   
"hello world",
   
{
    reverse
: false,
    exclaim
: true,
    repeat
: 3
   
}
)); // Prints "hello world!hello world!hello world!"

クラス

完全性を保つために、embind を使用してクラス全体を公開する方法についても説明します。これにより、ES6 クラスとのシナジー効果が大幅に高まります。ここまでで、ある程度パターンが見えてきたのではないでしょうか。

#include <emscripten/bind.h>
#include <algorithm>

using namespace emscripten;

class Counter {
public:
   
int counter;

   
Counter(int init) :
    counter
(init) {
   
}

   
void increase() {
    counter
++;
   
}

   
int squareCounter() {
   
return counter * counter;
   
}
};

EMSCRIPTEN_BINDINGS
(my_module) {
    class_
<Counter>("Counter")
   
.constructor<int>()
   
.function("increase", &Counter::increase)
   
.function("squareCounter", &Counter::squareCounter)
   
.property("counter", &Counter::counter);
}

JavaScript 側では、これはネイティブ クラスのように見えます。

<script src="/a.out.js"></script>
<script>
Module.onRuntimeInitialized = _ => {
   
const c = new Module.Counter(22);
    console
.log(c.counter); // prints 22
    c
.increase();
    console
.log(c.counter); // prints 23
    console
.log(c.squareCounter()); // prints 529
};
</script>

C はどうですか?

embind は C++ 用に記述されており、C++ ファイルでのみ使用できますが、C ファイルにリンクできないわけではありません。C と C++ を混在させるには、入力ファイルを C ファイルと C++ ファイルの 2 つのグループに分割し、emcc の CLI フラグを次のように拡張するだけです。

$ emcc --bind -O3 --std=c++11 a_c_file.c another_c_file.c -x c++ your_cpp_file.cpp

まとめ

embind を使用すると、wasm と C/C++ を扱う際のデベロッパー エクスペリエンスが大幅に向上します。この記事では、embind が提供するすべてのオプションについて説明しません。興味がある場合は、embind のドキュメントを参照することをおすすめします。embind を使用すると、gzip 圧縮時に wasm モジュールと JavaScript グルーコードの両方が最大 11,000 バイト増加する可能性があります(特に小さいモジュールの場合)。wasm サーフェスが非常に小さい場合、本番環境で embind を使用すると費用対効果が悪くなる可能性があります。それでも、ぜひ試してみてください。