Es bindet JS an Ihr WASM.
In meinem letzten WASM-Artikel habe ich beschrieben, wie man eine C-Bibliothek in WASM kompiliert, damit sie im Web verwendet werden kann. Eine Sache, die mir (und vielen Lesern) aufgefallen ist, ist die umständliche und etwas unbeholfene Art und Weise, wie Sie manuell deklarieren müssen, welche Funktionen Ihres WASM-Moduls Sie verwenden. Zur Erinnerung: Das ist das Code-Snippet, um das es geht:
const api = {
version: Module.cwrap('version', 'number', []),
create_buffer: Module.cwrap('create_buffer', 'number', ['number', 'number']),
destroy_buffer: Module.cwrap('destroy_buffer', '', ['number']),
};
Hier deklarieren wir die Namen der Funktionen, die wir mit EMSCRIPTEN_KEEPALIVE
markiert haben, sowie ihre Rückgabetypen und die Typen ihrer Argumente. Anschließend können wir die Methoden für das api
-Objekt verwenden, um diese Funktionen aufzurufen. Bei dieser Verwendung von WASM werden jedoch keine Strings unterstützt und Sie müssen Speicherblöcke manuell verschieben, was die Verwendung vieler Bibliotheks-APIs sehr mühsam macht. Gibt es keine bessere Lösung? Ja, sonst gäbe es diesen Artikel nicht.
C++-Namensmangling
Die Entwicklerfreundlichkeit wäre Grund genug, ein Tool zu entwickeln, das bei diesen Bindungen hilft. Es gibt jedoch einen dringenderen Grund: Wenn Sie C- oder C++-Code kompilieren, wird jede Datei separat kompiliert. Anschließend werden alle diese sogenannten Objektdateien von einem Linker zusammengeführt und in eine WASM-Datei umgewandelt. In C sind die Namen der Funktionen weiterhin in der Objektdatei verfügbar, damit der Linker sie verwenden kann. Um eine C-Funktion aufrufen zu können, benötigen Sie nur den Namen, den wir als String an cwrap()
übergeben.
C++ unterstützt dagegen die Überladung von Funktionen. Das bedeutet, dass Sie dieselbe Funktion mehrmals implementieren können, solange die Signatur unterschiedlich ist (z. B. Parameter mit unterschiedlichen Typen). Auf Compilerebene wird ein Name wie add
in etwas umgewandelt, das die Signatur im Funktionsnamen für den Linker codiert. Daher können wir unsere Funktion nicht mehr über ihren Namen aufrufen.
embind eingeben
embind ist Teil der Emscripten-Toolchain und bietet eine Reihe von C++-Makros, mit denen Sie C++-Code annotieren können. Sie können deklarieren, welche Funktionen, Enums, Klassen oder Werttypen Sie in JavaScript verwenden möchten. Beginnen wir mit einigen einfachen Funktionen:
#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);
}
Im Vergleich zu meinem vorherigen Artikel wird emscripten.h
nicht mehr verwendet, da wir unsere Funktionen nicht mehr mit EMSCRIPTEN_KEEPALIVE
annotieren müssen.
Stattdessen haben wir einen EMSCRIPTEN_BINDINGS
-Abschnitt, in dem wir die Namen auflisten, unter denen wir unsere Funktionen für JavaScript verfügbar machen möchten.
Zum Kompilieren dieser Datei können wir dieselbe Einrichtung (oder, wenn Sie möchten, dasselbe Docker-Image) wie im vorherigen Artikel verwenden. Um embind zu verwenden, fügen wir das Flag --bind
hinzu:
$ emcc --bind -O3 add.cpp
Jetzt müssen wir nur noch eine HTML-Datei erstellen, mit der unser neu erstelltes WASM-Modul geladen wird:
<script src="/a.out.js"></script>
<script>
Module.onRuntimeInitialized = _ => {
console.log(Module.add(1, 2.3));
console.log(Module.exclaim("hello world"));
};
</script>
Wie Sie sehen, verwenden wir cwrap()
nicht mehr. Das funktioniert sofort. Noch wichtiger ist jedoch, dass wir uns nicht mehr darum kümmern müssen, Speicherblöcke manuell zu kopieren, damit Strings funktionieren. embind bietet Ihnen das kostenlos, zusammen mit Typüberprüfungen:

Das ist sehr praktisch, da wir einige Fehler frühzeitig erkennen können, anstatt uns mit den manchmal recht unhandlichen WASM-Fehlern auseinandersetzen zu müssen.
Objekte
Viele JavaScript-Konstruktoren und -Funktionen verwenden Optionenobjekte. Das ist ein schönes Muster in JavaScript, aber es ist extrem mühsam, es manuell in WASM zu realisieren. embind kann auch hier helfen.
Ich habe zum Beispiel diese unglaublich nützliche C++-Funktion entwickelt, die meine Strings verarbeitet, und ich möchte sie dringend im Web verwenden. So bin ich vorgegangen:
#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);
}
Ich definiere eine Struktur für die Optionen meiner processMessage()
-Funktion. Im EMSCRIPTEN_BINDINGS
-Block kann ich value_object
verwenden, damit JavaScript diesen C++-Wert als Objekt erkennt. Ich könnte auch value_array
verwenden, wenn ich diesen C++-Wert lieber als Array verwenden möchte. Ich binde auch die processMessage()
-Funktion und der Rest ist embind-Magie. Ich kann die Funktion processMessage()
jetzt ohne Boilerplate-Code aus JavaScript aufrufen:
console.log(Module.processMessage(
"hello world",
{
reverse: false,
exclaim: true,
repeat: 3
}
)); // Prints "hello world!hello world!hello world!"
Klassen
Der Vollständigkeit halber möchte ich Ihnen auch zeigen, wie Sie mit embind ganze Klassen verfügbar machen können, was viele Synergien mit ES6-Klassen bietet. Wahrscheinlich können Sie jetzt ein Muster erkennen:
#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);
}
Auf der JavaScript-Seite fühlt sich das fast wie eine native Klasse an:
<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>
Was ist mit C?
embind wurde für C++ geschrieben und kann nur in C++-Dateien verwendet werden. Das bedeutet jedoch nicht, dass Sie keine C-Dateien verknüpfen können. Wenn Sie C und C++ mischen möchten, müssen Sie Ihre Eingabedateien nur in zwei Gruppen aufteilen: eine für C- und eine für C++-Dateien. Außerdem müssen Sie die CLI-Flags für emcc
so erweitern:
$ emcc --bind -O3 --std=c++11 a_c_file.c another_c_file.c -x c++ your_cpp_file.cpp
Fazit
embind bietet erhebliche Verbesserungen für Entwickler, die mit WASM und C/C++ arbeiten. In diesem Artikel werden nicht alle Optionen von embind behandelt. Wenn Sie daran interessiert sind, empfehle ich Ihnen, die Dokumentation zu embind zu lesen. Beachten Sie, dass die Verwendung von embind sowohl Ihr WASM-Modul als auch Ihren JavaScript-Glue-Code um bis zu 11 KB größer machen kann, wenn sie mit gzip komprimiert werden – insbesondere bei kleinen Modulen. Wenn Sie nur eine sehr kleine WASM-Oberfläche haben, kann embind in einer Produktionsumgebung mehr kosten, als es wert ist. Trotzdem sollten Sie es auf jeden Fall ausprobieren.