はじめに
この記事は C++ & emscripten & WebAssembly (wasm) について一通り知っている人向けの内容です。wasm や emscripten の基礎的な部分に興味があれば、拙作 C++er 向けの WebAssembly 入門 をどうぞ。
あらまし
C++ & emscripten で wasm を作って JavaScript 側にインターフェースを公開する場合、embind を使うのが手っ取り早い。
embind は pybind11 によく似たバインダだ。pybind11 を知らなくても、例を見れば使い方はすぐわかるだろう。例えば以下のような class があり、これを JavaScript 側に公開したいとする。
class MyClass
{
public:
MyClass();
int getData() const;
void setData(int v);
void doSomething();
};
embind によるバインダは以下のようになる。
EMSCRIPTEN_BINDINGS(MyClass)
{
using namespace emscripten;
class_<MyClass>("MyClass")
.constructor()
.property("data", &MyClass::getData, &MyClass::setData)
.function("doSomething", &MyClass::doSomething)
;
}
JavaScript 側でこの class を使うコードは以下のようになる。
let hoge = new Module.MyClass();
hoge.data = 100;
hoge.doSomething();
hoge.delete(); // delete() が必要な点に注意!
…と、ここまではいろんなところで紹介されている内容だが、実際に embind を使っているといくつかハマりどころがある。以下は個人的にハマった部分とその対策になる。
参照の取り扱い
個人的に embind の最大のハマりどころがこれになる。
参照を返す関数を embind で登録して JavaScript 側から呼んだ場合、返ってくるのはその参照のコピーになる。コピーは new により作成され、そのポインタが JavaScript 側に返される。JavaScript 側は delete() による後始末が必要になる。
// C++ 側
class MyClass
{
public:
~MyClass()
{
printf("MyClass::~MyClass(): %p\n", this);
}
const MyClass& getReference() const
{
return *this;
}
};
EMSCRIPTEN_BINDINGS(MyClass)
{
using namespace emscripten;
class_<MyClass>("MyClass")
.constructor()
.function("getReference", &MyClass::getReference)
;
}
// JavaScript 側
let hoge = new Module.MyClass();
let r = hoge.getReference();
r.delete();
hoge.delete();
/*
出力例:
MyClass::~MyClass(): 0x18808
MyClass::~MyClass(): 0x187f8
*/
参照の実体が継承されたオブジェクトの場合、コピーの際に継承された部分が切り捨てられる、いわゆる slicing 問題が起きる。そうでなくてもほとんどの場合コピーは意図した挙動ではないはずで、不用意に参照を embind に絡めないように注意が必要になる。
ポインタの取り扱い
このように参照は厄介であるため、JavaScript 側に渡すのはポインタにしておきたい。しかし、embind はポインタの扱いには厳しい。
ポインタを扱う一番自明な方法は allow_raw_pointers() だろう。これは function() などの最後の引数に渡すパラメータで、ポインタを返したり受け取ったりする関数もバインドできるようになる。
class MyClass
{
public:
MyClass* getPointer() const;
void setPointer(MyClass* v);
};
EMSCRIPTEN_BINDINGS(MyClass)
{
using namespace emscripten;
// allow_raw_pointers() がないとコンパイルエラーになる
class_<MyClass>("MyClass")
.constructor()
.function("getPointer", &MyClass::getPointer, allow_raw_pointers())
.function("setPoninter", &MyClass::setPointer, allow_raw_pointers())
;
}
これで済む状況ならこれでいいのだが、property() は allow_raw_pointers() を指定できず対応できないといった問題も残る。抜本的に解決しようと思うと、ポインタを val と相互に変換する手段が欲しくなってくる。
val (emscripten::val) は JavaScript 側の変数に対応する C++ 側の class だ。数値型や std::string は val と相互に変換できるようになっているが、ポインタの相互変換はわかりやすい方法が用意されてない。しかし、以下のようなコードで実現できる。
using emscripten::val; // 以後省略
// ポインタから val
template<typename T>
inline val pointer_to_val(const T* p)
{
using namespace emscripten;
return p ?
val::take_ownership(internal::_emval_take_value(internal::TypeID<T>::get(), &p)) :
val::null();
}
// val からポインタ
template<class T>
inline T as_pointer(val v)
{
static_assert(std::is_pointer_v<T>);
// val::as() は allow_raw_pointers() を指定すればポインタへの変換を許可している
return v.as<T>(emscripten::allow_raw_pointers());
}
T は emscripten::class_() で登録されている型である必要がある。
pointer_to_val() は nullptr の場合 JavaScript 側に null を返しているが、これは JavaScript 側でのチェックを楽にするため。これを省いた場合、JavaScript 側での null チェックは後述の $$.ptr
を用いる必要がある。
これらを用いて先の getPointer() / setPointer() を property として実装すると以下のようになる。
class MyClass
{
public:
MyClass* getPointer() const;
void setPointer(MyClass* v);
};
EMSCRIPTEN_BINDINGS(MyClass)
{
using namespace emscripten;
// ポインタ↔val の変換を lambda で行う。
// lambda は std::function で包むと function() や property() に渡せる。
class_<MyClass>("MyClass")
.constructor()
.property("pointer",
std::function{ [](const MyClass& self) { return pointer_to_val(self.getPointer()); } },
std::function{ [](MyClass& self, val v) { return self.setPointer(as_pointer<MyClass*>(v)); } }
)
;
}
ちなみに、C++ 側の class は JavaScript 側では ClassHandle というオブジェクトに変換され、このオブジェクトは $$.ptr
にポインタを保持している。JavaScript 側で nullptr 判別などを行うにはこれを参照する。
C++ 側からも val v に対して v["$$"]["ptr"] でアクセス可能である。(これが必要になるケースはないと思われるが)
また、現行の wasm はメモリ空間が 32bit であり、sizeof(void*) == sizeof(int) であるため、ポインタを int にキャストして JavaScript 側の Number と相互変換することも可能である。とはいえ、当然これはバグを誘発しやすいので、本当に必要なケース以外では避けたほうがよいだろう。
参照の取り扱い (2)
ポインタ↔val が実現できたので、参照もポインタへの変換を挟みつつバインドする選択肢が取れる。
class MyClass
{
public:
MyClass& getSomething() const;
void setSomething(MyClass& v);
};
EMSCRIPTEN_BINDINGS(MyClass)
{
using namespace emscripten;
class_<MyClass>("MyClass")
.constructor()
.property("something",
std::function{ [](const MyClass& self) { return pointer_to_val(&self.getSomething()); } },
std::function{ [](MyClass& self, val v) { return self.setSomething(*as_pointer<MyClass*>(v)); } }
)
;
}
lambda をバインドする
これまでの例で既に出ているが、lambda も間接的にバインドできる。
なぜかドキュメントには書かれていないが、embind の function() や property() は関数、メンバ関数に加え、std::function もバインドできるようになっている。
lambda は直接は受け付けないが、std::function で包めば可能だ。C++17 以降であればテンプレートのパラメータはコンパイラが推測してくれるので、単純に std::function { [](MyClass& self) { /* ... */ } }
のように書けばよい。
memory view として JavaScript 側に返す
数値型の配列は std::span 的に memory view として JavaScript 側に渡すことができる。例えば int の配列は JavaScript 側では Int32Array、float の配列は FloatArray として扱うといった具合だ。この場合、メモリの内容は C++ 側と JavaScript 側で共有される。ローレベルな処理を行う際に重宝する。
template<typename T>
inline val span_to_val(const T* data, size_t size)
{
// 数値型のみ受け付ける
static_assert(std::is_arithmetic_v<T>);
return val{ emscripten::typed_memory_view(size, data) };
}
ちなみに、数値型の std::vector は val::array() に渡すと memory view として JavaScript 側に渡されるのだが、この場合データはコピーが作られ、C++ 側と共有はされない。
iterable として JavaScript 側に返す
例えば要素数 100 万の std::vector<std::string> があったとして、それを JavaScript 側に公開する場合、Array を作成して 100 万要素全てをそれに格納するのはやりたくないだろう。こういう場合 begin() と end() だけを渡し、iterable として扱いたい。
若干面倒だがこれも可能だ。通常の JavaScript 同様、next(), value, done を実装したオブジェクトで巡回を実装し。それを @@iterator
関数で返せばよい。
template<class T>
inline val to_val(const T& v)
{
if constexpr (std::is_arithmetic_v<T>) {
if constexpr (std::is_integral_v<T> && sizeof(T) >= 8) {
// 64 bit int は明示的変換が必要
return val((double)v);
}
else {
return val(v);
}
}
else if constexpr (std::is_same_v<T, std::string> || std::is_same_v<T, std::wstring>) {
return val(v);
}
else if constexpr (std::is_pointer_v<T>) {
return pointer_to_val(v);
}
else {
// 数値型、文字列、ポインタ以外はユーザー定義 class とみなしてポインタを返す。
// (std::vector は val::array() にしたいなど他にもあると思われるが、ここでは省略)
return pointer_to_val(&v);
}
}
template<class Iter>
class Iterable
{
public:
Iterable(Iter begin, Iter end) :
begin_(begin), end_(end)
{
static struct binder {
binder() {
emscripten::class_<Iterable>(typeid(Iterable).name())
.function("@@iterator", &Iterable::iterator)
.function("next", &Iterable::next);
}
} bind;
current_ = val::object();
current_.set("done", false);
}
val iterator()
{
return pointer_to_val(this);
}
val next()
{
if (begin_ != end_) {
current_.set("value", to_val(*begin_++));
return current_;
}
else {
// delete this で自身が破棄されるので current_ を move しておく
val tmp = std::move(current_);
tmp.set("done", true);
delete this;
return tmp;
}
}
private:
Iter begin_, end_;
val current_;
};
template<class Iter>
inline val range_to_val(Iter begin, Iter end)
{
return pointer_to_val(new Iterable<Iter>(begin, end));
}
delete this
が危なっかしくてちょっと嫌な感じだが、自動で巡回終了と同時に消えてもらうにはこういう対処が必要になると思われる。
JavaScript では普通 @@iterator
を提供するオブジェクトと iterator オブジェクトそのものは別になっていると思われるが、一緒でも特に問題はないため、簡便のためそうしている。
可変長引数の関数をバインドしたい
2024/10/01 更新
2024/10/01 リリースの 3.1.68 で、std::optional な引数は JavaScript 側でこれを省略できるようになった。これにより、可変長引数やデフォルト引数をサポートできるようになった。
ただし、C++ 上のデフォルト引数を暗黙にそのままバインドできるわけではなく、std::optional をハンドリングする処理を自分で書く必要がある。
class Hoge
{
public:
void test(int arg1, std::optional<float> _arg2, std::optional<std::string> _arg3)
{
float arg2 = _arg2 ? _arg2.value() : 1.0f; // arg2 は デフォルト値 1.0
std::string arg3 = _arg3 ? _arg3.value() : "default"; // arg3 は デフォルト値 "default"
printf("Hoge::test(): %d %f %s\n", arg1, arg2, arg3.c_str());
}
};
EMSCRIPTEN_BINDINGS(test)
{
using namespace emscripten;
register_optional<float>();
register_optional<std::string>();
class_<Hoge>("Hoge")
.constructor()
.function("test", &Hoge::test);
}
// JavaScript 側
let hoge = new Module.Hoge();
hoge.test(1);
hoge.test(1, 1000);
hoge.test(1, 1000, "abc");
hoge.delete();
// 出力:
// Hoge::test(): 1 1.000000 default
// Hoge::test(): 1 1000.000000 default
// Hoge::test(): 1 1000.000000 abc
JavaScript で複雑な引数を扱うときにやるように、Object の引数を取ることでも実現することはできる。(3.1.68 未満ではそうするしかなかった)
// C++ 側
class Hoge
{
public:
void test(std::string arg1 = "str", int arg2 = 100, float arg3 = 0.01f)
{
printf("arg1: %s, arg2: %d, arg3: %f\n", arg1.c_str(), arg2, arg3);
}
};
EMSCRIPTEN_BINDINGS(Hoge)
{
using namespace emscripten;
class_<Hoge>("Hoge")
.constructor()
.function("test", std::function { [](Hoge& self, val args) {
std::string arg1 = "str";
int arg2 = 100;
float arg3 = 0.01f;
if (val tmp = args["arg1"]; tmp.isString()) {
arg1 = tmp.as<std::string>();
}
if (val tmp = args["arg2"]; tmp.isNumber()) {
arg2 = tmp.as<int>();
}
if (val tmp = args["arg3"]; tmp.isNumber()) {
arg3 = tmp.as<float>();
}
self.test(arg1, arg2, arg3);
}});
}
// JavaScript 側
let hoge = new Module.Hoge();
hoge.test({ arg1: "^_^", arg3: 0.111 }); // arg2 は未指定だが問題ない
hoge.delete();
EMSCRIPTEN_KEEPALIVE との違い
関数を JavaScript 側に公開する場合、embind の emscripten::function() の他に EMSCRIPTEN_KEEPALIVE を使う方法がある。
EMSCRIPTEN_KEEPALIVE は指定すると export address table に登録されるもので、要するに dllexport 相当品と考えてよい。この場合、JavaScript 側には wasmExports に登録される形で公開される。
// C++ 側
// 名前の mangling を避けるため、extern "C" も指定
EMSCRIPTEN_KEEPALIVE extern "C" void FunctionByKeepAlive(int value)
{
printf("FunctionByKeepAlive(): %d\n", value);
}
void FunctionByEmbind(int value)
{
printf("FunctionByEmbind(): %d\n", value);
}
EMSCRIPTEN_BINDINGS(Test)
{
using namespace emscripten;
function("FunctionByEmbind", &FunctionByEmbind);
}
// JavaScript 側
wasmExports.FunctionByKeepAlive(100);
Module.FunctionByEmbind(200);
この 2 者には大きな違いがある。
embind で登録した関数は、wasm バイナリ上の関数そのものではなく、引数のチェック・変換などを行ってから wasm バイナリ上の関数を呼ぶ wrapper 関数となる。一方、EMSCRIPTEN_KEEPALIVE で登録した関数は wasm バイナリ上の関数そのものになる。
EMSCRIPTEN_KEEPALIVE の場合、引数は数値型しか正しく解釈できない。embind では引数が std::string や val などでも問題なく呼べるが、これは wrapper 関数がいい感じに変換しているからだ。
例として、EMSCRIPTEN_KEEPALIVE の関数に文字列を渡すケースを考える。この場合、引数が const char* の関数を JavaScript 側に公開し、JavaScript 側から malloc() などで wasm 世界のメモリを確保し、その領域に文字列を書き込み、ポインタを関数に渡す必要がある。
// C++ 側
EMSCRIPTEN_KEEPALIVE extern "C" void FunctionByKeepAlive(const char* str)
{
printf("FunctionByKeepAlive(): %s\n", str);
}
// ライブラリ関数 stringToUTF8 も JavaScript 側に公開する必要がある
// (コンパイルオプション -sEXPORTED_RUNTIME_METHODS=stringToUTF8)
// JavaScript 側
function call_FunctionByKeepAlive(str)
{
let maxLength = str.length * 4 + 1; // 必要とされうる最大 byte 数
let ptr = wasmExports.malloc(maxLength);
Module.stringToUTF8(str, ptr, maxLength);
wasmExports.FunctionByKeepAlive(ptr);
wasmExports.free(ptr);
}
call_FunctionByKeepAlive("hello!");
/*
出力:
FunctionByKeepAlive(): hello!
*/
人力でこんな面倒なことは普通やらないと思われるが、embind は裏でこういう類の変換処理を行う。val の変換はもっと複雑で、頻繁に呼ぶケースでは無視できないオーバーヘッドが生じうる。シビアにパフォーマンスを求める場合、embind を経由せず wasm バイナリ上の関数を直接呼びたくなることもあるかもしれない。
Chrome 上で C++ ソースをデバッグする
embind そのものの話ではないが、わりと困ったところだったので…。
Google が提供している DWARF 拡張 により Chrome 上で直接 C++ ソースをデバッグできる。emscripten を使うなら必須である。拡張インストールでそのままデバッグできているなら問題ないのだが、以下はすんなり行かなかった場合の話。
私は基本 Windows で、emscripten は WSL の Ubuntu で動かしている。(Windows 上で直接 emscripten を動かそうとすると色々面倒なため、この構成の方は結構いるのではないかと思われる)
この場合、デバッグ情報に含まれるファイルのパスは Linux 形式になり、しかし実行時には Windows 形式のパスを求められるため、ソースファイルを見つけられずデバッグできなくなってしまう。
これは上記拡張の設定で解決できる。(設定の場所がわかりにくい…)