Emscripten 的魔法

它會將 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 會免費提供這項功能,並進行型別檢查:

當您呼叫的函式引數數量錯誤或引數類型錯誤時,開發人員工具會發生錯誤

這非常棒,因為我們可以及早發現某些錯誤,而不用處理有時相當難以處理的 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++ 值視為物件。如果我偏好將這個 C++ 值做為陣列使用,也可以使用 value_array。我也會繫結 processMessage() 函式,其餘部分則是 embind 魔法。我現在可以從 JavaScript 呼叫 processMessage() 函式,而不需要任何固定程式碼:

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>

那 C 呢?

embind 是為 C++ 編寫,只能用於 C++ 檔案,但這並不表示您無法連結 C 檔案!如要混合 C 和 C++,您只需將輸入檔案分成兩組:一組是 C 檔案,另一組是 C++ 檔案,然後擴充 emcc 的 CLI 標記,如下所示:

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

結論

在使用 wasm 和 C/C++ 時,embind 可大幅改善開發人員體驗。本文未涵蓋 embind 提供的所有選項。如果您有興趣,建議您繼續參閱 embind 的說明文件。請注意,使用 embind 時,在 gzip 時,您的 wasm 模組和 JavaScript 黏著劑程式碼會變得更大,最多可達 11k,特別是在小型模組上。如果您只有非常小的 WASM 途徑,在實際工作環境中,embind 的成本可能會超過其價值!不過,您還是應該試試看。