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 tosca y un poco incómoda en la que debes declarar manualmente qué funciones de tu módulo 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 retorno y cuáles son los tipos de 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 APIs de bibliotecas sean muy tediosas de usar. ¿No hay una manera mejor? Por supuesto que sí, de lo contrario, ¿de qué se trataría este artículo?

Conversión de nombres de C++

Si bien la experiencia del desarrollador sería motivo suficiente para crear 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 combinar 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 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 cadena 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 de tipos diferentes). A nivel del compilador, un nombre agradable como add se alteraría en algo que codifica la firma en el nombre de la función para el vinculador. Como resultado, ya no podríamos buscar nuestra función con su nombre.

Ingresa embind

embind forma 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, enums, clases o tipos de valores planeas usar desde JavaScript. Comencemos 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, 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 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 por copiar manualmente fragmentos de memoria para que funcionen las cadenas. embind te lo ofrece de forma gratuita, junto con verificaciones de tipo:

Se producen errores en DevTools cuando invocas una función con la cantidad incorrecta de argumentos o si los argumentos tienen el 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 es muy tedioso realizarlo en wasm de forma manual. Embind también puede ayudar aquí.

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 una 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 prefiriera 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 de C++, pero eso no significa que no puedas vincularlo 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 el código de unión de JavaScript hasta en 11 K cuando se comprimen con gzip, en especial 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. Sin embargo, te recomendamos que lo pruebes.