raw
current commit
240703.2.2.4.1.1 · call `ref.func` needs to be declared first

Typed Function References for WebAssembly

Introduction

This proposal adds function references that are typed and can be called directly. Unlike funcref and the existing call_indirect instruction, typed function references do not need to be stored in a table to be called (though they can). A typed function reference can be formed from any function index.

The proposal distinguished regular and nullable function reference. The former cannot be null, and a call through them does not require any runtime check.

The proposal has instructions for producing and consuming (calling) function references. It also includes instruction for testing and converting between regular and nullable references.

Typed references have no canonical default value, because they cannot be null. To enable storing them in locals, which so far depend on default values for initialization, the proposal also tracks the initialization status of locals during validation.

Motivation

Summary

Examples

The function $hof takes a function pointer as parameter, and is invoked by $caller, passing $inc as argument:

(type $i32-i32 (func (param i32) (result i32)))
(elem declare funcref (ref.func $inc))

(func $hof (param $f (ref $i32-i32)) (result i32)
  (i32.add (i32.const 10) (call_ref $i32-i32 (i32.const 42) (local.get $f)))
)

(func $inc (param $i i32) (result i32)
  (i32.add (local.get $i) (i32.const 1))
)

(func $caller (result i32)
  (call $hof (ref.func $inc))
)

It is also possible to create a typed function table:

(table 1 (ref $i32-i32) (ref.func $inc))

Such a table can neither contain null entries nor functions of another type. Because entries can’t be null, tables of concrete type are required to be declared with an explicit initialisation value. Any use of call_indirect on this table does hence avoid all runtime checks beyond the basic bounds check. By using multiple tables, each one can be given a homogeneous type. The table can be initialized with an initializer or by growing it.

Typed function references are a subtype of funcref, so they can also be used as untyped references. All previous uses of ref.func remain valid:

(func $f (param i32))
(func $g)
(func $h (result i64))

(table 10 funcref)

(func $init
  (table.set (i32.const 0) (ref.func $f))
  (table.set (i32.const 1) (ref.func $g))
  (table.set (i32.const 2) (ref.func $h))
)

Language

Based on reference types proposalgithub, which introduces types funcref and externref.

Types

Heap Types

A heap type denotes a user-defined or pre-defined data type that is not a primitive scalar:

Reference Types

A reference type denotes the type of a reference to some data. It may either include or exclude null:

Type Definitions

Subtyping

The following rules, now defined in terms of heap types, replace and extend the rules for basic reference types.

Reference Types
Heap Types
Type Indices

Defaultability

Local Types

Instruction Types

Note: Extending block types with index sets to allow initialization status to last beyond a block’s end is a possible extension.

Instructions

Functions

Optional References

Locals

Typing of local instructions is updated to account for the initialization status of locals.

Note: These typing rules do not try to exclude indices for locals that have already been set, but an implementation could.

Instruction Sequences

Typing of instruction sequences is updated to account for initialization of locals.

Note: These typing rules do not try to eliminate duplicate indices, but an implementation could.

A subsumption rule allows to go to a supertype for any instruction:

Tables

Table definitions have an initializer value:

Binary Format

Types

Reference Types

Opcode Type Parameters
-0x10 funcref
-0x11 externref
-0x1c (ref ht) $t : heaptype
-0x1d (ref null ht) $t : heaptype

Heap Types

The opcode for heap types is encoded as an s33.

Opcode Type Parameters
i >= 0 i
-0x10 func
-0x11 extern

Instructions

Opcode Instruction Immediates
0x14 call_ref $t $t : u32
0x15 return_call_ref $t $t : u32
0xd4 ref.as_non_null
0xd5 br_on_null $l $l : u32
0xd6 br_on_non_null $l $l : u32

Tables

Entries to the table section are extended as follows:

Table Definition Note
tabletype null-initialized table (as before)
0x40 0x00 tabletype constexpr explicitly initialized table

The encoding of a table type starts with the encoding of a reference type, which cannot be 0x40 (since this is a pseudo type code otherwise only used in block types). Consequently, both forms can be distinguished by the first byte. The second byte is reserved for possible future extensions.

JS API

The group decided to go with the “no-frills” approach for the JS API for the time being. In the context of this proposal, that means that conversions (or type reflection) at concrete reference type throws a TypeError exception.

Value Conversions

Reference Types

In addition to the rules for basic reference types:

Constructors

Global

Table

Type Reflection

In the presence of the JS type reflection proposalgithub:

Note: The GC proposalgithub is expected to at least add anyref as a recognised RefType, but possibly throw on other abstract reference types.

Possible Extension: Full Type Reflection

Post-MVP, the type reflection abilities of the JS API could be refined based on the JS type reflection proposalgithub.

Type Representation

Note: The GC proposalgithub adds additional heap types.

Value Conversions

Reference Types

In addition to the rules for basic reference types:

Note: The GC proposalgithub is expected to allow additional conversions for anyref.

Possible Extension: Function Subtyping

In the future (especially with the GC proposalgithub) it will be desirable to allow the usual subtyping rules on function types. This is relevant, for example, to encode method tablesgithub.

While doing so, the semantics of static and dynamic type checks need to remain coherent. That is, both static and dynamic type checking (as well as link-time checks) must use the same subtype relation. Otherwise, values of a subtype would not always be substitutable for values of a supertype, thereby breaking a fundemental property of subtyping and various transformations and optimisations relying on substitutability.

At the same time, care has to be taken to not damage the performance of existing instructions. In particular, call_indirect performs a runtime type check over (structural) function types that is expected to be no more expensive than a single pointer comparison. That effectively rules out allowing any form of subtyping to kick in for this check.

Exact Types

This tension with call_indirect can be resolved by distinguishing function types that have further subtypes and those that don’t. The latter are sometimes called exact types.

Exact types might come in handy in a few other circumstances, so we could distinguish the two forms in a generic manner by enriching heap types with a flag as follows:

Exact types are themselves subtypes of corresponding non-exact types, but the crucial difference is that they do not have further subtypes themselves. That is, the following subtype rules would be defined on heap types:

in combination with the canonical rules for function subtyping itself:

(and appropriate rules for other types that may be added to the type section in the future).

With this:

For any import or reference in a module’s interface that flows into a function table on which it invokes call_indirect, the module can thereby enforce exact types that are guaranteed to succeed the signature check.

Example

Consider:

(module $A
  (type $proc (func))
  (func (export "f") (result funcref) ...)
  (func (export "g") (result (ref $proc)) ...)
)

(module $B
  (type $th (func (result funcref)))
  (func $h (import "..." "h") (type exact $th))

  (table $tab funcref (elem $h))

  (func $call
    ...
    (call_indirect $th (i32.const 0))
    ...
  )

  (func $update (param $f (ref exact $th))
    (table.set (local.get $f) (i32.const 0))
  )
)

In module $B, the imported function $h requires an exact type match. That ensures that a client cannot supply a function of a subtype, such as A.g, which would break the use of call_indirect in $call. An attempt to do so will fail at link time. Passing A.f would be accepted, however.

Similarly, $update can only be passed a reference with exact type, again ensuring that $call will not fail unexpectedly subsequently.

If a function import (or reference) never interferes with call_indirect, however, then the import can have a more flexible type:

(module $C
  (type $th (func (result funcref)))
  (func $h (import "..." "h") (type $th))

  (func $k (result (ref $th))
    (call $h)
  )
)

Here, the laxer import type is okay: for $h, any function that returns a subtype of funcref is fine, since all choices will satisfy the result type (ref $th) of function $k performing the call.

More generally, the following imports are well-typed:

(module
  (type $proc (func))
  (type $tf (func (result funcref)))
  (type $tg (func (result (ref $proc))))

  (func $h1 (import "A" "g") (type $tg))
  (func $h2 (import "A" "g") (type exact $tg))
  (func $h3 (import "A" "g") (type $tf))
  ;; (func $h4 (import "A" "g") (type exact $tf))   ;; fails to link!
)

Note that the use of exact types for imports or references flowing into a function table is not enforced by the type system. It is the producer’s responsibility to protect its interface with suitably narrow types.

Backwards Compatibility and Textual Syntax

For exposition, we assumed above that exact types are a new construct, denoted by an explicit flag. However, such a design would still change the meaning of existing type expressions (by suddenly allowing subtyping) and would hence not be backwards compatible, for the reasons stated above.

To avoid breaking existing modules when operating in new environments that support subtyping, we must preserve the current subtype-less, i.e. exact, meaning of the pre-existing function types – at least in the binary format. Subtypable function references/imports must be introduced as the new construct.

It’s a slightly different question what to do for the text format, however. To maintain full backwards compatibility, the flag would likewise need to be inverted: instead of an optional exact flag we’d have an optional sub flag with the opposite meaning:

In the binary format it doesn’t really matter which way both alternatives are encoded. But for the text format, this inversion will be rather annoying: the normal use case is to allow subtyping; but to express that, every import or reference type will have to explicitly state sub.

One possibility might be to keep the exact syntax above for the text format, and effectively change the interpretation of the existing syntax. That means that existing text format programs using e.g. function imports would change their meaning, and running wasm/wat converters would produce different results. In particular, rebuilding a binary from a pre-existing text file may produce a binary with weaker interface requirements.

Is the risk involved in that worth taking? How much of a problem do we think this would be?