Эмбинд Эмскриптена

Он привязывает JS к вашему wasm!

В моей последней статье о wasm я рассказывал о том, как скомпилировать библиотеку C в wasm для использования в вебе. Меня (и многих читателей) поразил грубоватый и немного неуклюжий способ вручную указывать используемые функции модуля wasm. Чтобы освежить память, вот фрагмент кода, о котором я говорю:

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

Здесь мы объявляем имена функций, отмеченных как EMSCRIPTEN_KEEPALIVE , типы возвращаемых ими значений и типы аргументов. После этого мы можем использовать методы объекта api для вызова этих функций. Однако такое использование wasm не поддерживает строки и требует ручного перемещения фрагментов памяти, что делает использование многих библиотечных API очень утомительным. Разве нет лучшего способа? Конечно, есть, иначе о чём была бы эта статья?

Искажение имени в C++

Хотя опыт разработчика был бы достаточным основанием для создания инструмента, помогающего с этими привязками, есть и более веская причина: при компиляции кода C или C++ каждый файл компилируется отдельно. Затем линкер объединяет все эти так называемые объектные файлы и преобразует их в wasm-файл. В C имена функций по-прежнему доступны линкеру в объектном файле. Всё, что нужно для вызова функции C, — это имя, которое мы передаем в виде строки в cwrap() .

С другой стороны, C++ поддерживает перегрузку функций, то есть одну и ту же функцию можно реализовать несколько раз, если её сигнатура будет отличаться (например, параметры будут иметь разные типы). На уровне компилятора такое красивое имя, как add , будет искажено, превратившись в нечто, кодирующее сигнатуру в имени функции для компоновщика. В результате мы больше не сможем найти нашу функцию по её имени.

Введите embind

Embind входит в набор инструментов Emscripten и предоставляет набор макросов C++, позволяющих аннотировать код C++. Вы можете объявить, какие функции, перечисления, классы или типы значений вы планируете использовать из JavaScript. Начнём с простых функций:

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

По сравнению с моей предыдущей статьей, мы больше не включаем emscripten.h , поскольку нам больше не нужно аннотировать наши функции с помощью EMSCRIPTEN_KEEPALIVE . Вместо этого у нас есть раздел EMSCRIPTEN_BINDINGS , в котором перечислены имена, под которыми мы хотим предоставлять наши функции JavaScript.

Для компиляции этого файла можно использовать ту же конфигурацию (или, если хотите, тот же образ Docker), что и в предыдущей статье . Чтобы использовать embind, добавляем флаг --bind :

$ emcc --bind -O3 add.cpp

Теперь осталось только создать HTML-файл, который загрузит наш свежесозданный модуль wasm:

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

Как видите, мы больше не используем cwrap() . Всё работает сразу, как надо. Но, что ещё важнее, нам не нужно беспокоиться о ручном копировании фрагментов памяти для корректной работы со строками! embind предоставляет вам это бесплатно, вместе с проверкой типов:

Ошибки DevTools при вызове функции с неверным количеством аргументов или аргументы имеют неправильный тип

Это очень здорово, поскольку мы можем выявлять некоторые ошибки на ранних стадиях, вместо того чтобы разбираться с порой довольно громоздкими ошибками wasm.

Объекты

Многие конструкторы и функции JavaScript используют объекты параметров. Это удобный шаблон в JavaScript, но его очень сложно реализовать вручную в wasm. embind может помочь и здесь!

Например, я придумал невероятно полезную функцию C++ для обработки строк, и мне срочно нужно использовать её в вебе. Вот как я это сделал:

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

Я определяю структуру для параметров моей функции processMessage() . В блоке EMSCRIPTEN_BINDINGS я могу использовать value_object , чтобы JavaScript воспринимал это значение C++ как объект. Я также мог бы использовать value_array , если бы предпочел использовать это значение C++ как массив. Я также привязываю функцию processMessage() , а остальное — магия embind. Теперь я могу вызывать функцию processMessage() из JavaScript без какого-либо шаблонного кода:

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

Классы

Для полноты картины я также должен показать вам, как embind позволяет выводить целые классы, что обеспечивает значительную синергию с классами ES6. Вы, вероятно, уже начали замечать закономерность:

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

Со стороны JavaScript это почти похоже на собственный класс:

<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>

А как насчет С?

Функция embind написана для C++ и может использоваться только в файлах C++, но это не значит, что нельзя компоновать файлы C! Чтобы объединить C и C++, достаточно разделить входные файлы на две группы: одну для файлов C и одну для файлов C++, а также добавить флаги CLI для emcc следующим образом:

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

Заключение

Embind значительно улучшает работу разработчика с wasm и C/C++. Эта статья не охватывает все возможности Embind. Если вам интересно, рекомендую продолжить изучение документации Embind . Имейте в виду, что использование Embind может увеличить размер как вашего wasm-модуля, так и вашего связующего JavaScript-кода до 11 КБ при сжатии gzip — особенно заметно для небольших модулей. Если у вас очень маленькая область wasm, Embind может стоить дороже, чем он того стоит в рабочей среде! Тем не менее, вам определённо стоит попробовать.