Skip to content

Latest commit

 

History

History
1599 lines (1380 loc) · 93.4 KB

Explainer.md

File metadata and controls

1599 lines (1380 loc) · 93.4 KB

组件模型解释器

解释器本文介绍了组件的汇编级(assembly-level)定义,以及原生JavaScript运行时组件嵌入提案。如需面向用户的详细说明,请查看组件模型文档

限制特性(Gated features)

默认情况下,本解释器中描述的功能(以及支持的Binary.mdWIT.mdCanonicalABI.md)已实现并包含在WASI Preview 2稳定性里程碑中。不属于 Preview 2 的功能由以下列出的表情符号之一划定;这些表情符号将在实现、被视为稳定并包含在未来的里程碑中后被删除:

  • 🪙: 值导入/导出(imports/exports)和组件级启动函数(component-level start function)
  • 🪺: 导入/导出名称中的嵌套命名空间和包(nested namespaces and packages)
  • 🧵: 线程内置

(基于之前向 WebAssembly CG 提出的范围和分层提案,此仓库合入并取代了模块链接(module-linking)接口类型(interface-types)提案,将它们的一些原始功能推送到MVP后续代办的未来功能中。

语法

本节使用EBNF语法定义组件,该语法解析介于纯粹抽象语法树(如Core WebAssembly规范的结构部分)和完整文本格式(如Core WebAssembly规范的文本格式部分之间的内容。目标是平衡完整性和简洁性,只需提供足够的细节来编写示例并以二进制格式部分的样式定义二进制格式(binary format) ,详细严谨的语法将推迟到正式规范

语法简化的主要方法是定义的使用,在实际文本格式中使用标识符(<id>)显示地引用定义X的索引(写作<Xidx>),在解析时检查标识符是否解析为X定义,然后将解析后的索引嵌入到AST中。

此外,假定了下面未明确定义的Core WebAssembly文本格式定义的标准缩写(例如,内联导出定义inline export definitions)。

组件定义(Component Definitions)

顶层component是各种类型定义的序列:

component  ::= (component <id>? <definition>*)
definition ::= core-prefix(<core:module>)
             | core-prefix(<core:instance>)
             | core-prefix(<core:type>)
             | <component>
             | <instance>
             | <alias>
             | <type>
             | <canon>
             | <start> 🪺
             | <import>
             | <export>
             | <value> 🪙

其中,当 X 解析为 '(' Y ')' 时,core-prefix(X) 解析为 '(' 'core' Y ')'

组件类似于Core WebAssembly模块,其包含的定义是无环的:定义只能引用先前的定义(在AST、文本格式和二进制格式中)。但与模块不同的是,组件可以任意交错不同类型的定义。

元函数(meta-function)core-prefix把用于解析Core WebAssembly定义的语法规则进行相同的转换,但会在最左侧(后添加core标志。例如,当core:module代表(module (func))时,则core-prefix(<core:module>)(core module (func))。请注意,内部的func不需要core前缀;前缀core标识用于表示解析从组件定义过渡为核心定义。

组件模型未修改core:module项目,因此组件嵌入了当前标准的Core WebAssembly(文本和二进制格式)模块,允许复用未经修改的Core WebAssembly实现。目前Core WebAssembly不包含core:instance项目,但当Core WebAssembly采纳模块链接(module-linking)提案后则会涵盖。下面将介绍新的核心定义及对应的组件级部分。最后,现有的core:type项目按模块链接提案扩展增加核心模块类型。因此,总体思路是将核心定义(在AST、二进制和文本格式中)作为已添加至Core WebAssembly中,因此最终可以分层共享解码和验证的实现。

接下来的定义类型是组件自身的递归定义,由此形成的组件树中所有其他类型定义只出现在叶子节点上。例如,基于目前定义的内容,我们可以编写如下组件:

(component
  (component
    (core module (func (export "one") (result i32) (i32.const 1)))
    (core module (func (export "two") (result f32) (f32.const 2)))
  )
  (core module (func (export "three") (result i64) (i64.const 3)))
  (component
    (component
      (core module (func (export "four") (result f64) (f64.const 4)))
    )
  )
  (component)
)

上述顶层组件构成了一棵树,叶子节点包含4个模块和1个组件。然而,由于没有任何instance定义(后续介绍),运行时不会实例化或执行任何内容;它们均是无用代码(dead code)。

索引空间(Inedx Spaces)

类似于Core WebAssembly,组件模型将每个definition放入一组固定的索引空间,从而允许后续定义(在文本和二进制格式中)通过非负整数索引引用该定义。在定义、验证和执行组件时,有5个组件级索引空间(component-level index spaces):

  • (component) functions
  • (component) values
  • (component) types
  • component instances
  • components

WebAssembly 1.0也存在5个核心索引空间:

  • (core) functions
  • (core) tables
  • (core) memories
  • (core) globals
  • (core) types

以及2个额外的核心索引空间,其中包含由组件模型引入的核心定义,但WebAssembly 1.0中未定义(然而:模块链接(module-linking)提案将会添加):

  • module instances
  • modules

实现需要维护共12个索引空间,例如验证组件。此处12个索引空间与下方类别项目的终端符 1:1对应,因此 “类别(sort)” 和 “索引空间(index space)” 可以互换使用。

类似于Core WebAssembly,组件模型的文本格式允许使用标识符(identifiers)代替索引,这些标识符会被解析为AST的索引(在此基础上定义验证和执行)。因此,下面两个组件等价:

(component
  (core module (; empty ;))
  (component   (; empty ;))
  (core module (; empty ;))
  (export "C" (component 0))
  (export "M1" (core module 0))
  (export "M2" (core module 1))
)
(component
  (core module $M1 (; empty ;))
  (component $C    (; empty ;))
  (core module $M2 (; empty ;))
  (export "C" (component $C))
  (export "M1" (core module $M1))
  (export "M2" (core module $M2))
)

实例定义(Instance definitions)

鉴于模块和组件代表不可变的代码,实例(instance)将代码与潜在可变的状态(potentially-mutable state,例如线性内存)相关联,因此在运行代码前必须创建实例。实例定义通过选择一个模块或组件,并提供一组命名的参数(arguments)来满足所选模块或组件的所有命名导入(imports),从而创建模块或组件实例。

定义core:instance实例的语法为:

core:instance       ::= (instance <id>? <core:instancexpr>)
core:instanceexpr   ::= (instantiate <core:moduleidx> <core:instantiatearg>*)
                      | <core:inlineexport>*
core:instantiatearg ::= (with <core:name> (instance <core:instanceidx>))
                      | (with <core:name> (instance <core:inlineexport>*))
core:sortidx        ::= (<core:sort> <u32>)
core:sort           ::= func
                      | table
                      | memory
                      | global
                      | type
                      | module
                      | instance
core:inlineexport   ::= (export <core:name> <core:sortidx>)

当通过instantiate实例化模块时,核心模块的双层导入(two-level imports)按如下方式解析:

  1. 导入的core:name的第一项(如:import "a" "one"中的"a"),通过在core:instantiatearg的命名列表中查找以选择核心模块实例(core module instance)。(未来,当core wasm增加单层导入(single-level imports)时支持其他的core:sort导入)
  2. 导入的core:name的第二项(如:import "a" "one"中的"one"),通过在上述选择的核心模块实例查找导出(export)以确定导入的核心定义(core definition)

每个core:sort都与包含该类核心定义的独立索引空间1:1匹配。core:sortidx中的u32字段用于选择一个其对应索引空间的定义。

在此基础上,我们可以将两个核心模块$A和$B以下组件链接在一起:

(component
  (core module $A
    (func (export "one") (result i32) (i32.const 1))
  )
  (core module $B
    (func (import "a" "one") (result i32))
  )
  (core instance $a (instantiate $A))
  (core instance $b (instantiate $B (with "a" (instance $a))))
)

查看其他类别的案例,我们需要下一节介绍的alias定义。

core:instanceexpr<core:inlineexport>*形式允许把先前定义组合在一起直接创建模块实例,无需instantiate辅助模块。core:instantiatearg<core:inlineexport>*形式为语法糖,在文本解析时扩展通过with引用外部实例定义。为了展示这些示例,我们依然需要下一节介绍的alias定义。

定义组件实例的语法与核心模块实例保持对称,但是在组件级别扩展了sort定义:

instance       ::= (instance <id>? <instanceexpr>)
instanceexpr   ::= (instantiate <componentidx> <instantiatearg>*)
                 | <inlineexport>*
instantiatearg ::= (with <name> <sortidx>)
                 | (with <name> (instance <inlineexport>*))
name           ::= <core:name>
sortidx        ::= (<sort> <u32>)
sort           ::= core <core:sort>
                 | func
                 | value 🪙
                 | type
                 | component
                 | instance
inlineexport   ::= (export <exportname> <sortidx>)

由于组件级(component-level)的函数、类型和实例与核心级(core-level)有所区别,它们被放置在不同的索引空间中并进行单独索引。组件可以导入和导出各种核心定义(需与无共享(shared-nothing)模型兼容,当前仅适用于module,未来可能包含data)。因此,组件级sort将完整的core:sort集合注入其中用于引用(由验证规则丢弃上下文不允许的核心类别)。

name复用Core WebAssembly的core:name引号字符串字面量语法(出现于核心模块的导入和导出且可包含任何有效UTF-8字符串)。

🪙 sort的value指的是实例化过程中提供和消耗的制。其工作方式在值定义部分详细介绍。

在介绍组件实例化的复杂示例之前,我们需引入几个其他定义,它们允许组件导入、定义和导出组件函数。

别名定义(Alias Definitions)

别名定义项将其他组件索引空间中的定义映射到当前组件索引空间中。如下述的AST所示,别名有三种"目标(targets)":组件实例的导出export、核心模块实例的核心导出core export以及外部组件(outer component)定义(包含当前组件):

alias            ::= (alias <aliastarget> (<sort> <id>?))
aliastarget      ::= export <instanceidx> <name>
                   | core export <core:instanceidx> <core:name>
                   | outer <u32> <u32>

如果存在别名,其id会关联所添加的新索引,然后可使用于任何id所在的地方。

关于export别名,校验保证name是目标实例的导出且匹配类别(sort)。

关于outer别名,u32对作为德布鲁因索引(de Bruijn index),第一个u32是需跳过的封装组件/模块的数量,第二个u32是目标类别索引空间的索引。尤其,第一个u32允许为0,此时外部别名(outer alias)引用当前组件。为了保持模块实例化的无环性,外部别名只被允许指向先前的外部定义。

包含外部别名(outer aliases)的组件在实例化时,实际上会产生一个闭包,包含外部别名定义的副本。因为普遍假设组件是不可变的值,所以仅限于引用不可变的定义:非资源类型、模块和组件。(未来,可以通过某种"stateful"类型属性记录结果组件(resulting component)在其类型中的状态,从而允许所有类别的定义支持外部别名)

这两种别名都具备内联隐式声明的语法糖:

对于export别名,内联语法糖扩展了sortidx的定义和各种类别明确(sort-specific)的索引:

sortidx     ::= (<sort> <u32>)          ;; as above
              | <inlinealias>
Xidx        ::= <u32>                   ;; as above
              | <inlinealias>
inlinealias ::= (<sort> <u32> <name>+)

如果<sort>引用了<core:sort>,那么inlinealias<u32>则是<core:instanceidx>;否则是<instanceidx>。例如,下面的代码片段使用了两个内联函数别名:

(instance $j (instantiate $J (with "f" (func $i "f"))))
(export "x" (func $j "g" "h"))

脱糖后为:

(alias export $i "f" (func $f_alias))
(instance $j (instantiate $J (with "f" (func $f_alias))))
(alias export $j "g" (instance $g_alias))
(alias export $g_alias "h" (func $h_alias))
(export "x" (func $h_alias))

对于outer别名,内联语法糖仅为外部定义的标识符,使用正常的词法作用域规则解析。例如,以下组件:

(component
  (component $C ...)
  (component
    (instance (instantiate $C))
  )
)

脱糖后为:

(component $Parent
  (component $C ...)
  (component
    (alias outer $Parent $C (component $Parent_C))
    (instance (instantiate $Parent_C))
  )
)

最后,为了与imports对称,别名可以颠倒书写顺序,把类别放到前面:

    (func $f (import "i" "f") ...type...) ≡ (import "i" "f" (func $f ...type...))   (WebAssembly 1.0)
          (func $f (alias export $i "f")) ≡ (alias export $i "f" (func $f))
   (core module $m (alias export $i "m")) ≡ (alias export $i "m" (core module $m))
(core func $f (alias core export $i "f")) ≡ (alias core export $i "f" (core func $f))

通过目前的定义,我们能够以任意重命名的方式链接模块:

(component
  (core module $A
    (func (export "one") (result i32) (i32.const 1))
    (func (export "two") (result i32) (i32.const 2))
    (func (export "three") (result i32) (i32.const 3))
  )
  (core module $B
    (func (import "a" "one") (result i32))
  )
  (core instance $a (instantiate $A))
  (core instance $b1 (instantiate $B
    (with "a" (instance $a))                      ;; 未重命名
  ))
  (core func $a_two (alias core export $a "two")) ;; ≡ (alias core export $a "two" (core func $a_two))
  (core instance $b2 (instantiate $B
    (with "a" (instance
      (export "one" (func $a_two))                ;; 重命名,使用界外(out-of-line)别名
    ))
  ))
  (core instance $b3 (instantiate $B
    (with "a" (instance
      (export "one" (func $a "three"))            ;; 重命名,使用<inlinealias>
    ))
  ))
)

为了展示链接组件的类似示例,我们需要组件级类型和函数定义,这将在接下来的两节中介绍。

类型定义(Type Definitions)

定义核心类型语法基于现有进行了扩展,增加module类型构造器:

core:rectype     ::= ... 来自Core WebAssembly规范
core:typedef     ::= ... 来自Core WebAssembly规范
core:subtype     ::= ... 来自Core WebAssembly规范
core:comptype    ::= ... 来自Core WebAssembly规范
                   | <core:moduletype>
core:moduletype  ::= (module <core:moduledecl>*)
core:moduledecl  ::= <core:importdecl>
                   | <core:type>
                   | <core:alias>
                   | <core:exportdecl>
core:alias       ::= (alias <core:aliastarget> (<core:sort> <id>?))
core:aliastarget ::= outer <u32> <u32>
core:importdecl  ::= (import <core:name> <core:name> <core:importdesc>)
core:exportdecl  ::= (export <core:name> <core:exportdesc>)
core:exportdesc  ::= strip-id(<core:importdesc>)

当 X 解析为 '(' sort <id>? Y ')',则 strip-id(X) 解析为 '(' sort Y ')'

此处,GC提案中定义的core:comptype(复合类型"composite type"的简称)扩展了module类型构造器。GC提案还在核心wasm类型中增加了递归和显式的子类型。由于它们有不同的需求和预期使用方式,模块类型支持隐式自类型话且非递归。以鸟巢,现有的核心验证规则需要模块类型的声明超类为空并禁止递归使用模块类型。

在MVP中,验证规则会丢弃core:moduletype定义或core:moduletype的别名,因为在模块链接之前,核心模块不能自己导入或导出其他核心模块。

模块类型主体包含有序的“模块声明符(module declarators)”列表,它们在类型级别描述了模块的导入和导出。在模块类型上下文中,导入和导出声明符都可以在WebAssembly 1.0定义的core:importdesc中复用,唯一区别是在文本格式中core:importdesc可以绑定一个标识符在后续复用,但core:exportdesc不可以。

随着Core WebAssembly的类型导入(type-imports),模块类型将需要根据导入类型的能力来定义导出类型。为此,模块类型以空类型索引空间开始,该空间由type声明符填充。以便未来这些type声明符可以引用模块类型自身的本地类型导入。例如,未来以下模块类型将可表达为:

(component $C
  (core type $M (module
    (import "" "T" (type $T))
    (type $PairT (struct (field (ref $T)) (field (ref $T))))
    (export "make_pair" (func (param (ref $T)) (result (ref $PairT))))
  ))
)

在此示例中,$M具有不同于$C的类型索引空间,元素0是导入类型,元素1是struct类型,元素2是隐式创建的引用了前两个元素的func类型。

最后,core:alias模块声明符允许模块类型定义通过outer type别名在闭合组件核心的类型索引空间中复用(而不是重新定义)类型定义。MVP中,验证限制core:alias模块声明允许outer type别名(闭合组件或组件类型的核心类型索引空间)。未来,更多类型的别名将有意义并被允许。

举例来说,下面的组件定义了两个语义等价的模块类型,前者通过type声明符定义函数类型,后者通过alias声明符引用函数类型。

(component $C
  (core type $C1 (module
    (type (func (param i32) (result i32)))
    (import "a" "b" (func (type 0)))
    (export "c" (func (type 0)))
  ))
  (core type $F (func (param i32) (result i32)))
  (core type $C2 (module
    (alias outer $C $F (type))
    (import "a" "b" (func (type 0)))
    (export "c" (func (type 0)))
  ))
)

组件级类型定义同核心级类型定义对称,但使用一组完全不同的值类型。不同于假设共享线性内存来传递复合值的低级类型core:valtype,组件级值类型假设没有共享内存,因此其必须高级别的,能描述全部的复合值。

type          ::= (type <id>? <deftype>)
deftype       ::= <defvaltype>
                | <resourcetype>
                | <functype>
                | <componenttype>
                | <instancetype>
defvaltype    ::= bool
                | s8 | u8 | s16 | u16 | s32 | u32 | s64 | u64
                | f32 | f64
                | char | string
                | (record (field "<label>" <valtype>)+)
                | (variant (case "<label>" <valtype>?)+)
                | (list <valtype>)
                | (tuple <valtype>+)
                | (flags "<label>"+)
                | (enum "<label>"+)
                | (option <valtype>)
                | (result <valtype>? (error <valtype>)?)
                | (own <typeidx>)
                | (borrow <typeidx>)
valtype       ::= <typeidx>
                | <defvaltype>
resourcetype  ::= (resource (rep i32) (dtor <funcidx>)?)
functype      ::= (func <paramlist> <resultlist>)
paramlist     ::= (param "<label>" <valtype>)*
resultlist    ::= (result "<label>" <valtype>)*
                | (result <valtype>)
componenttype ::= (component <componentdecl>*)
instancetype  ::= (instance <instancedecl>*)
componentdecl ::= <importdecl>
                | <instancedecl>
instancedecl  ::= core-prefix(<core:type>)
                | <type>
                | <alias>
                | <exportdecl>
                | <value> 🪙
importdecl    ::= (import <importname> bind-id(<externdesc>))
exportdecl    ::= (export <exportname> bind-id(<externdesc>))
externdesc    ::= (<sort> (type <u32>) )
                | core-prefix(<core:moduletype>)
                | <functype>
                | <componenttype>
                | <instancetype>
                | (value <valuebound>) 🪙
                | (type <typebound>)
typebound     ::= (eq <typeidx>)
                | (sub resource)
valuebound    ::= (eq <valueidx>) 🪙
                | <valtype> 🪙

当 X 解析为 '(' sort Y ')'bind-id(X) 解析为 '(' sort <id>? Y ')'

因为这种类型语法中没有类似于gc提案的rectype,所以这些类型都是非递归的。

基本值类型(Fundamental value types)

值类型在valtype可被分为两类:*基本(fundamental)值类型和特殊(specialized)*值类型,其中特殊值类型定义由扩展基本值类型而来。基本值类型包括以下几组抽象值:

Type Values
bool truefalse
s8, s16, s32, s64 [-2N-1, 2N-1-1]范围内的整数
u8, u16, u32, u64 [0, 2N-1]范围内的整数
f32, f64 IEEE754 浮点数,只有一个 NaN 值
char Unicode标量值(Unicode Scalar Values)
record 命名值的异构元组(tuples)
variant 命名值的异构标签联合(tagged unions)
list 相同类型、长度可变的值序列(sequences)
own 唯一、地址不透明的资源,当此值被丢弃时该资源将被销毁
borrow 地址不透明的资源,当前导出调用返回之前该资源必须被丢弃

组件如何通过canonical lifting and lowering definitions配置Core WebAssembly值和线性内存生产和使用这些抽象值,将在下面介绍。例如,尽管抽象变量(variant)包含按名称标记的实例(case)列表,但canonical lifting and lowering会将每个实例映射到一个从0开始的i32值。

数字类型(Numeric types)

虽然核心数字类型根据一组位模式(bit-patterns)和解释位操作的多种方式定义,但组件级数字类型根据值集合定义。其允许在使用不同值表示的源语言和协议之间转换值。

核心整数类型仅是不区分正负符号的位模式,而组件级整数类型则是包含或不包含负值的整数集。核心浮点数类型有多种不同的NaN位模式,而组件级浮点数类型仅有一个NaN值。在核心wasm中,布尔值通常表示为i32,其所有零(all-zeros)运算解释为false,而组件级具有含truefalsebool类型。

容器类型(Container types)

recordvariantlist类型允许对包含的值进行分组、分类和排序。

句柄类型(Handle types)

ownborrow值类型均为句柄类型。句柄在逻辑上包含资源的不透明地址,避免在跨组件边界传递时复制资源。通过操作系统来比喻,句柄类似于文件描述符,它们存储在表中并只能通过其表中的整数索引被不受信任的用户模式进程间接地使用。

在组件模型中,句柄是从封装的每个组件实例(per-component-instance)句柄表中lifted-from和lowered-into的i32值,该表由下面介绍的规范函数定义所维护。未来,句柄可以向后兼容引用类型的lifted和lowered(通过增加新的canonopt如下介绍)。

上述的唯一性和丢弃条件在运行时通过这些规范定义由组件模型强制执行。句柄类型目前的typeidx必须引用resource类型(如下所述),该类型静态地分类句柄可以指向特定种类的资源。

特殊值类型(Specialized value types)

其余特殊值类型允许的值集由以下映射定义: The sets of values allowed for the remaining specialized value types are defined by the following mapping:

                    (tuple <valtype>*) ↦ (record (field "𝒊" <valtype>)*) for 𝒊=0,1,...
                    (flags "<label>"*) ↦ (record (field "<label>" bool)*)
                     (enum "<label>"+) ↦ (variant (case "<label>")+)
                    (option <valtype>) ↦ (variant (case "none") (case "some" <valtype>))
(result <valtype>? (error <valtype>)?) ↦ (variant (case "ok" <valtype>?) (case "error" <valtype>?))
                                string ↦ (list char)

特殊值类型具有与其对应的非专门类型相同的语义值集,但具有不同的类型构造器(这些构造器于非专门的类型构造器不同),因此它们具有不同的二进制编码。这使得特殊值类型可以传达更具体的意图。例如,result不仅是变量variant,它还表示成功或失败的含义,因此源码绑定可以通过源语言的错误报告来显示它。此外,有时能以不同的方式表示值。例如,规范ABI(Canonical ABI)的string使用各种Unicode编码,而list<char>使用4字节的char代码序列。同样,规范ABI中的flags使用位向量(bit-vector),而等效的布尔字段使用布尔值字节序列。

请注意,至少在初期,变量需要有非空项列表。将来可能会放开限制允许空项列表,其中空(variant)实际上作为一个空类型(empty type)且表示不可达。

定义类型(Definition types)

deftype的剩余的4种类型构造器使用valtype描述无共享函数、资源、组件和组件实例:

func类型构造器描述接收并返回valtype列表的组件级函数定义。与core:functype相反,functype的参数和返回值可以关联需校验唯一的名称。为了提升常见的单值函数(single-value-returning functions)的易用性和性能,函数类型可能还有一个单独的未命名的返回类型。对于这种特殊情况,建议绑定生成器直接返回单个值,而不要将其包装在record/object/struct之中

resource类型构造器为包含组件的每个实例创建新(fresh)类型("freshness"及其与一般类型检查的交互在下面详细描述)。资源类型可以被句柄类型(如ownborrow)以及下面描述的规范哪件类型引用。resource类型目前的rep指定其核心表示类型(core representation type),目前固定为i32,但未来会放宽(至少包括i64,但也可能包括其他类型)。当最后一个指向资源的句柄被丢弃时,将调用dtor当前指定的资源的析构函数(如果存在),允许实现组件执行清理,如释放线性内存分配。

instance类型构造器描述了可以由组件导入或导出命名的、类型化的定义列表。通俗地说,实例类型(instance types)对应于“接口(interface)”的常见概念,因此实例类型作为静态接口描述。除了在此定义中的S-Expression文本格式外(旨在放入组件定义中),接口还可以使用接口定义语言(Interface Definition Language)wit定义为独立的、人类友好的文本文件 the wit

component类型构造器于核心module类型构造器对称,包含两个命名定义列表,分别用于组件的导入和导出。如上所述,实例(instance)类型可以在组件(component)类型的导入和导出两者中出现。

instancecomponent类型构造器都是由四种类型—typealiasimportexport—“声明符(declarators)”序列构成,其中只有component类型构造器可以包含import声明符。这些声明符的含义与上面介绍的核心模块声明符基本相同,但扩展至覆盖组件模型的额外功能。

声明符(Declarators)

importdeclexportdecl声明符分别对应组件的importexport定义,允许绑定标识符被后续声明符使用。labelimportnameexportname定义在下面的导入和导出部分给出。 按照core:typeuse的先例,文本格式允许引用界外类型定义(通过(type <typeidx>))和内联类型表达式,问呗格式将其脱糖为界外类型定义。

🪙 externdescvalue项描述了在下面的值定义章节描述的实例化时导入或导出的运行时值。

externdesctype项描述了导入或导出的类型及其"绑定(bound)":

sub绑定声明导入/导出类型为其他子类型抽象类型(abstract type),目前,唯一支持绑定的是resource(按照GC提案的命名约定),其表示“任何资源(resource)类型”。因此,只用资源类型可以被抽象的导入/导出,而不是任意值类型。这允许类型导入总是可以独立于它们的参数使用句柄值的“通用表示”(即 i32,由规范ABI定义)进行编译。未来,sub可能会扩展以允许引用其他资源类型,从而允许抽象资源子类型。

eq绑定说明导入/导出的类型必须与某个先前的类型定义在结构上相等。其允许:

  • 导入的抽象类型被重新导出;
  • 组件为前置的抽象类型引入另一个标签(当实现具有相同资源的多个独立接口时,这可能是必要的);
  • 组件将透明类型别名(transparent type aliases)附加到结构类型(structural types)上,以反映在源级别的绑定中(例如,(export "bytes" (type (eq (list u64))))可以在C++中生成typedef std::vector<uint64_t> bytes或在JS中生成一个名为bytes的导出字段,该字段别名Uint64Array

放宽上述core:alias声明符的限制,alias声明符允许区分typeinstance的别名outerexport。这允许后续类型声明符使用instance类型的导入和导出声明符导出的类型:

(component
  (import "fancy-fs" (instance $fancy-fs
    (export $fs "fs" (instance
      (export "file" (type (sub resource)))
      ;; ...
    ))
    (alias export $fs "file" (type $file))
    (export "fancy-op" (func (param "f" (borrow $file))))
  ))
)

type声明符受到验证限制不允许resource类型定义,从而防止组件类型中出现“私有(private)”资源类型定义并避开预防问题(avoidance problem)。因此,只有通过importdeclexportdecl引入的资源类型才可能出现在instancetypecomponenttype中。

到目前为止的定义,我们可以使用混合类型定义来定义组件类型:

(component $C
  (type $T (list (tuple string bool)))
  (type $U (option $T))
  (type $G (func (param "x" (list $T)) (result $U)))
  (type $D (component
    (alias outer $C $T (type $C_T))
    (type $L (list $C_T))
    (import "f" (func (param "x" $L) (result (list u8))))
    (import "g" (func (type $G)))
    (export "g2" (func (type $G)))
    (export "h" (func (result $U)))
    (import "T" (type $T (sub resource)))
    (import "i" (func (param "x" (list (own $T)))))
    (export "T2" (type $T' (eq $T)))
    (export "U" (type $U' (sub resource)))
    (export "j" (func (param "x" (borrow $T')) (result (own $U'))))
  ))
)

注意$G$U的内联使用是outer别名的语法糖。

类型检查(Type Checking)

类似于核心模块,组件在前期验证阶段会检查组件定义确保基本的一致性。类型检查是验证的核心部分,例如,验证实例化(instantiate)表达式with参数与被实例化组件的import是否类型兼容时会进行类型检查。

为了逐步描述类型检查是如何工作的,我们将从非资源、非句柄、本地类型定义的类型等价性开始并逐步加强。

几乎所有类型(除了下面描述的)的类型等价性都是纯粹的结构性的(structural)。在结构性设定中,类型被认为是抽象语法树,其节点是类型构造器,像u8string被认为是出现在叶子上的"零元"类型构造器,而像listrecord这样的非零元类型构造器出现在父节点上。然后,类型等价性被定义为AST等价性。重要的是,这些类型AST包含任何类型索引或依赖于索引空间布局;这些二进制格式的细节被解码以构成AST。例如,在以下的复合组件中:

(component $A
  (type $ListString1 (list string))
  (type $ListListString1 (list $ListString1))
  (type $ListListString2 (list $ListString1))
  (component $B
    (type $ListString2 (list string))
    (type $ListListString3 (list $ListString2))
    (type $ListString3 (alias outer $A $ListString1))
    (type $ListListString4 (list $ListString3))
    (type $ListListString5 (alias outer $A $ListListString1))
  )
)

所有 5 种变体$ListListStringX都被视为相等,因为解码后,它们都具有相同的AST。

接下来,AST的类型等价关系被放宽为更灵活的子类型关系。当前,仅有instancecomponent类型的子类型被放宽,但将来可能会为更多的类型构造器放宽子类关系,以便更好地支持API演进(API Evolution)(注意理解子类型在各种源语言中如何表现,以便子类型兼容的更新不会无意中破坏源级客户端)。

组件和实例子类型允许子类型导出比超类型(supertype)声明的更多内容并导入更少内容,忽略导入和导出的确切顺序,仅考虑名称。例如下方,$I1$I2的子类型:

(component
  (type $I1 (instance
    (export "foo" (func))
    (export "bar" (func))
    (export "baz" (func))
  ))
  (type $I2 (instance
    (export "bar" (func))
    (export "foo" (func))
  ))
)

并且$C1$C2的子类型:

(component
  (type $C1 (component
    (import "a" (func))
    (export "x" (func))
    (export "y" (func))
  ))
  (type $C2 (component
    (import "a" (func))
    (import "b" (func))
    (export "x" (func))
  ))
)

当我们接下来考虑导入和导出类型时,typebound有两个不同的子情况需要考虑:eqsub

eq绑定增加了类型相等规则(扩展上面提到的内置子类型规则集),表示导入类型在结构上等同与边界中引用的类型。例如,在组件中:

(component
  (type $L1 (list u8))
  (import "L2" (type $L2 (eq $L1)))
  (import "L3" (type $L2 (eq $L1)))
  (import "L4" (type $L2 (eq $L3)))
)

所有4种$L*类型都相等(从子类型的角度看,它们均是彼此的子类型)。

相反,sub绑定引入一种新的抽象类型,组件的其余部分必须谨慎的假设该抽象类型可以是绑定的子类型的任何类型。这对于类型检查而言,每个子类型绑定的类型导入/导出都会引入一种的类型抽象,该抽象类型与每个先前的类型定义都不相等。 目前(且可能在MVP中),仅有resource支持类型绑定(这意味着“任何资源类型”),因此唯一的抽象类型是抽象resource类型。例如,在下面的组件中:

(component
  (import "T1" (type $T1 (sub resource)))
  (import "T2" (type $T2 (sub resource)))
)

$T1$T2类型不相等。

一旦导入了类型,它就可以被后续的等价绑定类型导入引用,从而添加更多它等价的类型。例如,下方组件:

(component $C
  (import "T1" (type $T1 (sub resource)))
  (import "T2" (type $T2 (sub resource)))
  (import "T3" (type $T3 (eq $T2)))
  (type $ListT1 (list (own $T1)))
  (type $ListT2 (list (own $T2)))
  (type $ListT3 (list (own $T3)))
)

$T2$T3类型彼此相等但不等于$T1。根据上述传递结构相等规则,$List2$List3彼此相等但不等于$List1

句柄类型(ownborrow)是结构化类型(类似于list),但它们引用资源类型,因此可以传递性地“继承(inherit)”抽象资源类型的新鲜度(freshness)。例如,下方组件:

(component
  (import "T" (type $T (sub resource)))
  (import "U" (type $U (sub resource)))
  (type $Own1 (own $T))
  (type $Own2 (own $T))
  (type $Own3 (own $U))
  (type $ListOwn1 (list $Own1))
  (type $ListOwn2 (list $Own2))
  (type $ListOwn3 (list $Own3))
  (type $Borrow1 (borrow $T))
  (type $Borrow2 (borrow $T))
  (type $Borrow3 (borrow $U))
  (type $ListBorrow1 (list $Borrow1))
  (type $ListBorrow2 (list $Borrow2))
  (type $ListBorrow3 (list $Borrow3))
)

$Own1$Own2类型彼此相等但不等于$Own3或任何$Borrow*。相同的,$Borrow1$Borrow2彼此相等但不等于$Borrow3。传递性地,$ListOwn1$ListOwn2类型彼此相等但不等于$ListOwn3或任何$ListBorrow*。类型导入的类型检查规则反映了通用类型(universal types)(∀T)引入规则。

上述示例均展示了导入方面的抽象类型,但当为另一个组件设置导出别名时,同样的“新鲜度(freshness)”条件也适用。例如,下方组件:

(component
  (import "C" (component $C
    (export "T1" (type (sub resource)))
    (export "T2" (type $T2 (sub resource)))
    (export "T3" (type (eq $T2)))
  ))
  (instance $c (instantiate $C))
  (alias export $c "T1" (type $T1))
  (alias export $c "T2" (type $T2))
  (alias export $c "T3" (type $T3))
)

$T2$T3类型彼此相等单不等于$T1。这些针对类型导出别名的类型检查规则反映了存在类型(existential types)(∃T)消除规则。

接下来,我们讨论抽象类型的第三个来源是资源类型定义。不同于导入和导出引入的抽象类型,资源类型定义为设置和获取资源的私有表示值(将在下面介绍)提供了规范的内置功能。这些内建功能必然被限制在生成资源类型的组件实例中,从而隐藏了对资源类型表示的外部访问。因为每个组件实例都生成了与同一组件的所有先前实例不同的新资源类型,所以资源类型是["生成性的(generative)"]["Generative"]。

例如,在下方示例组件中:

(component
  (type $R1 (resource (rep i32)))
  (type $R2 (resource (rep i32)))
  (func $f1 (result (own $R1)) (canon lift ...))
  (func $f2 (param (own $R2)) (canon lift ...))
)

$R1$R2类型不相等,因此返回类型$f1与参数类型$f2不兼容。

资源类型定义的生成性与上面提到的类型导出的抽象类型规则相匹配,该规则强制组件的所有客户端绑定一个新的抽象类型。例如,在下方组件中:

(component
  (component $C
    (type $r1 (export "r1") (resource (rep i32)))
    (type $r2 (export "r2") (resource (rep i32)))
  )
  (instance $c1 (instantiate $C))
  (instance $c2 (instantiate $C))
  (type $c1r1 (alias export $c1 "r1"))
  (type $c1r2 (alias export $c1 "r2"))
  (type $c2r1 (alias export $c2 "r1"))
  (type $c2r2 (alias export $c2 "r2"))
)

外部组件中的所有4种类型别名都不相等,反映出每个$C的实例生成两种新的资源类型的事实。

如果一个资源类型定义被导出多次,那么第一次之后的导出将等价绑定到第一次导出。例如,以下组件:

(component
  (type $r (resource (rep i32)))
  (export "r1" (type $r))
  (export "r2" (type $r))
)

被分配了下面的componenttype

(component
  (export "r1" (type $r1 (sub resource)))
  (export "r2" (type (eq $r1)))
)

因此,从外部角度来看,r1r2是同一类型的两个标签。

如果一个组件想要避免这个实现并强制客户端假设r1r2为不同类型(从而允许实现在未来实际使用不同的类型而不破坏客户端),可以为导出指定明确的次严格的sub绑定替换eq绑定类型(使用下方介绍的语法)。

(component
  (type $r (resource (rep i32)))
  (export "r1" (type $r))
  (export "r2" (type $r) (type (sub resource)))
)

该组件分配如下componenttype

(component
  (export "r1" (type (sub resource)))
  (export "r2" (type (sub resource)))
)

该类型对上述组件的分配反映了存在类型(existential types)(∃T)引入规则。

当通过instantiate为导入类型提供资源类型(导入定义)时,类型检查会指向替换,将实例化组件中的所有使用import通过with替换为实际类型。例如,下方组件验证:

(component $P
  (import "C1" (component $C1
    (import "T" (type $T (sub resource)))
    (export "foo" (func (param (own $T))))
  ))
  (import "C2" (component $C2
    (import "T" (type $T (sub resource)))
    (import "foo" (func (param (own $T))))
  ))
  (type $R (resource (rep i32)))
  (instance $c1 (instantiate $C1 (with "T" (type $R))))
  (alias export $c1 "foo" (func $foo))
  (instance $c2 (instantiate $C2 (with "T" (type $R)) (with "foo" (func $foo))))
)

这主要取决于在验证$c1$c2的实例化时,$C1$C2是否已被替换。这些用于实例化类型导入的类型检查规则反映了通用类型(∀T)消除规则。

重要的是,这种由父级进行的类型替换在验证或运行时对子级不可见。特别是,没有运行时强制转换可以“看透”原始类型参数,从而避免了常见的动态强制转换类型暴露问题(type-exposure problems with dynamic casts)

总结:所有类型构造器都是*结构化(structural)*的,除了resource,他是抽象(abstract)生成性(generative)的。具有子类型绑定的类型导入和导出也引入了抽象类型,并遵循通用和存在类型的标准引入和消除规则。

最后,由于“名义上的(nominal)”通常被认为是“结构性的对立面(the opposite of structural)”,因此一个有效的问题是上述任何一种类型是否是“名义类型(nominal typing)”。在组件内部,资源类型表现为“名义上地”:每个资源类型定义为资源类型提供了不同于所有先前定义的资源类型的新的本地“名称”。有趣的情况是当从组件外部考虑资源类型等价性,,特别是当单个组件被多次实例化时。在这种情况下,使用单一exportname导出的单一资源类型定义将在每个组件实例都会得到一个新的类型,上面提到的抽象类型规则确保组件实例的每个资源类型保持不同。因此,从某种意义上说,资源类型的生成性概括了传统的基于名称的名义类型,提供了比共享全局命名空间可以实现的更细粒度的隔离。

规范定义(Canonical Definitions)

从运行在组件内部的Core WebAssembly角度来看,组件模型是一个嵌入器。因此,组件模型定义了传递给module_instantiate的Core WebAssembly导入和通过func_invoke调用的Core WebAssembly导出。这允许组件模型指定核心模块如何链接在一起(如上所示),但它还允许组件模型任意合成由Core WebAssembly导入的Core WebAssembly函数(通过func_alloc)。这些合成的核心函数是通过下面定义的几个*规范定义(canonical definitions)*之一创建的。

规范 ABI(Canonical ABI)

要实现或调用一个组件级函数,我们需要跨越一个共享无关的边界。传统上,这个问题是通过定义一个序列化格式来解决的。组件模型MVP大致上使用了这种方法,定义了一个基于线性内存的ABI,成为“规范ABI(Canonical ABI)”,它为任何functype指定了一个相应的(corresponding)core:functype,以及将值从线性内存中复制进/出的规则。然而,组件模型与传统方法不同之处在于,BAI是可配置的,允许同一个抽象值有多种不同的内存表示。在MVP中,这种可配置型仅限于下面展示的小型canonopt集。然而,MVP后续,可以添加适配器函数以允许更多的程序控制。

规范ABI明确地应用于以两个方向之一“包装”现有的函数:

  • lift包装一个核心函数(类型为core:functype),生成一个组件函数(类型为functype),可以传递给其他组件
  • lower包装一个组件函数(类型为functype),生成一个核心函数(类型为core:functype),可以从当前组件内的Core WebAssembly代码导入和调用

规范定义指定这两个包装方向之一、要包装的函数和配置选项列表:

canon    ::= (canon lift core-prefix(<core:funcidx>) <canonopt>* bind-id(<externdesc>))
           | (canon lower <funcidx> <canonopt>* (core func <id>?))
canonopt ::= string-encoding=utf8
           | string-encoding=utf16
           | string-encoding=latin1+utf16
           | (memory <core:memidx>)
           | (realloc <core:funcidx>)
           | (post-return <core:funcidx>)

虽然externdesc接受任何sort,但canon lift的验证规则仅允许func类别。未来,可能会增加其他类别(即,类型),因此需要明确的类别。

string-encoding选项指定了Canonical ABI将如何对字符串类型进行编码。latin1+utf16编码能适应Java,JavaScript和.NET VMs的常见字符串编码方式,并允许在Latin-1(固定1字节编码,但码位有限)或UTF-16(可以表达所有码位,但每个码位占用2或4字节)之间动态选择。如果没有指定string-encoding,默认为UTF-8。同时指定多个字符串编码选项会校验错误。

(memory ...)选项指定了Canonical ABI将用于加载和存储值的内存。如果Canonical ABI需要加载或存储,校验需要此选项存在(无默认值)。

(realloc ...)选项指定了一个核心函数,该函数被校验需为下面的核心函数类型:

(func (param $originalPtr i32)
      (param $originalSize i32)
      (param $alignment i32)
      (param $newSize i32)
      (result i32))

Canonical ABI将使用realloc进行内存分配(allocate,第一、二个参数为0)以及内存重新分配(reallocate)。如果Canonical ABI需要realloc,那么校验需要此选项存在(无默认值)。

(post-return ...)选项只能在canon lift中出现并指定一个核心函数,该函数将在读取完原始返回值后使用原始返回值进行调用,从而允许释放内存并调用析构函数。这个立即数是可选的,但是如果存在,则验证其参数是否与被调用者的返回类型匹配且结果为空。

基于AST的描述,规范ABI解释器(Canonical ABI explainer)给出了liftlower的静态和动态语义的详细解析。

规范ABI解释器中给出的canon lift的动态语义的一个高层级结果是,组件函数语核心函数不同,所有的控制流转移都在其类型中明确反映。 例如,使用Core WebAssembly的异常处理(exception-handling)堆栈切换(stack-switching),类型为(func (result i32))的核心函数可以返回i32,抛出、暂停或捕获异常。相反,类型为(func (result string))的组件函数仅可能返回一个string或捕获异常。为了表达失败,组件函数可以返回result,具有异常处理的语言可以将异常绑定到error情况。类似的,即将添加的future和stream类型将在组件函数签名中明确声明堆栈切换的模式。

与上面显示的importalias类似,canon定义也能以倒置形式编写,将类别放在第一位:

(func $f (import "i" "f") ...type...) ≡ (import "i" "f" (func $f ...type...))       (WebAssembly 1.0)
(func $g ...type... (canon lift ...)) ≡ (canon lift ... (func $g ...type...))
(core func $h (canon lower ...))      ≡ (canon lower ... (core func $h))

注意:未来,canon可能会被推广到定义函数以外的其他类别(例如类型),因此需显示的sort

使用规范的函数定义,我们最终可以熟悉一个不平凡的组件,它接收一个字符串,进行一些记录,然后返回一个字符串。

(component
  (import "logging" (instance $logging
    (export "log" (func (param string)))
  ))
  (import "libc" (core module $Libc
    (export "mem" (memory 1))
    (export "realloc" (func (param i32 i32) (result i32)))
  ))
  (core instance $libc (instantiate $Libc))
  (core func $log (canon lower
    (func $logging "log")
    (memory (core memory $libc "mem")) (realloc (func $libc "realloc"))
  ))
  (core module $Main
    (import "libc" "memory" (memory 1))
    (import "libc" "realloc" (func (param i32 i32) (result i32)))
    (import "logging" "log" (func $log (param i32 i32)))
    (func (export "run") (param i32 i32) (result i32)
      ... (call $log) ...
    )
  )
  (core instance $main (instantiate $Main
    (with "libc" (instance $libc))
    (with "logging" (instance (export "log" (func $log))))
  ))
  (func $run (param string) (result string) (canon lift
    (core func $main "run")
    (memory (core memory $libc "mem")) (realloc (func $libc "realloc"))
  ))
  (export "run" (func $run))
)

此示例展示了从特定组件的不可复用模块($Main)中分离出可复用的语言运行时模块($Libc)。 除了减少代码大小和增加多组件场景中的代码共享之外,这种分离方式还允许$libc先被创建,这样它的导出就可以被canon lower引用。如果没有这种分离(也就是说$Main包含memory和分配函数),那么canon lower$Main之间就会存在循环以来关系,必须使用辅助模块执行call_indirect打破这种依赖循环。

规范内置(Canonical Built-ins)

除了适配现有函数的liftlower的规范函数定义之外,还有一组规范“内置(built-ins)”,它们从无到有定义可以被核心模块导入的核心函数,从而与资源等规范ABI实体动态交互(以及当提案中添加了异步(async)、任务(task)时)。 canon ::= ... | (canon resource.new (core func ?)) | (canon resource.drop (core func ?)) | (canon resource.rep (core func ?)) | (canon thread.spawn (core func ?)) 🧵 | (canon thread.hw_concurrency (core func ?)) 🧵


##### 资源(Resources)

内置`resource.new`具有`[i32] -> [i32]`类型并创建一个新的资源(具有资源类型`typeidx`),其表示为给定的`i32`值并返回指向此资源的新句柄的`i32`索引。

内置`resource.drop`具有`[i32] -> []`类型并删除给定`i32`索引的资源句柄(具有资源类型`typeidx`)。如果删除的句柄拥有资源,那么资源如果存在`dtor`则会被调用。

内置`resource.rep`具有`[i32] -> [i32]`类型并返回由给定`i32`索引处的句柄指向的资源(具有资源类型`typeidx`)的`i32`表示。

举个例子,以下组件导入了内置`resource.new`,使其能够创建并返回新资源给其客户端:
```wasm
(component
  (import "Libc" (core module $Libc ...))
  (core instance $libc (instantiate $Libc))
  (type $R (resource (rep i32) (dtor (func $libc "free"))))
  (core func $R_new (param i32) (result i32)
    (canon resource.new $R)
  )
  (core module $Main
    (import "canon" "R_new" (func $R_new (param i32) (result i32)))
    (func (export "make_R") (param ...) (result i32)
      (return (call $R_new ...))
    )
  )
  (core instance $main (instantiate $Main
    (with "canon" (instance (export "R_new" (func $R_new))))
  ))
  (export $R' "r" (type $R))
  (func (export "make-r") (param ...) (result (own $R'))
    (canon lift (core func $main "make_R"))
  )
)

这里,由resource.new返回的i32,是组件句柄表的索引,被make_R立即返回,从而将新创建资源的所有权转移给导出的调用者。

🧵 线程(Threads)

提案共享所有线程(shared-everything-threads)为线程管理增加了组件模型内置。这些被指定为内置而非核心WebAssembly指令的原因是浏览器希望这些功能由现有的Web/JS API提供。

内置thread.spawn具有[f:(ref null $f) c:i32] -> [i32]类型,它通过调用共享函数f并向其传递c来生成新线程,返回值表示是否成功创建了线程。

内置resource.hw_concurrency具有[] -> [i32]类型,它返回可以并发执行的线程数量。

请参阅CanonicalABI.md获取内置(built-ins)及其交互的详细定义。

🪙 值定义(Value Definitions)

值定义(在值索引空间中)类似于Core WebAssembly中的不可变global定义,只是验证要求它们在实例化时(instantiation-time)只被使用一次(即,它们是线性的(linear))。

组件可以使用以下语法在值索引空间中定义值:

value    ::= (value <id>? <valtype> <val>)
val      ::= false | true
           | <core:i64>
           | <f64canon>
           | nan
           | '<core:stringchar>'
           | <core:name>
           | (record <val>+)
           | (variant "<label>" <val>?)
           | (list <val>*)
           | (tuple <val>+)
           | (flags "<label>"*)
           | (enum "<label>")
           | none | (some <val>)
           | ok | (ok <val>) | error | (error <val>)
           | (binary <core:datastring>)
f64canon ::= <core:f64> without the `nan:0x` case.

value的校验规则要求valvaltype匹配。

(binary ...)表达式提供了一种替代语法,允许将值定义的二进制内容直接以文本格式写入,类似于数据段(data segments),避免在编码或解码时需要理解类型信息。

例如:

(component
  (value $a bool true)
  (value $b u8  1)
  (value $c u16 2)
  (value $d u32 3)
  (value $e u64 4)
  (value $f s8  5)
  (value $g s16 6)
  (value $h s32 7)
  (value $i s64 8)
  (value $j f32 9.1)
  (value $k f64 9.2)
  (value $l char 'a')
  (value $m string "hello")
  (value $n (record (field "a" bool) (field "b" u8)) (record true 1))
  (value $o (variant (case "a" bool) (case "b" u8)) (variant "b" 1))
  (value $p (list (result (option u8)))
    (list
      error
      (ok (some 1))
      (ok none)
      error
      (ok (some 2))
    )
  )
  (value $q (tuple u8 u16 u32) (tuple 1 2 3))

  (type $abc (flags "a" "b" "c"))
  (value $r $abc (flags "a" "c"))

  (value $s (enum "a" "b" "c") (enum "b"))

  (value $t bool (binary "\00"))
  (value $u string (binary "\07example"))

  (type $complex
    (tuple
      (record
        (field "a" (option string))
        (field "b" (tuple (option u8) string))
      )
      (list char)
      $abc
      string
    )
  )
  (value $complex1 (type $complex)
    (tuple
      (record
        none
        (tuple none "empty")
      )
      (list)
      (flags)
      ""
    )
  )
  (value $complex2 (type $complex)
    (tuple
      (record
        (some "example")
        (tuple (some 42) "hello")
      )
      (list 'a' 'b' 'c')
      (flags "b" "a")
      "hi"
    )
  )
)

与所有定义类别一样,值可以由组件导入和导出。以下是值导入的示例:

(import "env" (value $env (record (field "locale" (option string)))))

正如该示例所示,值导入可以作为通用环境变量,不仅允许string,还允许valtype的全部范围。

值也可以导出。例如:

(component
  (import "system-port" (value $port u16))
  (value $url string "https://example.com")
  (export "default-url" (value $url))
  (export "default-port" (value $port))
)

该组件的推断类型是:

(component
  (import "system-port" (value $port u16))
  (value $url string "https://example.com")
  (export "default-url" (value (eq $url)))
  (export "default-port" (value (eq $port)))
)

因此,默认情况下,导出的精确常量和导入会传递至组件类型从而成为公共接口。这样,值导出可以作为组件提供给主机或其他客户端工具的语义配置数据。组件可以使用后续导入和导出提到的“类型归属(type ascription)”功能将导出的精确值保持为抽象(以便精确值不帅说类型和公共接口)。

🪙 启动定义(Start Definitions)

与模块一样,组件可以有在实例化期间调用的启动函数。与模块不同,组件可以在实例化期间的多个点调用启动函数,每个此类调用都有参数和结果。因此,组件中的start定义类似于函数调用:

start ::= (start <funcidx> (value <valueidx>)* (result (value <id>?))*)

(value <valueidx>)*列表通过索引到*值索引空间(value index space)*来指定传递给funcidx的参数。两个值列表的参数数量和类型都经过校验匹配funcidx的签名。

通过这个,我们可以定义一个组件,在实例化时导入一个字符串并计算一个新的导出的字符串:

(component
  (import "name" (value $name string))
  (import "libc" (core module $Libc
    (export "memory" (memory 1))
    (export "realloc" (func (param i32 i32 i32 i32) (result i32)))
  ))
  (core instance $libc (instantiate $Libc))
  (core module $Main
    (import "libc" ...)
    (func (export "start") (param i32 i32) (result i32)
      ... general-purpose compute
    )
  )
  (core instance $main (instantiate $Main (with "libc" (instance $libc))))
  (func $start (param string) (result string) (canon lift
    (core func $main "start")
    (memory (core memory $libc "mem")) (realloc (func $libc "realloc"))
  ))
  (start $start (value $name) (result (value $greeting)))
  (export "greeting" (value $greeting))
)

如此例所示,启动函数重用了与正常导入和导出相同的规范ABI机制,将组件级值引入和导出核心线性内存。

导入和导出定义(Import and Export Definitions)

导入和导出定义都会将新元素附加到导入/导出sort的索引空间,该元素在文本格式中可以选择绑定一个标识符。对于导入,标识符的绑定类似于Core WebAssembly,作为externdesc的一部分(例如,(import "x" (func $x))绑定标识符$x)。 对于导出,紧接着export后面的<id>?会被绑定,而<sortidx>中的<id>则是对正在被导出的先前定义的引用(例如,(export $x "x" (func $f))绑定新的标识符$x)。

import ::= (import "<importname>" bind-id(<externdesc>))
export ::= (export <id>? "<exportname>" <sortidx> <externdesc>?)

所有的导入名称都必须是唯一的,所有的导出名称也必须是唯一的。导入和导出的其余语法定义了导入和导出名称内容的结构化语法。在语法上,这些名称出现在带引号的字符串字面量中。因此,语法限制了这些字符串字面量的内容,以提供更多的结构化信息,这些信息可以被工具链和运行时进行机翻,以支持习惯用的开发者工作流程和源语言绑定。下面定义此结构化名称语法的规则将被解释为定义单个标记的词法语法,因此不会自动插入空格,所有终端都用单引号引起来,并且所有未加引号的内容都是元字符。

exportname    ::= <plainname>
                | <interfacename>
importname    ::= <exportname>
                | <depname>
                | <urlname>
                | <hashname>
plainname     ::= <label>
                | '[constructor]' <label>
                | '[method]' <label> '.' <label>
                | '[static]' <label> '.' <label>
label         ::= <fragment>
                | <label> '-' <fragment>
fragment      ::= <word>
                | <acronym>
word          ::= [a-z] [0-9a-z]*
acronym       ::= [A-Z] [0-9A-Z]*
interfacename ::= <namespace> <label> <projection> <version>?
                | <namespace>+ <label> <projection>+ <version>? 🪺
namespace     ::= <words> ':'
words         ::= <word>
                | <words> '-' <word>
projection    ::= '/' <label>
version       ::= '@' <valid semver>
depname       ::= 'unlocked-dep=<' <pkgnamequery> '>'
                | 'locked-dep=<' <pkgname> '>' ( ',' <hashname> )?
pkgnamequery  ::= <pkgpath> <verrange>?
pkgname       ::= <pkgpath> <version>?
pkgpath       ::= <namespace> <words>
                | <namespace>+ <words> <projection>* 🪺
verrange      ::= '@*'
                | '@{' <verlower> '}'
                | '@{' <verupper> '}'
                | '@{' <verlower> ' ' <verupper> '}'
verlower      ::= '>=' <valid semver>
verupper      ::= '<' <valid semver>
urlname       ::= 'url=<' <nonbrackets> '>' (',' <hashname>)?
nonbrackets   ::= [^<>]*
hashname      ::= 'integrity=<' <integrity-metadata> '>'

组件提供了六种命名导入选项:

  • 普通名称,让开发人员“阅读文档”或以其他方式弄清楚要提供什么来导入;
  • 接口名称,假设它唯一地标识了组件正在请求一个非特定的wasm或本地实现的更高级的语义契约;
  • URL名称,组件请求通过获取(fetching)URL来解析特定的wasm实现;
  • 哈希名称(hash name),包含特定的wasm实现的字节的内容哈希(content-hash),但不指定字节的位置;
  • 锁定依赖项名称(locked dependency name),组件请求通过一些上下文提供的注册表(registry)解析到特定的wasm实现,使用给定的分层名称和版本;
  • 未锁定依赖项名称(unlocked dependency name),组件请求通过一些上下文提供的注册表(registry)解析到一组可能的wasm实现之一,使用给定的分层名称和版本范围。

并非所有主机都应支持所有六个导入命名选项,并且通常,构建工具可能需要使用外部组件包装要部署的组件,该外部组件仅使用目标主机可以理解的导入名称。例如:

  • 离线主机可能只实现一组固定的接口名称,需要构建工具来捆绑URL、依赖项和哈希名称(用嵌套定义替换导入);
  • 浏览器可能仅支持纯文本和URL名称(通过导入映射或JS API解析纯文本名称),需要构建过程发布或捆绑依赖项,将依赖项名称转换为嵌套定义或URL名称;
  • 生产服务器环境可能只允许部署从一组固定的接口和锁定的依赖项名称导入的组件,从而要求事先锁定和部署所有依赖项;
  • 没有直接开发人员界面(例如 JS API 或导入映射)的主机嵌入可能会拒绝所有普通名称,需要构建过程事先解决这些问题;
  • 没有内容可寻址存储的主机可能会拒绝哈希名称(因为它们无法找到内容)。

URL名称的语法和验证允许嵌入的URL包函任何UTF-8字符序列(除了用于分割URL的尖括号外),在获取URL的准备阶段,将URL的结构良好性作为解析URL过程的一部分进行检查。传递给URL规范解析算法的基础URL操作数由主机确定,并且可能因不存在导致不允许相对URL。因此,URL导入的解析和获取是主机定义的操作,发生在组件解码和校验之后,但在组件实例化之前。

当通过URL或依赖项名称表示特定实现时,importname允许组件额外指定wasm实现的预期二进制表示的加密哈希,复用W3C子资源完整性规范(W3C Subresource Integrity specification)定义的完整性元数据(integrity-metadata)项目。当存在哈希时,组件可以表达复用另一个组件或核心模块的意图,其特异性成都与直接嵌套组件或核心模块相同,从而允许组件在不影响运行时行为的情况下分解出公共依赖项。当存在哈希(在hashname中)时,主机必须使用哈希(例如,使用OCI注册表)定位内容。

依赖项名称所指向的“注册表(registry)”用于将分层名称和版本映射到特定的模块、组件或导出定义。例如,在嵌套命名空间和包(🪺)的完整通用性中,在注册表名称a:b:c/d/e/f中,a:b:c通过命名空间ab的路径遍历到组件c/d/e/f遍历c的导出(其中de必须是组件导出,但f可以是任意导出)。基于这个抽象定义,开发者工具可以将一些具体的数据源解析为“注册表”。

合法语义版本(valid semver)项由Semantic Versioning 2.0规范定义并根据该规范进行解释。verrange项嵌入了常见包管理器(如npmcargo)中版本范围语法的最小子集,并应按相同的语义进行解释。(大多数情况下解释按通常的SemVer规范定义的顺序,但请注意预发布标签的特定行为。)

plainname项记录了几种语言无关的语法提示,允许绑定生成器在目标语言中生成更符合习惯的绑定。在顶层,plainname允许使用前置资源将函数注解为构造器(constructor)、方法(method)或静态(static)函数,在这些情况下,第一个label是资源名称,第二个label是函数的逻辑字段名。这些额外的嵌套信息允许绑定生成器将函数插入到类(class)、抽象数据类型(abstract data type)、对象(object)、命名空间(namespace)、包(package)、模块(module)等任何被绑定的资源的嵌套范围中。例如,名为[method]C.foo的函数可以在C++中绑定到类C的成员函数foo下面的JS API描述了原生JavaScript绑定会是什么样的。Binary.md中描述的校验检查plainname的内容并确保函数具有兼容的签名。

plainname中使用的label项以及recordvariant类型的label都需要使用烤串命名法(kebab case)。这种特定形式的大小写用于明确分隔单词和首字母缩写词(表示为全大写的单词),这样源语言绑定就可以将label转换为该语言的惯用的大小写。(实际上,由于连字符通常为无效标识符,烤串命名法实际上强制语言绑定进行此类转换。)例如,标签(label)``is-XML可以映射为isXMLIsXmlis_XMLis_xml,取决于目标语言/约定。高度限制的字符集确保大写是不重要的且无需查阅Unicode表。

由于一些大小写方案(如全部小写)会导致两个仅有大小写差异的label产生冲突,因此在所有需要名称之间“唯一性”的情况下(即,import/export的name、record field的label、variant case的label、以及function parameter/result的name),两个仅有大小写差异的label会因被视为相等而拒绝。

组件提供了两种命名导出的选项,与前两种命名导入的选项对称:

  • 普通名称,让开发人员“阅读文档”或以其他方式弄清楚导出的作用以及如何使用它;
  • 接口名称,该名称被认为唯一地标识了组件声称使用给定的导出定义来实现的更高级别的语义契约。

例如,下方组件使用了所有9种导入和导出案例:

(component
  (import "custom-hook" (func (param string) (result string)))
  (import "wasi:http/handler" (instance
    (export "request" (type $request (sub resource)))
    (export "response" (type $response (sub resource)))
    (export "handle" (func (param (own $request)) (result (own $response))))
  ))
  (import "url=<https://mycdn.com/my-component.wasm>" (component ...))
  (import "url=<./other-component.wasm>,integrity=<sha256-X9ArH3k...>" (component ...))
  (import "locked-dep=<my-registry:[email protected]>,integrity=<sha256-H8BRh8j...>" (component ...))
  (import "unlocked-dep=<my-registry:imagemagick@{>=1.0.0}>" (instance ...))
  (import "integrity=<sha256-Y3BsI4l...>" (component ...))
  ... impl
  (export "wasi:http/handler" (instance $http_handler_impl))
  (export "get-JSON" (func $get_json_impl))
)

此处,custom-hookget-JSON函数的普通名称,其语义约定仅在此组件指定且未在其他地方定义。相反,wasi:http/handler是单独定义的借口名称,允许组件使用具有进行外发HTTP请求(通过导入)和接收传入HTTP请求的能力,这种方式可以由主机和工具进行机翻。

剩下的4个导入展示了组件可以导入外部实现的不同方式。这里,URL和锁定依赖项导入使用component类型,允许组件使用instance定义私有创建并链接实例。相反的,未锁定依赖项导入使用使用instance类型,预期后续工具步骤(可能是执行依赖解析的步骤)来选择、实例化及提供实例。

export校验要求在导出函数或值类型中资源类型的所有传递使用,全部引用导入或导出的资源(具体来说,通过importexport引入的类型索引)。export中的可选项<externdesc>?能显示地赋予导出一个已校验为定义类型的父类型,从而允许私有(未导出)类型定义被替换为公共(已导出)类型定义。

例如,在以下组件中:

(component
  (import "R1" (type $R1 (sub resource)))
  (type $R2 (resource (rep i32)))
  (export $R2' "R2" (type $R2))
  (func $f1 (result (own $R1)) (canon lift ...))
  (func $f2 (result (own $R2)) (canon lift ...))
  (func $f2' (result (own $R2')) (canon lift ...))
  (export "f1" (func $f1))
  ;; (export "f2" (func $f2)) -- invalid
  (export "f2" (func $f2) (func (result (own $R2'))))
  (export "f2" (func $f2'))
)

注释的export是无效的,因为其类型传递引用了$R2,这是一个私有类型。此要求为了解决在具有抽象类型模块系统中出现的标准规避问题(avoidance problem)。特别是,她确保组件的客户端能够在外部定义于组件导出兼容的类型。

与类型导出类似,值导出也可以分配一个类型以防止精确值成为类型和公共接口的一部分。

例如:

(component
  (value $url string "https://example.com")
  (export "default-url" (value $url) (value string))
)

该组件的推断类型是:

(component
  (export "default-url" (value string))
)

Note, that the url value definition is absent from the component type 请注意,组件类型中没有url值定义

组件不变性(Component invariants)

基于上述无共享设计的结果,所有进或出组件实例的调用必须通过组件函数定义实现。因此,组件函数在组件实例包含的核心模块实例周围形成一个“膜(membrane)”,允许组件模型建立不变量,以在Core WebAssembly的全共享设置中无法实现的方式提高可优化性和组合性。组件模型体验建立以下三个运行时不变式:

  1. Components define a "lockdown" state that prevents continued execution after a trap. This both prevents continued execution with corrupt state and also allows more-aggressive compiler optimizations (e.g., store reordering). This was considered early in Core WebAssembly standardization but rejected due to the lack of clear trapping boundary. With components, each component instance is given a mutable "lockdown" state that is set upon trap and implicitly checked at every execution step by component functions. Thus, after a trap, it's no longer possible to observe the internal state of a component instance.
  2. Components prevent unexpected reentrance by setting the "lockdown" state (in the previous bullet) whenever calling out through an import, clearing the lockdown state on return, thereby preventing reentrant export calls in the interim. This establishes a clear contract between separate components that both prevents obscure composition-time bugs and also enables more-efficient non-reentrant runtime glue code (particularly in the middle of the Canonical ABI). This implies that components by default don't allow concurrency and multi-threaded access will trap.

JavaScript Embedding

JS API

The JS API currently provides WebAssembly.compile(Streaming) which take raw bytes from an ArrayBuffer or Response object and produces WebAssembly.Module objects that represent decoded and validated modules. To natively support the Component Model, the JS API would be extended to allow these same JS API functions to accept component binaries and produce new WebAssembly.Component objects that represent decoded and validated components. The binary format of components is designed to allow modules and components to be distinguished by the first 8 bytes of the binary (splitting the 32-bit core:version field into a 16-bit version field and a 16-bit layer field with 0 for modules and 1 for components).

Once compiled, a WebAssembly.Component could be instantiated using the existing JS API WebAssembly.instantiate(Streaming). Since components have the same basic import/export structure as modules, this means extending the read the imports logic to support single-level imports as well as imports of modules, components and instances. Since the results of instantiating a component is a record of JavaScript values, just like an instantiated module, WebAssembly.instantiate would always produce a WebAssembly.Instance object for both module and component arguments.

Types are a new sort of definition that are not (yet) present in Core WebAssembly and so the read the imports and create an exports object steps need to be expanded to cover them:

For type exports, each type definition would export a JS constructor function. This function would be callable iff a [constructor]-annotated function was also exported. All [method]- and [static]-annotated functions would be dynamically installed on the constructor's prototype chain. In the case of re-exports and multiple exports of the same definition, the same constructor function object would be exported (following the same rules as WebAssembly Exported Functions today). In pathological cases (which, importantly, don't concern the global namespace, but involve the same actual type definition being imported and re-exported by multiple components), there can be collisions when installing constructors, methods and statics on the same constructor function object. In such cases, a conservative option is to undo the initial installation and require all clients to instead use the full explicit names as normal instance exports.

For type imports, the constructors created by type exports would naturally be importable. Additionally, certain JS- and Web-defined objects that correspond to types (e.g., the RegExp and ArrayBuffer constructors or any Web IDL interface object) could be imported. The ToWebAssemblyValue checks on handle values mentioned below can then be defined to perform the associated internal slot type test, thereby providing static type guarantees for outgoing handles that can avoid runtime dynamic type tests.

Lastly, when given a component binary, the compile-then-instantiate overloads of WebAssembly.instantiate(Streaming) would inherit the compound behavior of the abovementioned functions (again, using the layer field to eagerly distinguish between modules and components).

For example, the following component:

;; a.wasm
(component
  (import "one" (func))
  (import "two" (value string)) 🪙
  (import "three" (instance
    (export "four" (instance
      (export "five" (core module
        (import "six" "a" (func))
        (import "six" "b" (func))
      ))
    ))
  ))
  ...
)

and module:

;; b.wasm
(module
  (import "six" "a" (func))
  (import "six" "b" (func))
  ...
)

could be successfully instantiated via:

WebAssembly.instantiateStreaming(fetch('./a.wasm'), {
  one: () => (),
  two: "hi", 🪙
  three: {
    four: {
      five: await WebAssembly.compileStreaming(fetch('./b.wasm'))
    }
  }
});

The other significant addition to the JS API would be the expansion of the set of WebAssembly types coerced to and from JavaScript values (by ToJSValue and ToWebAssemblyValue) to include all of valtype. At a high level, the additional coercions would be:

Type ToJSValue ToWebAssemblyValue
bool true or false ToBoolean
s8, s16, s32 as a Number value ToInt8, ToInt16, ToInt32
u8, u16, u32 as a Number value ToUint8, ToUint16, ToUint32
s64 as a BigInt value ToBigInt64
u64 as a BigInt value ToBigUint64
f32, f64 as a Number value ToNumber
char same as USVString same as USVString, throw if the USV length is not 1
record TBD: maybe a JS Record? same as dictionary
variant see below see below
list create a typed array copy for number types; otherwise produce a JS array (like sequence) same as sequence
string same as USVString same as USVString
tuple TBD: maybe a JS Tuple? TBD
flags TBD: maybe a JS Record? same as dictionary of optional boolean fields with default values of false
enum same as enum same as enum
option same as T? same as T?
result same as variant, but coerce a top-level error return value to a thrown exception same as variant, but coerce uncaught exceptions to top-level error return values
own, borrow see below see below

Notes:

  • Function parameter names are ignored since JavaScript doesn't have named parameters.
  • If a function's result type list is empty, the JavaScript function returns undefined. If the result type list contains a single unnamed result, then the return value is specified by ToJSValue above. Otherwise, the function result is wrapped into a JS object whose field names are taken from the result names and whose field values are specified by ToJSValue above.
  • In lieu of an existing standard JS representation for variant, the JS API would need to define its own custom binding built from objects. As a sketch, the JS values accepted by (variant (case "a" u32) (case "b" string)) could include { tag: 'a', value: 42 } and { tag: 'b', value: "hi" }.
  • For option, when Web IDL doesn't support particular type combinations (e.g., (option (option u32))), the JS API would fall back to the JS API of the unspecialized variant (e.g., (variant (case "some" (option u32)) (case "none")), despecializing only the problematic outer option).
  • When coercing ToWebAssemblyValue, own and borrow handle types would dynamically guard that the incoming JS value's dynamic type was compatible with the imported resource type referenced by the handle type. For example, if a component contains (import "Object" (type $Object (sub resource))) and is instantiated with the JS Object constructor, then (own $Object) and (borrow $Object) could accept JS object values.
  • When coercing ToJSValue, handle values would be wrapped with JS objects that are instances of the handles' resource type's exported constructor (described above). For own handles, a FinalizationRegistry would be used to drop the own handle (thereby calling the resource destructor) when its wrapper object was unreachable from JS. For borrow handles, the wrapper object would become dynamically invalid (throwing on any access) at the end of the export call.
  • The forthcoming addition of future and stream types would allow Promise and ReadableStream values to be passed directly to and from components without requiring handles or callbacks.
  • When an imported JavaScript function is a built-in function wrapping a Web IDL function, the specified behavior should allow the intermediate JavaScript call to be optimized away when the types are sufficiently compatible, falling back to a plain call through JavaScript when the types are incompatible or when the engine does not provide a separate optimized call path.

ESM-integration

Like the JS API, ESM-integration can be extended to load components in all the same places where modules can be loaded today, branching on the layer field in the binary format to determine whether to decode as a module or a component.

For URL import names, the embedded URL would be used as the Module Specifier. For plain names, the whole plain name would be used as the Module Specifier (and an import map would be needed to map the string to a URL). For locked and unlocked dependency names, ESM-integration would likely simply fail loading the module, requiring a bundler to map these registry-relative names to URLs.

TODO: ESM-integration for interface imports and exports is still being worked out in detail.

The main remaining question is how to deal with component imports having a single string as well as the new importable component, module and instance types. Going through these one by one:

For component imports of module type, we need a new way to request that the ESM loader parse or decode a module without also instantiating that module. Recognizing this same need from JavaScript, there is a TC39 proposal called Import Reflection that adds the ability to write, in JavaScript:

import Foo from "./foo.wasm" as "wasm-module";
assert(Foo instanceof WebAssembly.Module);

With this extension to JavaScript and the ESM loader, a component import of module type can be treated the same as import ... as "wasm-module".

Component imports of component type would work the same way as modules, potentially replacing "wasm-module" with "wasm-component".

In all other cases, the (single) string imported by a component is first resolved to a Module Record using the same process as resolving the Module Specifier of a JavaScript import. After this, the handling of the imported Module Record is determined by the import type:

For imports of instance type, the ESM loader would treat the exports of the instance type as if they were the Named Imports of a JavaScript import. Thus, single-level imports of instance type act like the two-level imports of Core WebAssembly modules where the first-level has been factored out. Since the exports of an instance type can themselves be instance types, this process must be performed recursively.

Otherwise, function or value imports are treated like an Imported Default Binding and the Module Record is converted to its default value. This allows the following component:

;; bar.wasm
(component
  (import "./foo.js" (func (result string)))
  ...
)

to be satisfied by a JavaScript module via ESM-integration:

// foo.js
export default () => "hi";

when bar.wasm is loaded as an ESM:

<script src="bar.wasm" type="module"></script>

Examples

For some use-case-focused, worked examples, see:

TODO

The following features are needed to address the MVP Use Cases and will be added over the coming months to complete the MVP proposal: