• 起始日期:2018-10-05
  • RFC PR:https://github.com/rustwasm/rfcs/pull/5
  • 追蹤議題:(留空)

摘要

#[wasm_bindgen] 預設改為使用 structural,並新增一個名為 final 的屬性,讓使用者可以選擇沿用目前的行為。實作完成後,使用 Deref 來模擬 web-sysjs-sys 中的類別繼承階層,以便更方便地使用 Web 類型的父類別方法。

動機

這項 RFC 的初始動機在 RFC 3 中有概述,也就是 web-sys crate 提供了 Web 上許多 API 的綁定,但要使用父類別的功能卻相當麻煩。

Web 程式廣泛使用類別繼承階層,而在目前的 web-sys 中,每個類別都有自己的 struct 類型和固有方法。這些類型之間透過 AsRef 實作子類別關係,但實際上要使用父類別的功能卻很不方便!例如


# #![allow(unused_variables)]
#fn main() {
let x: &Element = ...;
let y: &Node = x.as_ref();
y.append_child(...);
#}

或是...


# #![allow(unused_variables)]
#fn main() {
let x: &Element = ...;
<Element as AsRef<Node>>::as_ref(x)
    .append_child(...);
#}

如果我們能以更原生、更方便的方式支援這個功能就好了!

注意:雖然這項 RFC 與 RFC 3 的動機相同,但它提出了另一種解決方案,特別是透過預設切換到 structural 來實現,這在 RFC 3 中有討論,但希望在這裡能正式概述。

詳細說明

這項 RFC 建議使用內建的 Deref trait 來模擬 web-sys 中 Web 上的類別階層。它也建議修改 #[wasm_bindgen],讓使用 Deref 來綁定任意 JS API (例如 NPM 上的 API) 變得可行。

例如,web-sys 將包含


# #![allow(unused_variables)]
#fn main() {
impl Deref for Element {
    type Target = Node;

    fn deref(&self) -> &Node { /* ... */ }
}
#}

讓我們可以將上面的例子寫成


# #![allow(unused_variables)]
#fn main() {
let x: &Element = ...;
x.append_child(...); // implicit deref to `Node`!
#}

web-sys 和其他地方的所有 JS 類型最多都只有一個父類別。然而,目前 #[wasm_bindgen] 屬性允許多個 extends 屬性來指定父類別


# #![allow(unused_variables)]
#fn main() {
#[wasm_bindgen]
extern {
    #[wasm_bindgen(extends = Node, extends = Object)]
    type Element;

    // ...
}
#}

web-sys API 產生器目前會列出所有父類別的 extends,包含遞移的父類別。這會在程式碼產生器中用於產生 ElementAsRef 實作。

#[wasm_bindgen] 的程式碼產生將會根據以下規則更新

  • 如果沒有 extends 屬性,則定義的類型會實作 Deref<Target=JsValue>
  • 否則,會使用第一個 extends 屬性來實作 Deref<Target=ListedType>
  • (長期目標,目前需要破壞性變更) 拒絕多個 extends 屬性,要求只能有一個。

這表示 web-sys 可能需要更新,以確保直接父類別在 extends 中列在第一個。手動綁定將會繼續運作,並且會保留舊的 AsRef 實作以及新的 Deref 實作。

Deref 的實作具體如下


# #![allow(unused_variables)]
#fn main() {
impl Deref for #imported_type {
    type Target = #target_type;

    #[inline]
    fn deref(&self) -> &#target_type {
        ::wasm_bindgen::JsCast::unchecked_ref(self)
    }
}
#}

預設切換到 structural

如果我們在 wasm-bindgen 中照原樣實作上述的 Deref 提案,它會有一個關鍵的缺點。它可能無法正確處理繼承!讓我們用一個例子來說明。假設我們想要匯入一些 JS 程式碼

class Parent {
    constructor() {}
    method() { console.log('parent'); }
}

class Child extends Parent {
    constructor() {}
    method() { console.log('child'); }
}

我們會在 Rust 中這樣綁定


# #![allow(unused_variables)]
#fn main() {
#[wasm_bindgen]
extern {
    type Parent;
    #[wasm_bindgen(constructor)]
    fn new() -> Parent;
    #[wasm_bindgen(method)]
    fn method(this: &Parent);

    #[wasm_bindgen(extends = Parent)]
    type Child;
    #[wasm_bindgen(constructor)]
    fn new() -> Child;
    #[wasm_bindgen(method)]
    fn method(this: &Child);
}
#}

然後我們可以這樣使用它


# #![allow(unused_variables)]
#fn main() {
#[wasm_bindgen]
pub fn run() {
    let parent = Parent::new();
    parent.method();
    let child = Child::new();
    child.method();
}
#}

今天我們會看到主控台輸出 parentchild。好的,到目前為止一切正常!不過,我們知道有 Deref<Target=Parent> for Child,所以讓我們稍微修改一下這個例子


# #![allow(unused_variables)]
#fn main() {
#[wasm_bindgen]
pub fn run() {
    call_method(&Parent::new());
    call_method(&Child::new());
}

fn call_method(object: &Parent) {
    object.method();
}
#}

在這裡,我們會天真地 (而且正確地) 預期會像之前一樣輸出 parentchild,但讓我們驚訝的是,它實際上輸出了兩次 parent

問題出在 #[wasm_bindgen] 今天處理方法呼叫的方式。當你寫


# #![allow(unused_variables)]
#fn main() {
#[wasm_bindgen(method)]
fn method(this: &Parent);
#}

wasm-bindgen (CLI 工具) 會產生如下所示的 JS 程式碼

const Parent_method_target = Parent.prototype.method;

export function __wasm_bindgen_Parent_method(obj) {
    Parent_method_target.call(getObject(obj));
}

在這裡我們可以看到,預設情況下,wasm-bindgen深入每個類別的 prototype 來找出要呼叫的方法。這也意味著當在 Rust 中呼叫 Parent::method 時,它會無條件地使用 Parent 上定義的方法,而不是像 JS 通常做的那樣沿著原型鏈找到正確的 method 方法。

為了改善這種情況,wasm-bindgen 提供了一個 structural 屬性來解決這個問題,當像這樣使用時


# #![allow(unused_variables)]
#fn main() {
#[wasm_bindgen(method, structural)]
fn method(this: &Parent);
#}

表示會產生以下 JS 程式碼

const Parent_method_target = function() { this.method(); };

// ...

在這裡我們可以看到,它產生了一個 JS 函式填充程式,而不是使用原型中的原始函式值。然而,這也意味著我們上面的例子確實會先輸出 parent 然後再輸出 child,因為 JS 會使用原型查找來找到 method 方法。

呼!好的,了解這些基本資訊後,我們可以發現,如果省略 structural,當將覆寫方法的子類別傳遞給接收父類別的方法時,JS 類別階層可能會出現微妙的錯誤

一個簡單的解決方案就是在任何地方都使用 structural,所以... 讓我們提出這個建議!因此,這項 RFC 建議將 #[wasm_bindgen] 的行為改為所有綁定都標記為 structural。雖然技術上來說這是一個破壞性變更,但我們認為目前沒有任何用法會實際遇到這種情況。

新增 #[wasm_bindgen(final)]

由於 structural 並非目前的預設值,因此我們實際上沒有名稱來描述 #[wasm_bindgen] 目前的預設行為。這項 RFC 建議在 #[wasm_bindgen] 中新增一個名為 final 的屬性,表示它應該具有目前的行為。

當附加到屬性或方法時,final 屬性表示該方法或屬性應該透過類別的 prototype 處理,而不是透過原型鏈以結構化的方式查找。

你可以將它理解為「目前所有東西預設都是 final」。

為什麼可以將 structural 設為預設值?

你可能會有一個很合理的疑問:「如果 structural 不是目前的預設值,為什麼可以切換它?」為了回答這個問題,讓我們先探討為什麼 final 是目前的預設值!

從一開始,wasm-bindgen 的設計就考慮到了未來的 WebAssembly 主機綁定 提案。主機綁定提案承諾提供比 JS 更快的 DOM 訪問速度,方法是移除呼叫 DOM 方法時必要的許多動態檢查。然而,這項提案仍處於相對早期的階段,尚未在任何瀏覽器中實作 (據我們所知)。

在 Web 上的 WebAssembly 中,所有匯入的函式都必須是普通的 JS 函式。它們目前都是以 undefined 作為 this 參數呼叫的。然而,使用主機綁定,可以指定匯入的函式使用函式的第一個參數作為 this 參數 (就像 JS 中的 Function.call)。這反過來帶來了消除呼叫匯入功能時必要的任何填充函式的承諾。

例如,今天對於 #[wasm_bindgen(method)] fn parent(this: &Parent);,我們會產生如下所示的 JS 程式碼


# #![allow(unused_variables)]
#fn main() {
#[wasm_bindgen(method)]
fn method(this: &Parent);
#}

表示會產生以下 JS 程式碼

const Parent_method_target = Parent.prototype.method;

export function __wasm_bindgen_Parent_method(idx) {
    Parent_method_target.call(getObject(idx));
}

如果我們暫時假設 anyref 已實作,我們可以將其改為

const Parent_method_target = Parent.prototype.method;

export function __wasm_bindgen_Parent_method(obj) {
    Parent_method_target.call(obj);
}

(注意不需要 getObject)。最後,使用 主機綁定,我們可以指定 wasm 模組匯入的 __wasm_bindgen_Parent_method 使用第一個參數作為 this,這表示我們可以將其轉換為

export const __wasm_bindgen_Parent_method = Parent.prototype.method;

然後,不需要 JS 函式填充程式!使用 structural,在這個未來世界中,我們仍然需要一個函式填充程式

export const __wasm_bindgen_Parent_method = function() { this.method(); };

好的,了解這些基本資訊後,讓我們回到為什麼-final-預設值。 主機綁定 的承諾是,透過消除所有這些必要的 JS 函式填充程式,我們可以比以前更快,讓人感覺 finalstructural 更快。然而,這個未來依賴於 wasm 引擎中目前尚未實作的許多功能。因此,讓我們先了解一下目前的效能如何!

我一直在慢慢地準備一個 微基準測試套件,用於測量 JS/wasm/wasm-bindgen 的效能。這裡有趣的是「structural vs not」這個基準測試。如果你在瀏覽器中點選「執行測試」,一段時間後你會看到出現兩個長條。左邊的是使用 final 的方法呼叫,右邊的是使用 structural 的方法呼叫。我在我的電腦上看到的結果是

  • Firefox 62,structural 快 3%
  • Firefox 64,structural 慢 3%
  • Chrome 69,structural 慢 5%
  • Edge 42,structural 慢 22%
  • Safari 12,strutural 慢 17%

所以看起來在 Firefox/Chrome 中並沒有太大的差別,但在 Edge/Safari 中使用 final 快很多!然而,事實證明,我們並沒有盡可能地最佳化 structural。讓我們將產生的程式碼從

const Parent_method_target = function() { this.method(); };

export function __wasm_bindgen_Parent_method(obj) {
    Parent_method_target.call(getObject(obj));
}

改為...

export function __wasm_bindgen_Parent_method(obj) {
    getObject(obj).method();
}

(今天需要手動編輯 JS)

如果我們重新執行基準測試 (抱歉,沒有線上展示),我們會得到

  • Firefox 62,structural 快 22%
  • Firefox 64,structural 快 10%
  • Chrome 69,structural 慢 0.3%
  • Edge 42,structural 快 15%
  • Safai 12,structural 慢 8%

這些數字看起來很不一樣!這裡有一些強有力的數據顯示,final 在今天並非總是更快,實際上幾乎總是更慢 (當我們稍微最佳化 structural 時)。

好的!這 basically 都是非常冗長地說明final 過去是預設值,因為我們認為它更快,但事實證明,在今天的 JS 引擎中,它並非總是更快。因此,這項 RFC 建議將 structural 設為預設值是可以的。

缺點

Deref 是一個有點安靜的 trait,但影響卻很大。它會影響方法解析 (. 運算符) 以及強制轉換 (&T&U)。在 web-sys 和/或生態系統中的 JS API 中發現這一點並非總是那麼容易。不過,我們認為在實際使用 JS API 時,Deref 的這個方面並不會經常出現。相反,大多數 API 都會像你在 JS 中預期的那樣「照常運作」,Deref 對開發者來說是一個不顯眼的解決方案,他們大多數時候可以忽略它,直接呼叫方法即可。

此外,Deref 的缺點是它不是專門為類別繼承階層設計的。例如,*element 會產生一個 Node**element 會產生一個 Object,依此類推。不過,預計這在實務中並不會經常出現,自動強制轉換將涵蓋幾乎所有類型的轉換。

理論依據和替代方案

這個設計的主要替代方案是 RFC 3,使用 trait 來模擬繼承階層。該提案的優缺點在 RFC 3 中有詳細列出。

未解決的問題

目前沒有!