- 开始日期:2018-10-05
- RFC PR:https://github.com/rustwasm/rfcs/pull/5
- 跟踪问题:(保留为空)
摘要
将 #[wasm_bindgen]
默认使用 structural
,并添加一个新的属性 final
用于选择加入当前的行为。实现后,使用 Deref
在 web-sys
和 js-sys
中模拟类继承层次结构,以实现对 Web 类型超类方法的符合人体工程学的使用。
动机
最初的动机在 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
特性来模拟 Web 中 web-sys
中的类层次结构。这也建议更改 #[wasm_bindgen]
,以使使用 Deref
成为可能,以便使用 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 的未来 主机绑定 提案。主机绑定提案承诺通过消除调用 DOM 方法时所需的许多动态检查来实现比 JS 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
与否”。如果你在浏览器中点击“运行测试”,过一会儿你会看到两个条形图。左侧的是使用 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
时)。
好的!这基本上是说 final
以前是默认值,因为我们认为它更快,但事实证明,在今天的 JS 引擎中,它并不总是更快。因此,此 RFC 建议将 structural
设为默认值是可以接受的。
缺点
Deref
是一个相对低调的特征,却有着不成比例的巨大影响。它影响着方法解析(.
运算符)以及强制转换(&T
到 &U
)。在 web-sys
或生态系统中的 JS API 中发现这一点并不总是最容易的事情。不过,人们认为,在实际使用 JS API 时,Deref
的这方面不会经常出现。相反,大多数 API 将“按原样”工作,正如您在 JS 中所期望的那样,在 Rust 中也是如此,Deref
是一个不显眼的解决方案,开发人员可以忽略它,只需调用方法即可。
此外,Deref
的缺点是它不是专门为类继承层次结构设计的。例如,*element
生成一个 Node
,**element
生成一个 Object
,等等。不过,预计这在实践中不会经常出现,相反,自动强制转换将涵盖几乎所有类型转换。
基本原理和替代方案
这种设计的主要替代方案是 RFC 3,使用特征来模拟继承层次结构。该提案的优缺点在 RFC 3 中有详细列出。
未解决的问题
目前没有!