Emscriptens embind

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 hier ist das Code-Snippet, von dem ich rede:

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

Hier legen wir die Namen der Funktionen fest, die wir mit EMSCRIPTEN_KEEPALIVE markiert haben, sowie deren 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? Warum ja, oder worum geht es in diesem Artikel?

C++-Namensänderung

Die Entwicklererfahrung wäre zwar der Grund genug, ein Tool zu entwickeln, das diese Bindungen unterstützt, aber es gibt einen wichtigeren 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. Mit C stehen die Namen der Funktionen in der Objektdatei weiterhin für die Verknüpfung zur Verfügung. Alles, was Sie benötigen, um eine C-Funktion aufrufen zu können, ist der Name, den wir als String für cwrap() angeben.

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 Compiler-Ebene würde ein schöner Name wie add verändert und so die Signatur im Funktionsnamen für die Verknüpfung codiert. Daher können wir unsere Funktion nicht mehr mit ihrem Namen suchen.

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 in JavaScript angeben, welche Funktionen, Enums, Klassen oder Werttypen Sie 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 gibt es einen EMSCRIPTEN_BINDINGS-Abschnitt, in dem wir die Namen auflisten, unter denen die Funktionen für JavaScript verfügbar sein sollen.

Zum Kompilieren dieser Datei können wir dasselbe Setup (oder 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, 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, wird cwrap() nicht mehr verwendet. 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 diese Funktion kostenlos an, zusammen mit Typprüfungen:

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

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

Objekte

Viele JavaScript-Konstruktoren und ‑Funktionen verwenden Optionsobjekte. Es ist ein schönes Muster in JavaScript, aber es ist sehr mühsam, es manuell in Wasm zu erkennen. Auch hier kann „embind“ hilfreich sein.

Zum Beispiel habe ich eine unglaublich nützliche C++-Funktion entwickelt, die meine Strings verarbeitet, und ich möchte sie dringend im Web verwenden. So habe ich das getan:

#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 Funktion processMessage() und der Rest ist magisch. 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>

Und 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 große Verbesserungen bei der Arbeit mit Wasm und C/C++. In diesem Artikel werden nicht alle Optionen von embind-Angeboten behandelt. 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-Glue-Code bei Verwendung mit GZIP um bis zu 11.000 KB erhöhen kann, 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.