Embind sakralny

Łączy JS z Wasm!

W poprzednim artykule omówiłem, jak skompilować bibliotekę C do biblioteki Wasm, aby można było z niej korzystać w internecie. Jedną z rzeczy, które zwróciły moją uwagę (i dla wielu czytelników) jest prymitywny i nieco niezręczny sposób, w jaki trzeba ręcznie zadeklarować, których funkcji modułu Wasm używasz. Dla przypomnienia – 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']),
};

W tym miejscu deklarujemy nazwy funkcji oznaczonych za pomocą atrybutu EMSCRIPTEN_KEEPALIVE, jakie są ich typy zwracanych i rodzaje argumentów. Później możemy wywoływać te funkcje przy użyciu metod w obiekcie api. Jednak korzystanie z Wasm w ten sposób nie obsługuje ciągów tekstowych i wymaga ręcznego przenoszenia fragmentów pamięci, co sprawia, że używanie wielu bibliotek bibliotecznych API jest bardzo żmudne. Czy nie ma lepszego sposobu? Dlaczego tak, a w przeciwnym razie o czym byłby ten artykuł?

Zarządzanie nazwami C++

Choć doświadczenie dla programistów wystarczy, by stworzyć narzędzie, które pomaga w ich reagowaniu, tak naprawdę istnieje ważniejsza przyczyna: gdy kompilujesz kod C lub C++, każdy plik jest kompilowany oddzielnie. Następnie łączy wszystkie tzw. pliki obiektów i przekształca je w plik Wasm. W przypadku C nazwy funkcji są nadal dostępne w pliku obiektów do użycia przez tag łączący. Aby móc wywołać funkcję C, wystarczy, że podasz jej nazwę, którą przekazujemy jako ciąg znaków funkcji cwrap().

Z kolei C++ obsługuje przeciążenie funkcji, co oznacza, że tę samą funkcję można zaimplementować wielokrotnie, z powodu innego podpisu (np. parametrów o innym wpisywaniu). Na poziomie kompilatora ładna nazwa, np. add, została zmienna w coś, co koduje podpis w nazwie funkcji tagu łączącego. W rezultacie nie będziemy mogli wyszukać naszej funkcji pod jej nazwą.

Wpisz embind

embind to część łańcucha narzędzi Emscripten i udostępnia wiele makr C++, które umożliwiają dodawanie adnotacji do kodu w języku C++. W języku JavaScript możesz zadeklarować, które funkcje, wyliczenia, klasy lub typy wartości chcesz używać. Zacznijmy od 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 trzeba już dodawać adnotacji do funkcji EMSCRIPTEN_KEEPALIVE. Zamiast tego mamy sekcję EMSCRIPTEN_BINDINGS, w której wymieniamy nazwy, pod którymi chcemy udostępnić nasze funkcje dla JavaScriptu.

Do skompilowania tego pliku możemy użyć tej samej konfiguracji (lub, jeśli chcesz, tego samego obrazu Dockera) co w poprzednim artykule. Aby użyć embind, dodajemy flagę --bind:

$ emcc --bind -O3 add.cpp

Teraz pozostaje wygenerować plik HTML, który wczytuje nasz nowo 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 korzystamy już z usługi cwrap(). Działa to od razu po wyjęciu z pudełka. Co ważniejsze, nie musimy się przejmować ręcznym kopiowaniem fragmentów pamięci, aby ciągi tekstowe działały. Funkcja embind pozwala Ci to bezpłatnie wykonywać wraz z sprawdzaniem typu:

Błędy w Narzędziach deweloperskich po wywołaniu funkcji z nieprawidłową liczbą argumentów lub z niewłaściwym typem argumentów

To świetnie, ponieważ jesteśmy w stanie wychwycić pewne błędy wcześniej, zamiast zajmować się czasami dość nieporęcznymi błędami Wasm.

Obiekty

Wiele konstruktorów i funkcji JavaScriptu korzysta z obiektów opcji. To ładny wzór w języku JavaScript, ale bardzo pracochłonne do samodzielnego wykonania w Wasm. Mogą też pomóc embind.

Wymyśliłem na przykład tę niezwykle przydatną funkcję w C++, która przetwarza moje ciągi znaków, i chcę pilnie użyć jej 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ę element struct dla opcji funkcji processMessage(). W bloku EMSCRIPTEN_BINDINGS mogę użyć value_object, aby JavaScript podawał wartość z C++ jako obiekt. Mogę też użyć value_array, jeśli wolisz użyć tej wartości C++ jako tablicy. Powiążę też funkcję processMessage(), a reszta to magia. Mogę teraz wywołać funkcję processMessage() z poziomu JavaScriptu bez powtarzającego się kodu:

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

Zajęcia

Dla jasności pokażę Ci też, jak embind umożliwia odsłonięcie wszystkich zajęć, co zapewnia wiele synergii z klasami ES6. Teraz możesz już zauważyć pewne prawidłowości:

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

Po stronie JavaScriptu 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?

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

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

Podsumowanie

Usługa embind znacznie usprawnia pracę programistów w środowiskach Wasm i C/C++. W tym artykule nie omówiliśmy wszystkich opcji powiązanych z ofertami. Jeśli chcesz dowiedzieć się więcej, zapoznaj się z dokumentacją usługi embind. Pamiętaj, że użycie metody embind może spowodować, że zarówno moduł Wasm, jak i kod JavaScriptu (gzip) w kodzie gzip, będą większe nawet o 11 tys. – zwłaszcza w przypadku małych modułów. W przypadku bardzo małej powierzchni w środowisku produkcyjnym jej łączenie może kosztować więcej niż jest to warte. Tak czy inaczej, zdecydowanie warto spróbować.