Embind sakralny

Łączy ono kod JS z 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. Jedna rzecz, która rzucała mi się w oczy (i także wielu czytelnikom) to prymitywna i trochę niewygodna metoda ręcznego deklarowania funkcji modułu wasm, których 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 EMSCRIPTEN_KEEPALIVE, ich typy zwracanych wartości oraz typy argumentów. Następnie możemy użyć metod obiektu api do wywołania tych funkcji. Jednak używanie w ten sposób wasm nie obsługuje ciągów znaków i wymaga ręcznego przenoszenia fragmentów pamięci, co powoduje, że wiele interfejsów API bibliotek jest bardzo uciążliwe w użyciu. Czy nie ma lepszego sposobu? Oczywiście, bo inaczej nie byłoby tego artykułu.

nazwy funkcji w C++.

Chociaż wygodę programistów można uznać za wystarczający powód do stworzenia narzędzia ułatwiającego korzystanie z tych powiązań, istnieje też bardziej naglący powód: podczas kompilowania kodu C lub C++ każdy plik jest kompilowany osobno. Następnie kompilator łączy wszystkie te 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 podać jej nazwę, którą przekazujemy jako ciąg znaków do 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, zostanie zmodyfikowana w taki sposób, aby kodować sygnaturę w nazwie funkcji dla linkera. 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 wartości, klas i 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ć 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, tego samego obrazu Dockera) co w poprzednim artykule. Aby użyć embind, dodaj flagę --bind:

$ emcc --bind -O3 add.cpp

Teraz wystarczy utworzyć plik HTML, który wczyta utworzony przez nas 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(). To działa od razu po zainstalowaniu. Co ważniejsze, nie musimy się martwić ręcznym kopiowaniem fragmentów pamięci, aby działały ciągi tekstowe. embind zapewnia to bezpłatnie, 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 świetna rzecz, ponieważ możemy wcześnie wykrywać niektóre błędy, zamiast radzić sobie z błędami w plikach wasm, które czasami są dość trudne do opanowania.

Obiekty

Wiele konstruktorów i funkcji JavaScript używa obiektów opcji. To ładny wzór w JavaScriptzie, ale bardzo żmudny do zrealizowania ręcznie w wasm. W tym przypadku również może Ci pomóc embind.

Na przykład wymyśliłem niezwykle przydatną funkcję C++, która przetwarza moje ciągi znaków. Chcę jej 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ę element struct dla opcji funkcji processMessage(). W bloku EMSCRIPTEN_BINDINGS mogę użyć funkcji value_object, aby JavaScript traktował tę wartość 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. Teraz mogę wywołać funkcję processMessage() z JavaScriptu bez żadnego kodu szablonowego:

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

Po stronie JavaScriptu wygląda to prawie jak zwykła klasa:

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

Narzędzie embind zostało napisane dla języka C++ i może być używane tylko w plikach C++, ale nie oznacza to, że nie można tworzyć linków do plików C. Aby mieszać C i C++, wystarczy podzielić pliki wejściowe na 2 grupy: jedną dla plików C, a drugą dla plików C++ i uzupełnić flagi 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 przy użyciu wasm i C/C++. Ten artykuł nie opisuje wszystkich opcji embind. Jeśli chcesz, zapoznaj się z dokumentacją embind. Pamiętaj, że użycie embind może spowodować zwiększenie rozmiaru skompresowanego pliku gzip zarówno modułu wasm, jak i kodu łączącego JavaScriptu nawet o 11 KB – szczególnie w przypadku małych modułów. Jeśli masz bardzo małą powierzchnię wasm, embind może kosztować więcej, niż jest to uzasadnione w środowisku produkcyjnym. Warto jednak spróbować.