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:
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ć.