Como incorporar snippets de JavaScript em C++ com Emscripten

Aprenda a incorporar código JavaScript na sua biblioteca WebAssembly para se comunicar com o mundo exterior.

Ao trabalhar na integração do WebAssembly com a Web, você precisa de uma maneira de chamar APIs externas, como APIs da Web e bibliotecas de terceiros. Você precisa de uma maneira de armazenar os valores e as instâncias de objeto retornados por essas APIs e de transmitir esses valores armazenados para outras APIs mais tarde. Para APIs assíncronas, talvez você também precise aguardar promessas no código C/C++ síncrono com o Asyncify e ler o resultado quando a operação for concluída.

A Emscripten fornece várias ferramentas para essas interações:

  • emscripten::val para armazenar e operar em valores JavaScript em C++.
  • EM_JS para incorporar snippets JavaScript e vincular como funções C/C++.
  • EM_ASYNC_JS, que é semelhante a EM_JS, mas facilita a incorporação de snippets JavaScript assíncronos.
  • EM_ASM para incorporar snippets curtos e executá-los in-line, sem declarar uma função.
  • --js-library para cenários avançados em que você quer declarar muitas funções JavaScript juntas como uma única biblioteca.

Neste post, você vai aprender a usar todos eles para tarefas semelhantes.

Classe emscripten::val

A classe emcripten::val é fornecida pelo Embind. Ele pode invocar APIs globais, vincular valores JavaScript a instâncias C++ e converter valores entre tipos C++ e JavaScript.

Confira como usá-lo com o .await() do Asyncify para buscar e analisar um código JSON:

#include <emscripten/val.h>

using namespace emscripten;

val fetch_json(const char *url) {
  // Get and cache a binding to the global `fetch` API in each thread.
  thread_local const val fetch = val::global("fetch");
  // Invoke fetch and await the returned `Promise<Response>`.
  val response = fetch(url).await();
  // Ask to read the response body as JSON and await the returned `Promise<any>`.
  val json = response.call<val>("json").await();
  // Return the JSON object.
  return json;
}

// Example URL.
val example_json = fetch_json("https://httpbin.org/json");

// Now we can extract fields, e.g.
std::string author = json["slideshow"]["author"].as<std::string>();

Esse código funciona bem, mas executa muitas etapas intermediárias. Cada operação em val precisa realizar as seguintes etapas:

  1. Converta valores C++ transmitidos como argumentos em algum formato intermediário.
  2. Acesse o JavaScript, leia e converta argumentos em valores JavaScript.
  3. Executar a função
  4. Converte o resultado de JavaScript para o formato intermediário.
  5. O resultado convertido é retornado para C++, e C++ o lê.

Cada await() também precisa pausar o lado do C++ desdobrando toda a pilha de chamadas do módulo WebAssembly, retornando ao JavaScript, aguardando e restaurando a pilha do WebAssembly quando a operação for concluída.

Esse código não precisa de nada do C++. O código C++ atua apenas como um driver para uma série de operações JavaScript. E se você pudesse mover fetch_json para JavaScript e reduzir a sobrecarga das etapas intermediárias ao mesmo tempo?

Macro EM_JS

O EM_JS macro permite mover o fetch_json para JavaScript. EM_JS no Emscripten permite que você declare uma função C/C++ implementada por um snippet de JavaScript.

Assim como a WebAssembly, ela tem a limitação de oferecer suporte apenas a argumentos numéricos e valores de retorno. Para transmitir outros valores, é necessário convertê-los manualmente usando as APIs correspondentes. Veja alguns exemplos.

Não é necessário converter números:

// Passing numbers, doesn't need any conversion.
EM_JS(int, add_one, (int x), {
  return x + 1;
});

int x = add_one(41);

Ao transmitir strings de e para JavaScript, é preciso usar as funções de conversão e alocação correspondentes do preamble.js:

EM_JS(void, log_string, (const char *msg), {
  console.log(UTF8ToString(msg));
});

EM_JS(const char *, get_input, (), {
  let str = document.getElementById('myinput').value;
  // Returns heap-allocated string.
  // C/C++ code is responsible for calling `free` once unused.
  return allocate(intArrayFromString(str), 'i8', ALLOC_NORMAL);
});

Por fim, para tipos de valores mais complexos e arbitrários, use a API JavaScript para a classe val mencionada anteriormente. Com ele, é possível converter valores do JavaScript e classes C++ em identificadores intermediários e vice-versa:

EM_JS(void, log_value, (EM_VAL val_handle), {
  let value = Emval.toValue(val_handle);
  console.log(value);
});

EM_JS(EM_VAL, find_myinput, (), {
  let input = document.getElementById('myinput');
  return Emval.toHandle(input);
});

val obj = val::object();
obj.set("x", 1);
obj.set("y", 2);
log_value(obj.as_handle()); // logs { x: 1, y: 2 }

val myinput = val::take_ownership(find_input());
// Now you can store the `find_myinput` DOM element for as long as you like, and access it later like:
std::string value = input["value"].as<std::string>();

Com essas APIs em mente, o exemplo fetch_json poderia ser reescrito para fazer a maior parte do trabalho sem sair do JavaScript:

EM_JS(EM_VAL, fetch_json, (const char *url), {
  return Asyncify.handleAsync(async () => {
    url = UTF8ToString(url);
    // Invoke fetch and await the returned `Promise<Response>`.
    let response = await fetch(url);
    // Ask to read the response body as JSON and await the returned `Promise<any>`.
    let json = await response.json();
    // Convert JSON into a handle and return it.
    return Emval.toHandle(json);
  });
});

// Example URL.
val example_json = val::take_ownership(fetch_json("https://httpbin.org/json"));

// Now we can extract fields, e.g.
std::string author = json["slideshow"]["author"].as<std::string>();

Ainda temos algumas conversões explícitas nos pontos de entrada e saída da função, mas o restante agora é código JavaScript normal. Ao contrário do equivalente val, agora ele pode ser otimizado pelo mecanismo JavaScript e só requer pausar o lado C++ uma vez para todas as operações assíncronas.

Macro EM_ASYNC_JS

O único bit restante que não está bonito é o wrapper Asyncify.handleAsync. O único objetivo dele é permitir a execução de funções JavaScript async com o Async. Na verdade, esse caso de uso é tão comum que agora existe uma macro EM_ASYNC_JS especializada que os combina.

Veja como usá-lo para produzir a versão final do exemplo de fetch:

EM_ASYNC_JS(EM_VAL, fetch_json, (const char *url), {
  url = UTF8ToString(url);
  // Invoke fetch and await the returned `Promise<Response>`.
  let response = await fetch(url);
  // Ask to read the response body as JSON and await the returned `Promise<any>`.
  let json = await response.json();
  // Convert JSON into a handle and return it.
  return Emval.toHandle(json);
});

// Example URL.
val example_json = val::take_ownership(fetch_json("https://httpbin.org/json"));

// Now we can extract fields, e.g.
std::string author = json["slideshow"]["author"].as<std::string>();

EM_ASM

EM_JS é a maneira recomendada de declarar snippets de JavaScript. Ela é eficiente porque vincula os snippets declarados diretamente como qualquer outra função JavaScript importada. Ele também oferece boa ergonomia, permitindo que você declare explicitamente todos os tipos e nomes de parâmetros.

No entanto, em alguns casos, você quer inserir um snippet rápido para a chamada console.log, uma instrução debugger; ou algo semelhante e não quer se preocupar em declarar uma função separada. Nesses casos raros, um EM_ASM macros family (EM_ASM, EM_ASM_INT e EM_ASM_DOUBLE) pode ser uma opção mais simples. Essas macros são semelhantes à EM_JS, mas executam o código inline onde são inseridas, em vez de definir uma função.

Como eles não declaram um protótipo de função, eles precisam de uma maneira diferente de especificar o tipo de retorno e acessar argumentos.

É preciso usar o nome correto da macro para escolher o tipo de retorno. Os blocos EM_ASM devem funcionar como funções void, os blocos EM_ASM_INT podem retornar um valor inteiro, e os blocos EM_ASM_DOUBLE retornam números de ponto flutuante correspondentemente.

Todos os argumentos transmitidos vão estar disponíveis com os nomes $0, $1 e assim por diante no corpo do JavaScript. Assim como acontece com EM_JS ou WebAssembly, em geral, os argumentos são limitados apenas a valores numéricos: inteiros, números de ponto flutuante, ponteiros e identificadores.

Confira um exemplo de como usar uma macro EM_ASM para registrar um valor arbitrário do JS no console:

val obj = val::object();
obj.set("x", 1);
obj.set("y", 2);
// executes inline immediately
EM_ASM({
  // convert handle passed under $0 into a JavaScript value
  let obj = Emval.fromHandle($0);
  console.log(obj); // logs { x: 1, y: 2 }
}, obj.as_handle());

--js-library

Por fim, o Emscripten oferece suporte à declaração de código JavaScript em um arquivo separado em um formato de biblioteca personalizado:

mergeInto(LibraryManager.library, {
  log_value: function (val_handle) {
    let value = Emval.toValue(val_handle);
    console.log(value);
  }
});

Em seguida, é necessário declarar os protótipos correspondentes manualmente no lado do C++:

extern "C" void log_value(EM_VAL val_handle);

Depois de declarada em ambos os lados, a biblioteca JavaScript pode ser vinculada ao código principal pelo --js-library option, conectando protótipos às implementações JavaScript correspondentes.

No entanto, o formato desse módulo não é padrão e requer anotações de dependência cuidadosas. Por isso, ele é reservado principalmente para cenários avançados.

Conclusão

Neste post, analisamos várias maneiras de integrar o código JavaScript ao C++ ao trabalhar com o WebAssembly.

A inclusão desses snippets permite que você expresse longas sequências de operações de maneira mais limpa e eficiente, além de explorar bibliotecas de terceiros, novas APIs JavaScript e até mesmo recursos de sintaxe JavaScript que ainda não são expressas por C++ ou Embind.