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 para Wasm para que puedas usarla en la Web. Algo que me llamó la atención (y a muchos lectores) es la manera cruda y un poco incómoda de declarar manualmente las funciones del módulo wasm que estás usando. Para recordarlo, este es el fragmento de código del 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 los 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 esas funciones. Sin embargo, este método no admite cadenas y requiere que muevas fragmentos de memoria de forma manual, lo que hace que sea muy tedioso utilizar muchas APIs de bibliotecas. ¿No hay una mejor manera de hacerlo? ¿Por qué sí? De lo contrario, ¿de qué se trataría este artículo?

Cambio de nombres en C++

Si bien la experiencia del desarrollador sería suficiente para compilar una herramienta que ayude con estas vinculaciones, en realidad hay un motivo más urgente: cuando compilas código C o C++, cada archivo se compila por separado. Luego, un vinculador se encarga de organizar todos estos llamados archivos de objetos y convertirlos en un archivo Wasm. Con C, los nombres de las funciones aún están disponibles en el archivo de objeto para que los use el vinculador. Lo único que necesitas para llamar a una función en C es el nombre, que proporcionamos como una cadena para 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 de tipo diferente). En el nivel de compilador, un buen nombre como add se transformaría en algo que codifique la firma en el nombre de la función del vinculador. Como resultado, ya no podríamos buscar nuestra función con su nombre.

Ingresar embind

embind es parte de la cadena de herramientas de Emscripten y te proporciona varias macros de C++ que te permiten agregar anotaciones en código C++. Puedes declarar qué funciones, enumeraciones, clases o tipos de valores planeas usar desde JavaScript. Comencemos de manera simple 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 no necesitamos anotar nuestras funciones con EMSCRIPTEN_KEEPALIVE. En su lugar, tenemos una sección EMSCRIPTEN_BINDINGS en la que se enumeran los nombres con los que queremos exponer nuestras funciones a JavaScript.

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

$ emcc --bind -O3 add.cpp

Ahora, lo único que falta es 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. Sin embargo, lo que es más importante, no tenemos que preocuparnos por copiar fragmentos de memoria de forma manual para que las cadenas funcionen. embind te ofrece eso de forma gratuita, junto con las verificaciones de tipo:

Errores de Herramientas para desarrolladores cuando invocas una función con una cantidad incorrecta de argumentos
o los argumentos tienen un tipo
incorrecto

Esto es bastante bueno, ya que podemos detectar algunos errores a tiempo en lugar de lidiar con errores de Wasm que suelen ser difíciles de manejar.

Objetos

Muchos constructores y funciones de JavaScript usan objetos de opciones. Es un buen patrón en JavaScript, pero extremadamente tedioso de comprenderlo manualmente. Embind también puede ser útil en este caso.

Por ejemplo, se me ocurrió esta función C++ increíblemente útil que procesa mis cadenas y quiero usarla 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);
}

Voy a definir un struct para las opciones de mi 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 array. También vinculo la función processMessage() y el resto incluye magic. 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

A modo de exhaustividad, también debería mostrarte cómo embind te permite exponer clases completas, lo que genera mucha sinergia con las clases ES6. Es probable que ya puedas empezar 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 se siente casi 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 vincularlo con archivos C. Para combinar C y C++, solo necesitas separar los archivos de entrada en dos grupos: uno para C y otro para los archivos C++, y aumentar 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 te ofrece grandes mejoras en la experiencia para desarrolladores cuando trabajas con Wasm y C/C++. En este artículo, no se abordan todas las opciones que incluyen las ofertas. Si te interesa, te recomendamos continuar con la documentación de Embind. Ten en cuenta que el uso de embind puede hacer que el módulo de wasm y el código de adhesión de JavaScript sean más grandes hasta 11,000 cuando se usa con gzip, en particular en módulos pequeños. Si solo tienes una superficie de Wasm muy pequeña, la embinación podría costar más de lo que vale en un entorno de producción. No obstante, deberías intentarlo.