Emscripten's embind

Collega JS al tuo wasm.

Nel mio ultimo articolo su wasm, ho parlato di come compilare una libreria C in wasm in modo da poterla utilizzare sul web. Una cosa che mi ha colpito (e a molti lettori) è il modo rozzo e un po' imbarazzante in cui devi dichiarare manualmente le funzioni del modulo wasm che utilizzi. Ecco lo snippet di codice a cui mi riferisco:

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

Qui dichiariamo i nomi delle funzioni contrassegnate con EMSCRIPTEN_KEEPALIVE, i relativi tipi di ritorno e i tipi dei relativi argomenti. In seguito, possiamo utilizzare i metodi dell'oggetto api per richiamare queste funzioni. Tuttavia, l'utilizzo di wasm in questo modo non supporta le stringhe e richiede di spostare manualmente blocchi di memoria, il che rende molto laborioso l'utilizzo di molte API di librerie. Non esiste un modo migliore? Certo che c'è, altrimenti di che parlerebbe questo articolo?

Manipolazione dei nomi C++

Sebbene l'esperienza dello sviluppatore sia un motivo sufficiente per creare uno strumento che aiuti con queste associazioni, in realtà esiste un motivo più urgente: quando compili il codice C o C++, ogni file viene compilato separatamente. Poi, un linker si occupa di riunire tutti questi cosiddetti file oggetto e trasformarli in un file wasm. In C, i nomi delle funzioni sono ancora disponibili nel file oggetto per essere utilizzati dal linker. Per poter chiamare una funzione C, è sufficiente il nome, che forniamo come stringa a cwrap().

C++ invece supporta il sovraccarico delle funzioni, il che significa che puoi implementare la stessa funzione più volte purché la firma sia diversa (ad es. parametri di tipo diverso). A livello di compilatore, un nome accattivante come add viene alterato in qualcosa che codifica la firma nel nome della funzione per il linker. Di conseguenza, non potremmo più cercare la nostra funzione con il nome.

Inserisci embind

embind faceva parte della toolchain Emscripten e forniva una serie di macro C++ che consentono di annotare il codice C++. Puoi dichiarare le funzioni, gli enum, le classi o i tipi di valore che prevedi di utilizzare da JavaScript. Iniziamo con alcune funzioni semplici:

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

Rispetto al mio articolo precedente, non includiamo più emscripten.h, poiché non dobbiamo più annotare le nostre funzioni con EMSCRIPTEN_KEEPALIVE. Abbiamo invece una sezione EMSCRIPTEN_BINDINGS in cui elenchiamo i nomi sotto i quali vogliamo esporre le nostre funzioni a JavaScript.

Per compilare questo file, possiamo utilizzare la stessa configurazione (o, se vuoi, la stessa immagine Docker) dell'articolo precedente. Per utilizzare embind, aggiungere il flag --bind:

$ emcc --bind -O3 add.cpp

Ora non resta che creare un file HTML che carichi il nostro modulo wasm appena creato:

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

Come puoi vedere, non utilizziamo più cwrap(). Funziona fin dal primo utilizzo. Ma, soprattutto, non dobbiamo preoccuparci di copiare manualmente frammenti di memoria per far funzionare le stringhe. embind ti offre tutto questo senza costi, insieme ai controlli di tipo:

Errori di DevTools quando chiami una funzione con il numero errato di argomenti o se gli argomenti hanno il tipo errato

Questo è fantastico perché possiamo rilevare alcuni errori in anticipo anziché dover gestire gli errori wasm a volte piuttosto complicati.

Oggetti

Molti costruttori e funzioni JavaScript utilizzano oggetti di opzioni. È un pattern molto utile in JavaScript, ma estremamente laborioso da realizzare manualmente in wasm. Anche in questo caso embind può essere d'aiuto.

Ad esempio, ho creato questa funzione C++ incredibilmente utile che elabora le mie stringhe e voglio utilizzarla urgentemente sul web. Ecco come ho fatto:

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

Sto definendo uno struct per le opzioni della mia funzione processMessage(). Nel blocco EMSCRIPTEN_BINDINGS, posso utilizzare value_object per fare in modo che JavaScript veda questo valore C++ come un oggetto. Potrei anche utilizzare value_array se preferissi usare questo valore C++ come array. Associo anche la funzione processMessage() e il resto è magia di embind. Ora posso chiamare la funzione processMessage() da JavaScript senza codice boilerplate:

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

Corsi

Per completezza, devo anche mostrarti come embind ti consente di esporre interi moduli, il che offre molta sinergia con i moduli ES6. Probabilmente ormai hai iniziato a notare un pattern:

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

Lato JavaScript, sembra quasi una classe nativa:

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

E C?

embind è stato scritto per C++ e può essere utilizzato solo nei file C++, ma questo non significa che non puoi eseguire il collegamento ai file C. Per combinare C e C++, devi solo separare i file di input in due gruppi: uno per i file C e uno per i file C++ e aumentare i flag della CLI per emcc come segue:

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

Conclusione

embind offre grandi miglioramenti nell'esperienza dello sviluppatore quando si lavora con wasm e C/C++. Questo articolo non copre tutte le opzioni offerte da embind. Se ti interessa, ti consiglio di continuare con la documentazione di embind. Tieni presente che l'utilizzo di embind può aumentare fino a 11 KB sia il modulo wasm sia il codice di collegamento JavaScript quando viene compresso con gzip, in particolare per i moduli di piccole dimensioni. Se hai solo una superficie wasm molto piccola, embind potrebbe costare più del dovuto in un ambiente di produzione. Tuttavia, ti consigliamo di provarla.