Embind sakralny

Powiąże JS z Twoim wasm.

W poprzednim artykule na temat formatu wasm omawiałem kompilowanie biblioteki C na potrzeby formatu wasm, aby można było z niej korzystać w internecie. Zauważyłem też (i wielu czytelników) to w nietypowy i nieco niezręczny sposób ręcznego zadeklarowania, których funkcji modułu Wasm używasz. Oto fragment kodu, o którym mówię:

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

Tutaj deklarujemy nazwy funkcji oznaczonych tagiem EMSCRIPTEN_KEEPALIVE, informacje o ich zwracanych typach i rodzaje ich argumentów. Później możemy używać metod obiektu api do wywoływania tych funkcji. Użycie Wasm w ten sposób nie obsługuje jednak ciągów znaków i wymaga ręcznego przenoszenia fragmentów pamięci, przez co korzystanie z wielu interfejsów API z biblioteką jest bardzo uciążliwe. Czy nie ma lepszego sposobu? Oczywiście, bo inaczej nie byłoby tego artykułu.

nazwy funkcji w C++

Wrażenia programisty są wystarczające do stworzenia narzędzia wspierającego te powiązania, ale w rzeczywistości jest ważniejszy powód – gdy kompilujesz kod w języku C lub C++, każdy plik jest kompilowany oddzielnie. Następnie kompilator łączy wszystkie te tak zwane pliki obiektów i przekształca je w plik WASM. W języku C nazwy funkcji są nadal dostępne w pliku obiektowym, z którego korzysta linker. Aby wywołać funkcję C, wystarczy nazwa, którą przekazujemy jako ciąg znaków funkcji cwrap().

Z drugiej strony, C++ obsługuje przeciążanie funkcji, co oznacza, że możesz zaimplementować tę samą funkcję kilka razy, o ile podpis jest inny (np. parametry o innym typie). Na poziomie kompilatora ładna nazwa, np. add, została zniekształcona, tworząc coś, co koduje podpis w nazwie funkcji tagu łączącego. W związku z tym nie będziemy już mogli znaleźć naszej funkcji po jej nazwie.

Wpisz embind

embind jest częścią zestawu narzędzi Emscripten i zawiera wiele makr C++, które umożliwiają adnotację kodu C++. Możesz zadeklarować, których funkcji, typów enumeracji, klas lub typów wartości zamierzasz używać w JavaScript. Zacznijmy od kilku prostych funkcji:

#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);
}

W porównaniu z poprzednim artykułem nie uwzględniamy już emscripten.h, ponieważ nie musimy już oznaczać funkcji za pomocą EMSCRIPTEN_KEEPALIVE. Zamiast tego mamy sekcję EMSCRIPTEN_BINDINGS, w której podajemy nazwy, pod którymi chcemy udostępniać nasze funkcje językowi JavaScript.

Aby skompilować ten plik, możemy użyć tej samej konfiguracji (lub – jeśli chcesz, możesz użyć tego samego obrazu Dockera) co w poprzednim artykule. Aby użyć embind, dodaj flagę --bind:

$ emcc --bind -O3 add.cpp

Teraz wystarczy przygotować plik HTML, który wczytuje nasz świeżo utworzony moduł Wasm:

<script src="/a.out.js"></script>
<script>
Module.onRuntimeInitialized = _ => {
    console.log(Module.add(1, 2.3));
    console.log(Module.exclaim("hello world"));
};
</script>

Jak widać, nie używamy już cwrap(). Działa to od razu po uruchomieniu. Co ważniejsze, nie musimy się martwić ręcznym kopiowaniem fragmentów pamięci, aby działały ciągi tekstowe. embind zapewnia to za darmo, a także sprawdzanie typu:

błędy w Narzędziach deweloperskich podczas wywoływania funkcji o nieprawidłowym typie lub z nieprawidłową liczbą argumentów;

To świetnie, bo możemy szybko wyłapać kilka błędów, zamiast zajmować się przypadkowymi, nieporęcznymi błędami.

Obiekty

Wiele konstruktorów i funkcji JavaScriptu korzysta z obiektów options. To fajny wzorzec w JavaScripcie, ale niezwykle żmudny, by wdrożyć go ręcznie w Wasm. Przydatne może być też narzędzie embind.

Udało mi się na przykład wymyślić tę niezwykle przydatną funkcję w C++, która przetwarza moje ciągi znaków, i chcę ją pilnie użyć w internecie. Oto jak to zrobić:

#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);
}

Definiuję strukturę opcji mojej funkcji processMessage(). W bloku EMSCRIPTEN_BINDINGS mogę użyć value_object, aby kod JavaScript rozpoznał tę wartość w C++ jako obiekt. Jeśli wolisz użyć tej wartości C++ jako tablicy, możesz też użyć value_array. Funkcję processMessage() również wiążemy, a reszta to magia embind. Mogę teraz wywołać funkcję processMessage() z JavaScriptu bez konieczności powtarzania kodu:

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

Zajęcia

Dla porządku pokażę też, jak embind umożliwia udostępnianie całych klas, co sprzyja współpracy z klasami ES6. Zapewne zaczynasz już dostrzegać pewien wzór:

#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);
}

Jeśli chodzi o JavaScript, wygląda to prawie jak klasa natywna:

<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>

A C?

Plik embind został napisany dla języka C++ i można go używać tylko w plikach C++. Nie oznacza to jednak, że nie można tworzyć linków do plików w języku C. Aby połączyć pliki w języku C i C++, wystarczy podzielić pliki wejściowe na 2 grupy: jedną dla plików C++ i jedną dla plików C++ oraz rozszerzyć flagi interfejsu wiersza poleceń emcc w ten sposób:

$ emcc --bind -O3 --std=c++11 a_c_file.c another_c_file.c -x c++ your_cpp_file.cpp

Podsumowanie

Pozwala on znacznie zwiększyć wygodę programistów podczas pracy z Wasm i C/C++. W tym artykule nie omówiliśmy wszystkich opcji. Jeśli chcesz, możesz zapoznać się z dokumentacją embind. Pamiętaj, że użycie embind może zwiększyć rozmiar modułu Wasm i kodu JavaScript glue o 11 KB w przypadku kodu gzip, zwłaszcza w przypadku małych modułów. Jeśli masz tylko bardzo małą powierzchnię wąski, narzędzie może kosztować więcej, niż jest warte w środowisku produkcyjnym. Warto jednak spróbować.