Embind sakralny

Łączy JS z WASM.

W ostatnim artykule o wasm pisałem o tym, jak skompilować bibliotekę C do wasm, aby można było jej używać w internecie. Jedną z rzeczy, która zwróciła moją uwagę (i uwagę wielu czytelników), jest nieporadny i nieco niezręczny sposób, w jaki musisz ręcznie deklarować, 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']),
};

Tutaj deklarujemy nazwy funkcji, które oznaczyliśmy symbolem EMSCRIPTEN_KEEPALIVE, ich typy zwracanych wartości oraz typy argumentów. Następnie możemy użyć metod obiektu api, aby wywołać te funkcje. Jednak używanie wasm w ten sposób nie obsługuje ciągów znaków i wymaga ręcznego przenoszenia fragmentów pamięci, co sprawia, że korzystanie z wielu interfejsów API bibliotek jest bardzo uciążliwe. Czy nie ma lepszego sposobu? Oczywiście, w przeciwnym razie o czym byłby ten artykuł?

C++ name mangling

Chociaż wygoda deweloperów jest wystarczającym powodem, aby stworzyć narzędzie ułatwiające te powiązania, istnieje jeszcze ważniejszy powód: podczas kompilowania kodu w C lub C++ każdy plik jest kompilowany osobno. Następnie linker łączy wszystkie te tak zwane pliki obiektowe i przekształca je w plik WASM. W przypadku języka C nazwy funkcji są nadal dostępne w pliku obiektu, z którego może korzystać linker. Aby wywołać funkcję C, wystarczy jej nazwa, którą przekazujemy do cwrap() jako ciąg znaków.

Z kolei język C++ obsługuje przeciążanie funkcji, co oznacza, że możesz zaimplementować tę samą funkcję wiele razy, o ile jej sygnatura jest inna (np. parametry mają różne typy). Na poziomie kompilatora nazwa add zostanie przekształcona w coś, co koduje sygnaturę w nazwie funkcji dla linkera. W związku z tym nie będziemy już mogli wyszukać naszej funkcji po jej nazwie.

Wpisz embind

embind jest częścią łańcucha narzędzi Emscripten i zawiera wiele makr C++, które umożliwiają dodawanie adnotacji do kodu C++. Możesz zadeklarować, których funkcji, wyliczeń, klas lub typów wartości zamierzasz używać w JavaScript. 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 musimy już oznaczać naszych funkcji za pomocą EMSCRIPTEN_KEEPALIVE. Zamiast tego mamy sekcję EMSCRIPTEN_BINDINGS, w której wymieniamy nazwy, pod którymi chcemy udostępniać nasze funkcje w JavaScript.

Aby skompilować ten plik, 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 wystarczy tylko utworzyć plik HTML, który wczyta 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 widzisz, nie używamy już cwrap(). Działa od razu po wyjęciu z pudełka. Co ważniejsze, nie musimy się martwić ręcznym kopiowaniem fragmentów pamięci, aby ciągi znaków działały. embind zapewnia to bezpłatnie wraz z kontrolą typów:

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

To świetne rozwiązanie, ponieważ możemy wcześnie wykryć niektóre błędy, zamiast radzić sobie z czasami dość nieporęcznymi błędami WASM.

Obiekty

Wiele konstruktorów i funkcji JavaScript używa obiektów opcji. To przydatny wzorzec w JavaScript, ale ręczne wdrożenie go w wasm jest niezwykle żmudne. W tym przypadku też może pomóc embind.

Na przykład wymyśliłem niezwykle przydatną funkcję C++, która przetwarza moje ciągi znaków, i pilnie chcę jej użyć w internecie. Oto jak to zrobiłem:

#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 dla mojej funkcji processMessage(). W bloku EMSCRIPTEN_BINDINGS mogę użyć value_object, aby JavaScript widział tę wartość C++ jako obiekt. Mogę też użyć value_array, jeśli wolę użyć tej wartości C++ jako tablicy. Wiążę też funkcję processMessage(), a reszta to magia embind. Teraz mogę wywołać funkcję processMessage() z kodu JavaScript bez żadnego kodu standardowego:

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

Zajęcia

Dla pełności obrazu warto też pokazać, jak embind umożliwia udostępnianie całych klas, co zapewnia dużą synergię z klasami ES6. Prawdopodobnie zaczynasz już dostrzegać pewną prawidłowość:

#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 niemal 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 co z C?

embind został napisany w C++ i może być używany tylko w plikach C++, ale nie oznacza to, że nie można go łączyć z plikami C. Aby połączyć C i C++, wystarczy podzielić pliki wejściowe na 2 grupy: jedną dla plików C i jedną dla plików C++. Następnie zmodyfikuj flagi interfejsu wiersza poleceń dla 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

embind znacznie ułatwia pracę deweloperom z wasm i C/C++. Ten artykuł nie obejmuje wszystkich opcji oferowanych przez embind. Jeśli Cię to interesuje, zapoznaj się z dokumentacją embind. Pamiętaj, że użycie embind może zwiększyć rozmiar modułu wasm i kodu JavaScript nawet o 11 KB po skompresowaniu za pomocą gzipa, zwłaszcza w przypadku małych modułów. Jeśli masz bardzo małą powierzchnię WASM, w środowisku produkcyjnym embind może być droższy niż jest tego wart. Mimo to warto spróbować.