La embinada de Emscripten

¡Vincula JS a tu wasm!

En mi último artículo sobre wasm, hablé sobre cómo compilar una biblioteca C en wasm para que puedas usarla en la Web. Algo que me llamó la atención (y a muchos lectores) es la forma grosera y un poco incómoda en la que tienes que declarar manualmente qué funciones del módulo de wasm estás usando. Para refrescar tu memoria, este es el fragmento de código del que estoy hablando:

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 los tipos de sus argumentos. Luego, podemos usar los métodos del objeto api para invocar estas funciones. Sin embargo, el uso de wasm de esta manera no admite cadenas y requiere que muevas de forma manual fragmentos de memoria, lo que hace que el uso de muchas APIs de biblioteca sea muy tedioso. ¿No hay una manera mejor? ¿Por qué sí lo hay? De lo contrario, ¿de qué se trataría este artículo?

Modificación de nombres de C++

Si bien la experiencia del desarrollador es suficiente para compilar una herramienta que ayude con estas vinculaciones, en realidad hay una razón más apremiante: cuando compilas código C o C++, cada archivo se compila por separado. Luego, un vinculador se encarga de combinar todos estos llamados archivos de objetos y convertirlos en un archivo WASM. Con C, los nombres de las funciones todavía están disponibles en el archivo de objeto para que los use el vinculador. Todo lo que necesitas para poder llamar a una función de C es el nombre, que proporcionamos como una string a cwrap().

Por otro lado, C++ 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 acortaría para generar algo que codifica la firma en el nombre de la función del vinculador. Como resultado, ya no podremos buscar nuestra función con su nombre.

Ingresar embind

embind 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 de JavaScript planeas usar. Comencemos simples con algunas funciones simples:

#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 enumeramos los nombres con los que queremos exponer nuestras funciones a JavaScript.

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

$ emcc --bind -O3 add.cpp

Ahora solo queda crear un archivo HTML que cargue nuestro módulo wasm recién creado:

<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 funciona de inmediato. Pero lo más importante es que no tenemos que preocuparnos de copiar fragmentos de memoria de forma manual para que las cadenas funcionen. embind te da eso de forma gratuita, junto con verificaciones de tipo:

Se producen errores de Herramientas para desarrolladores cuando invocas una función con una cantidad incorrecta de argumentos o estos tienen un tipo incorrecto.

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

Objetos

Muchos constructores y funciones de JavaScript usan objetos de opciones. Es un buen patrón en JavaScript, pero muy tedioso de usarlo en wasm manualmente. embind también puede ayudar en este caso.

Por ejemplo, se me ocurrió esta función de C++ increíblemente útil que procesa mis cadenas y quiero usarla con urgencia en la Web. A continuación, te explico cómo 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 el bloque EMSCRIPTEN_BINDINGS, puedo usar value_object para que JavaScript vea este valor de C++ como un objeto. También podría usar value_array si preferiría usar este valor de C++ como un array. También vinculo la función processMessage(), y el resto es magia de vinculación. Ahora puedo llamar a la función processMessage() desde JavaScript sin ningún código de plantilla:

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

Clases

Para completar la información, también debo mostrarte cómo embind te permite exponer clases completas, lo que genera mucha sinergia con las clases de ES6. Es probable que ya puedas comenzar a ver un patrón:

#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 se siente como 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 se puede usar en archivos C++, pero eso no significa que no puedas vincularte con archivos C. Para combinar C y C++, solo debes separar tus archivos de entrada en dos grupos: uno para C y otro para C++, y aumentar las marcas de 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 ofrece grandes mejoras en la experiencia del desarrollador cuando se trabaja con wasm y C/C++. En este artículo, no se abordan todas las opciones que ofrece embind. Si te interesa, te recomiendo que continúes con la documentación de embind. Ten en cuenta que usar embind puede aumentar el tamaño de tu módulo wasm y tu código de adhesión de JavaScript hasta en 11 KB cuando se comprime con gzip, especialmente en módulos pequeños. Si solo tienes una superficie de wasm muy pequeña, es posible que la vinculación cueste más de lo que vale en un entorno de producción. No obstante, definitivamente debes probarlo.