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 tipi restituiti e i tipi dei relativi argomenti. In seguito, possiamo utilizzare i metodi sull'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? Perché sì, quale sarebbe l'argomento di questo articolo?

Gestione nomi C++

Sebbene l'esperienza degli sviluppatori sia sufficiente per creare uno strumento che aiuti con queste associazioni, c'è un motivo più urgente: quando si compila 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. Tutto ciò di cui hai bisogno per poter chiamare una funzione C è 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 bel nome come add viene modificato in qualcosa che codifica la firma nel nome della funzione per il linker. Di conseguenza non saremmo più in grado di cercare la funzione con il suo 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, le enumerazioni, le classi o i tipi di valore che intendi utilizzare da JavaScript. Iniziamo con alcune semplici funzioni:

#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 sono elencati i nomi in base ai quali vogliamo esporre le 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, aggiungiamo il flag --bind:

$ emcc --bind -O3 add.cpp

A questo punto, abbiamo creato un file HTML che carichi il 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 cosa ancora più importante, non dobbiamo preoccuparci di copiare manualmente blocchi di memoria per far funzionare le stringhe. embind ti offre questa funzionalità senza costi, insieme ai controlli del 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 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 trovato questa funzione C++ incredibilmente utile che elabora le mie stringhe e voglio usarla 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 visualizzi questo valore C++ come oggetto. Potrei anche utilizzare value_array se preferissi usare questo valore C++ come array. Associamo anche la funzione processMessage() e il resto è "magic". Ora posso chiamare la funzione processMessage() da JavaScript senza alcun 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 intere classi, il che porta molta sinergia con le classi ES6. A questo punto probabilmente potresti iniziare a vedere uno schema:

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

Sul 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 la C?

embind è stato scritto per C++ e può essere utilizzato solo nei file C++, ma questo non significa che non sia possibile creare link a file C! Per combinare C e C++, basta separare i file di input in due gruppi: uno per C e uno per i file C++ e aumentare i flag dell'interfaccia a riga di comando per emcc in questo modo:

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

Conclusione

embind offre notevoli miglioramenti nell'esperienza degli sviluppatori quando si lavora con wasm e C/C++. Questo articolo non tratta tutte le opzioni relative alle offerte. 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ù di quanto vale in un ambiente di produzione. Tuttavia, ti consigliamo di provarla.