La embinada de Emscripten

¡Vincula JS a tu wasm!

En mi último artículo de Wasm, hablé sobre cómo compilar una biblioteca de C en wasm para poder usarla en la Web. Una cosa que me llamó la atención (y para muchos lectores) es la forma grosera y un poco incómoda debes declarar manualmente qué funciones del módulo de wasm usas. Para que lo recuerdes, este es el fragmento de código al que me refiero:

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

Aquí declaramos los nombres de las funciones que marcamos con EMSCRIPTEN_KEEPALIVE, cuáles son sus tipos de datos que se muestran y cuáles son sus argumentos. Luego, podemos usar los métodos del objeto api para invocar estas funciones. Sin embargo, usar wasm de esta manera no admite cadenas y requiere que muevas manualmente fragmentos de memoria, lo que hace que muchas bibliotecas Las APIs son muy tediosas de usar. ¿No hay una manera mejor? ¿Por qué sí?, de lo contrario ¿de qué trataría este artículo?

Modificación de nombres de C++

Aunque la experiencia del desarrollador es suficiente para crear una herramienta que ayude estas vinculaciones, hay una razón más apremiante: cuando compilas C o C++, cada archivo se compila por separado. Luego, un vinculador se encarga de organizando todos estos archivos de objetos y convirtiéndolos en un archivo wasm . Con C, los nombres de las funciones aún están disponibles en el archivo de objeto para que use el vinculador. Todo lo que necesitas para llamar a una función C es el nombre, que proporcionamos como una cadena a cwrap().

C++, por otro lado, admite la sobrecarga de funciones, lo que significa que puedes implementar la misma función varias veces, siempre que la firma sea diferente (p.ej., parámetros con tipos diferentes). En el nivel del compilador, un nombre agradable como add se modificaría en algo que codifique la firma en la función nombre del vinculador. Como resultado, no podríamos buscar nuestra función por su nombre.

Ingresar embind

embinar es parte de la cadena de herramientas de Emscripten y te proporciona un montón de macros de C++ que te permiten anotar código C++. Puedes declarar qué funciones, enumeraciones, clases o tipos de valores que planeas usar de JavaScript. Comencemos simple con algunas funciones sin formato:

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

En comparación con mi artículo anterior, ya no incluimos emscripten.h, ya que ya no tenemos que anotar nuestras funciones con EMSCRIPTEN_KEEPALIVE. En su lugar, tenemos una sección EMSCRIPTEN_BINDINGS en la que mostramos los nombres en con el que queremos exponer nuestras funciones a JavaScript.

Para compilar este archivo, podemos usar la misma configuración (o, si quieres, la misma imagen de Docker) como en la imagen anterior artículo. Para usar embind, agregamos la marca --bind:

$ emcc --bind -O3 add.cpp

Ahora solo falta crear un archivo HTML que cargue nuestro archivo Se creó el módulo de Wasm:

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

Como puedes ver, ya no usamos cwrap(). Esto simplemente funciona de la caja. Pero lo más importante es que no tenemos que preocuparnos por copiar manualmente memoria para que las cadenas funcionen. embind te da eso de forma gratuita, junto con con verificaciones de tipo:

Se producen errores de Herramientas para desarrolladores cuando invocas una función con la cantidad incorrecta de argumentos.
o si los argumentos tienen errores
tipo

Esto es muy útil, ya que podemos detectar algunos errores con anticipación en lugar de lidiar con los errores ocasionalmente difíciles de manejar.

Objetos

Muchos constructores y funciones de JavaScript usan objetos de opciones. Es un en JavaScript, pero es muy tedioso de comprenderlo manualmente. embind también puede ser de ayuda aquí.

Por ejemplo, se me ocurrió esta función de C++ increíblemente útil que procesa mis y quiero usarlas con urgencia en la Web. Así es como lo hice:

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

Estoy definiendo un struct para las opciones de la función processMessage(). En la EMSCRIPTEN_BINDINGS, puedo usar value_object para que JavaScript pueda ver este valor de C++ como un objeto. También podría usar value_array si así lo quisiera usa este valor de C++ como un array. También enlace la función processMessage(), y el resto es magia. Ahora puedo llamar a la función processMessage() desde JavaScript sin código estándar:

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

Clases

Para ser exhaustivo, también debo mostrarte cómo embind te permite exponer a clases enteras, lo que aporta mucha sinergia a las clases de ES6. Probablemente puedas empezar a ver un patrón a esta altura:

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

En el lado de JavaScript, esto casi parece una clase 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>

¿Qué sucede con C?

embind se escribió para C++ y solo puede usarse en archivos C++, pero eso no es obligatorio. esto significa que no puede vincularse con archivos C. Para combinar C y C++, solo necesitas separar tus archivos de entrada en dos grupos: uno para C y otro para C++, y aumenta las marcas de la CLI para emcc de la siguiente manera:

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

Conclusión

embind brinda grandes mejoras en la experiencia de los desarrolladores a la hora de trabajar con wasm y C/C++. En este artículo, no se abordan todas las opciones que ofrece. Si te interesa, te recomiendo que continúes con embind's documentación. Ten en cuenta que usar embind puede hacer que tanto el módulo wasm como El tamaño del código de adhesión de JavaScript es de hasta 11,000 cuando se comprime con gzip, sobre todo en archivos pequeños. módulos. Si solo tienes una superficie de Wasm muy pequeña, embind podría costar más de que vale la pena en un entorno de producción. No obstante, definitivamente debes dar una prueba.