- 起始日期:2018-10-05
- RFC PR:https://github.com/rustwasm/rfcs/pull/5
- 追蹤議題:(留空)
摘要
將 #[wasm_bindgen]
預設改為使用 structural
,並新增一個名為 final
的屬性,讓使用者可以選擇沿用目前的行為。實作完成後,使用 Deref
來模擬 web-sys
和 js-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
,包含遞移的父類別。這會在程式碼產生器中用於產生 Element
的 AsRef
實作。
#[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(); } #}
今天我們會看到主控台輸出 parent
和 child
。好的,到目前為止一切正常!不過,我們知道有 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(); } #}
在這裡,我們會天真地 (而且正確地) 預期會像之前一樣輸出 parent
和 child
,但讓我們驚訝的是,它實際上輸出了兩次 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 函式填充程式,我們可以比以前更快,讓人感覺 final
比 structural
更快。然而,這個未來依賴於 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 中有詳細列出。
未解決的問題
目前沒有!