Emscriptens embind

Es bindet JS an deinen Wasm!

In meinem letzten Wasm-Artikel habe ich darüber gesprochen, wie man eine C-Bibliothek zu Wasm kompiliert, damit man sie im Web verwenden kann. Mir (und vielen Lesern) ist mir aufgefallen, dass die Verwendung der Funktionen des Wasm-Moduls manuell deklariert werden muss. Zur Erinnerung ist hier das Code-Snippet, von dem ich spreche:

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, ihre Rückgabetypen und die Typen ihrer Argumente. Danach können wir die Methoden für das api-Objekt verwenden, um diese Funktionen aufzurufen. Wenn Sie Wasm jedoch auf diese Weise verwenden, werden keine Strings unterstützt und Sie müssen Speicherblöcke manuell verschieben, was die Nutzung vieler Bibliotheks-APIs sehr mühsam macht. Gibt es nicht eine bessere Lösung? Warum ja? Worum geht es in diesem Artikel?

C++-Namensmangling

Auch wenn Entwicklererfahrung Grund genug wäre, ein Tool zu erstellen, das bei diesen Bindungen hilft, gibt es einen wichtigeren Grund: Wenn Sie C- oder C++-Code kompilieren, wird jede Datei separat kompiliert. Anschließend führt ein Verknüpfungsobjekt diese sogenannten Objektdateien zusammen und wandelt sie in eine Wasm-Datei um. Bei C sind die Namen der Funktionen weiterhin in der Objektdatei für die Verknüpfung verfügbar. Sie müssen lediglich den Namen aufrufen können, den wir als String für cwrap() bereitstellen.

C++ hingegen unterstützt eine Überlastung von Funktionen. Das bedeutet, dass Sie dieselbe Funktion mehrmals implementieren können, solange sich die Signatur unterscheidet (z. B. unterschiedlich typisierte Parameter). Auf Compiler-Ebene würde ein schöner Name wie add so verwirrt werden, dass die Signatur im Funktionsnamen für den Verknüpfer codiert wird. Daher können wir unsere Funktion mit ihrem Namen nicht mehr abrufen.

Embind eingeben

embind gehört zur Emscripten-Toolchain und stellt eine Reihe von C++-Makros zur Verfügung, mit denen Sie C++-Code annotieren können. Sie können deklarieren, welche Funktionen, Aufzählungen, Klassen oder Werttypen aus JavaScript verwendet werden sollen. Beginnen wir einfach 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 zum vorherigen Artikel schließen wir emscripten.h nicht mehr ein, da wir unsere Funktionen nicht mehr mit EMSCRIPTEN_KEEPALIVE annotieren müssen. Stattdessen gibt es einen EMSCRIPTEN_BINDINGS-Abschnitt, in dem die Namen aufgelistet sind, unter denen die Funktionen für JavaScript verfügbar gemacht werden sollen.

Zum Kompilieren dieser Datei können wir die gleiche Einrichtung (oder gegebenenfalls das gleiche Docker-Image) wie im vorherigen Artikel verwenden. Um embind zu verwenden, fügen wir das Flag --bind hinzu:

$ emcc --bind -O3 add.cpp

Jetzt muss nur noch eine HTML-Datei erstellt werden, die unser neu erstelltes Wasm-Modul lädt:

<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 nach dem Auspacken. Und was noch wichtiger ist: Wir müssen uns nicht um das manuelle Kopieren von Speicherblöcken kümmern, um Zeichenfolgen funktionsfähig zu machen. Mit embind erhalten Sie dies kostenlos, zusätzlich zu Typprüfungen:

Entwicklertools-Fehler, wenn du eine Funktion mit der falschen Anzahl von Argumenten aufrufst oder die Argumente den falschen Typ haben

Das ist ziemlich gut, da wir Fehler frühzeitig erkennen können, anstatt mit den gelegentlich recht unhandlichen Wasm-Fehlern umzugehen.

Objekte

Viele JavaScript-Konstruktoren und -Funktionen verwenden Optionsobjekte. Es ist ein schönes Muster in JavaScript, aber es ist äußerst mühsam, manuell in Wasm zu erkennen. embind kann auch hier helfen.

Ich habe mir zum Beispiel diese unglaublich nützliche C++-Funktion zur Verarbeitung meiner Strings ausgedacht. Ich möchte sie dringend im Web verwenden. Das habe ich folgendermaßen 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 sieht. Wenn ich diesen C++-Wert lieber als Array verwenden möchte, kann ich auch value_array verwenden. Ich verbinde auch die processMessage()-Funktion, und der Rest ist Magie. Jetzt kann ich die Funktion processMessage() aus JavaScript ohne Boilerplate-Code aufrufen:

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

Kurse

Zur Vollständigkeit möchte ich Ihnen auch zeigen, wie Sie mit embind ganze Klassen verfügbar machen können, was einen großen Synergieeffekt mit ES6-Klassen bietet. Sie können jetzt wahrscheinlich 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 Links zu C-Dateien erstellen können. Wenn Sie C und C++ kombinieren möchten, müssen Sie die Eingabedateien nur in zwei Gruppen aufteilen: eine für C- und eine für C++-Dateien. Erweitern Sie 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 tolle Verbesserungen für Entwickler bei der Arbeit mit Wasm und C/C++. In diesem Artikel werden nicht alle Optionen behandelt, die embind bietet. Bei Interesse empfehlen wir, mit der Dokumentation von Embind fortzufahren. Durch die Verwendung von embind können sowohl das Wasm-Modul als auch der JavaScript-Glue Code um bis zu 11 KB vergrößert werden, wenn sie mit gzip komprimiert wurden – insbesondere bei kleinen Modulen. Wenn Sie nur eine sehr kleine Wasm-Oberfläche haben, kosten Embind möglicherweise mehr, als es in einer Produktionsumgebung wert ist. Trotzdem sollten Sie es auf jeden Fall ausprobieren.