• 开始日期:2018-10-05
  • RFC PR:https://github.com/rustwasm/rfcs/pull/5
  • 跟踪问题:(保留为空)

摘要

#[wasm_bindgen] 默认使用 structural,并添加一个新的属性 final 用于选择加入当前的行为。实现后,使用 Derefweb-sysjs-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();
}
#}

我们今天将看到 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 的未来 主机绑定 提案。主机绑定提案承诺通过消除调用 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 函数垫片,我们可以比其他方式更快,从而产生 finalstructural 更快的印象。但是,这个未来依赖于当今 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 中有详细列出。

未解决的问题

目前没有!