Es bindet JS an Ihr WASM.
In meinem letzten wasm-Artikel habe ich beschrieben, wie Sie eine C-Bibliothek in wasm kompilieren, damit Sie sie im Web verwenden können. Was mir (und vielen Lesern) aufgefallen ist, ist die grobe und etwas umständliche 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
gekennzeichnet haben, sowie ihre Rückgabetypen und die Typen ihrer Argumente. Anschließend können wir diese Funktionen mithilfe der Methoden des api
-Objekts aufrufen. Bei dieser Verwendung von wasm werden jedoch keine Strings unterstützt und Sie müssen Speicherbereiche manuell verschieben, was die Verwendung vieler Bibliotheks-APIs sehr mühsam macht. Gibt es nicht eine bessere Möglichkeit? Ja, denn sonst wäre dieser Artikel nicht geschrieben worden.
C++-Namensänderung
Die Entwicklerfreundlichkeit wäre zwar Grund genug, ein Tool zu entwickeln, das bei diesen Bindungen hilft, aber es gibt einen noch dringlicheren Grund: Wenn Sie C- oder C++-Code kompilieren, wird jede Datei separat kompiliert. Anschließend kümmert sich ein Linker darum, alle diese sogenannten Objektdateien zusammenzuführen und in eine WASM-Datei umzuwandeln. Bei C sind die Namen der Funktionen weiterhin in der Objektdatei für den Linker verfügbar. Um eine C-Funktion aufrufen zu können, benötigen Sie nur den Namen, den wir als String an cwrap()
übergeben.
C++ unterstützt hingegen die Funktionsüberladung. Das bedeutet, dass Sie dieselbe Funktion mehrmals implementieren können, solange sich die Signatur unterscheidet (z. B. Parameter mit unterschiedlichen Typen). Auf Compilerebene wird ein schöner Name wie add
umgewandelt in etwas, das die Signatur im Funktionsnamen für den Linker codiert. Wir könnten die Funktion dann nicht mehr anhand ihres Namens 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, Enumerationen, Klassen oder Werttypen Sie aus 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 verwenden wir emscripten.h
nicht mehr, da wir unsere Funktionen nicht mehr mit EMSCRIPTEN_KEEPALIVE
annotieren müssen.
Stattdessen haben wir einen Bereich EMSCRIPTEN_BINDINGS
, in dem wir die Namen auflisten, unter denen unsere Funktionen für JavaScript freigegeben werden sollen.
Zum Kompilieren dieser Datei können wir dieselbe Konfiguration (oder bei Bedarf 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, in 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 um das manuelle Kopieren von Speicherbereichen kümmern müssen, damit Strings funktionieren. embind bietet Ihnen das kostenlos und zusätzlich noch Typprüfungen:
Das ist sehr praktisch, da wir einige Fehler frühzeitig erkennen können, anstatt uns mit den manchmal recht unhandlichen wasm-Fehlern herumschlagen zu müssen.
Objekte
Viele JavaScript-Konstruktoren und ‑Funktionen verwenden Optionsobjekte. Es ist ein schönes Muster in JavaScript, aber extrem mühsam, es manuell in wasm zu realisieren. Auch hier kann embind helfen.
Ich habe beispielsweise diese unglaublich nützliche C++-Funktion entwickelt, die meine Strings verarbeitet, und möchte sie dringend im Web verwenden. So habe ich das gemacht:
#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. Außerdem verknüpfe ich die processMessage()
-Funktion. Der Rest ist Magie. Ich kann die processMessage()
-Funktion 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
Zur Vollständigkeit möchte ich Ihnen auch zeigen, wie Sie mit embind ganze Klassen freigeben können, was eine große Synergie mit ES6-Klassen bietet. Sie erkennen wahrscheinlich schon ein Muster:
#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 Verknüpfungen mit C-Dateien herstellen können. Wenn Sie C und C++ mischen möchten, müssen Sie Ihre Eingabedateien nur in zwei Gruppen unterteilen: eine für C- und eine für C++-Dateien. Erweitern Sie dann die Befehlszeilen-Flags für emcc
so:
$ emcc --bind -O3 --std=c++11 a_c_file.c another_c_file.c -x c++ your_cpp_file.cpp
Fazit
embind bietet Entwicklern bei der Arbeit mit wasm und C/C++ große Verbesserungen. Dieser Artikel deckt nicht alle Optionen ab, die embind bietet. Wenn Sie interessiert sind, empfehle ich Ihnen, mit der embind-Dokumentation fortzufahren. Beachten Sie, dass die Verwendung von embind sowohl Ihr WASM-Modul als auch Ihren JavaScript-Bindungscode nach dem GZIP-Komprimieren um bis zu 11 KB vergrößern kann, was vor allem bei kleinen Modulen der Fall ist. Wenn Sie nur eine sehr kleine WASM-Oberfläche haben, kann Embind in einer Produktionsumgebung mehr kosten als es wert ist. Sie sollten es trotzdem unbedingt ausprobieren.