Emscripten's embind

Associa JS al file wasm.

Nel mio ultimo articolo su Wasm, ho parlato di come compilare una libreria C in wasm per poterla usare sul web. Una cosa che mi ha colpito (e per molti lettori) è il modo in cui devi dichiarare manualmente quali funzioni del modulo Wasm stai utilizzando. Ecco lo snippet di codice di cui parlerò:

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 loro tipi restituiti e i tipi di argomenti. Dopodiché possiamo utilizzare i metodi nell'oggetto api per richiamare queste funzioni. Tuttavia, l'utilizzo di wasm in questo modo non supporta le stringhe e richiede lo spostamento manuale di blocchi di memoria, il che rende molte API di libreria molto noiose da usare. Non c'è un modo migliore? Perché sì? Altrimenti, di cosa tratterebbe questo articolo?

Gestione nomi C++

Anche se l'esperienza degli sviluppatori sarebbe un buon motivo per creare uno strumento che aiuti con queste associazioni, c'è un motivo più urgente: quando compili il codice C o C++, ogni file viene compilato separatamente. Poi, un linker si occupa di unire tutti questi cosiddetti file oggetto e di trasformarli in un file wasm. Con C, i nomi delle funzioni restano disponibili nel file dell'oggetto per essere utilizzato dal linker. Tutto ciò di cui hai bisogno per chiamare una funzione C è il nome, fornito come stringa a cwrap().

C++, d'altra parte, supporta l'overload delle funzioni, il che significa che puoi implementare la stessa funzione più volte purché la firma sia diversa (ad es. parametri di tipi diversi). A livello di compilatore, un nome appropriato come add viene mangiato 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 embina

embind fa parte della toolchain Emscripten e fornisce una serie di macro C++ che ti consentono di annotare il codice C++. Puoi dichiarare da JavaScript quali funzioni, enum, classi o tipi di valori intendi utilizzare. 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, in quanto non dobbiamo più annotare le nostre funzioni con EMSCRIPTEN_KEEPALIVE. Abbiamo invece una sezione EMSCRIPTEN_BINDINGS in cui sono elencati i nomi con cui 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 usare embind, aggiungiamo il flag --bind:

$ emcc --bind -O3 add.cpp

Ora non ti resta che creare 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(). Il tutto funziona da subito. Ma soprattutto, non dobbiamo preoccuparci di copiare manualmente blocchi di memoria per far funzionare le stringhe. embind te lo offre senza costi, insieme ai controlli dei tipi:

Errori DevTools quando richiami una funzione con il numero sbagliato
di argomenti o quando questi hanno il tipo

Si tratta di un'ottima cosa, in quanto riusciamo a individuare subito alcuni errori, invece di occuparci degli errori di wasm, che a volte sono piuttosto ingombranti.

Oggetti

Molti costruttori e funzioni JavaScript utilizzano oggetti opzioni. È un buon pattern in JavaScript, ma estremamente noioso da realizzare manualmente in wasm. embind può essere d'aiuto anche in questo caso.

Ad esempio, ho ideato questa funzione C++ incredibilmente utile che elabora le mie stringhe e vorrei tanto utilizzarla 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 funzione processMessage(). Nel blocco EMSCRIPTEN_BINDINGS, posso utilizzare value_object per fare in modo che JavaScript visualizzi questo valore C++ come un oggetto. Potrei anche usare value_array se preferisco usare questo valore C++ come array. Collego anche la funzione processMessage(), e il resto embine la magia. 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 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);
}

Per quanto riguarda JavaScript, l'aspetto è quasi come 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 puoi creare link a file C. Per combinare C e C++, devi solo 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 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 degli sviluppatori quando si lavora con wasm e C/C++. Questo articolo non riguarda tutte le opzioni di embind offerte. Se ti interessa, ti consiglio di continuare con la documentazione di Embind. Tieni presente che l'uso di embind può aumentare le dimensioni sia del modulo wasm sia del codice colla JavaScript fino a 11 kB quando gzip'd, in particolare nei moduli di piccole dimensioni. Se hai solo una superficie wasm molto piccola, l'embind potrebbe costare più di quanto ne vale in un ambiente di produzione. Ciononostante, dovresti assolutamente provare.