Skip to content
This repository has been archived by the owner on Aug 17, 2022. It is now read-only.

How to handle JS methods/constructors? #87

Open
alexcrichton opened this issue Nov 22, 2019 · 24 comments
Open

How to handle JS methods/constructors? #87

alexcrichton opened this issue Nov 22, 2019 · 24 comments

Comments

@alexcrichton
Copy link

One feature of this proposal that may have gotten lost in the shuffle from the previous "WebIDL Bindings" moniker to interface types is the ability to configure how JS functions are invoked. Some imported functions want to be invoked as a new function (e.g. new Function(...)) and others want to be invoked as a method where the first argument is the this of the call (e.g. foo.bar(other, arguments)).

In reviewing this again, I'm not sure if we have an avenue of introducing this with adapter functions? You sort of want to annotate that an adapter import is calling the imported function in a particular way, but this is very much a JS-ism that isn't really present in most other languages (methods, maybe, constructors, less so).

Do others have ideas of how we might reincorporate this JS feature back into the proposal? The only goal here is to hook up wasm/C++ engines directly without JS glue in the middle, so I don't think it really matters how we do it so long as the end goal is met for methods/constructors/etc.

@devsnek
Copy link
Member

devsnek commented Nov 22, 2019

Here are the two "primitive" procedures you'd have to support:

  • Call(F, thisValue, args)
  • Construct(F, newTarget, args) (newTarget is a constructor, usually F)

Even if you combine them somehow into a "JS calling convention": F(thisValue, newTarget, ..args), and make sure you are only passing thisValue or newTarget, you still have to decide somewhere to either perform a Call or a Construct, you just move it up the chain.

@fgmccabe
Copy link
Contributor

fgmccabe commented Nov 24, 2019 via email

@devsnek
Copy link
Member

devsnek commented Nov 24, 2019

Yeah, the problem isn't methods, it's construct. A construct is not a call, it just sometimes looks like a call (in fact you can do new Bar, without parentheses or arguments)

@fgmccabe
Copy link
Contributor

fgmccabe commented Nov 24, 2019 via email

@lukewagner
Copy link
Member

I think the distinction between (static) functions and methods belongs on the interface-typed function signature. This distinction wouldn't have any meaning when both sides were wasm: the arguments simply get passed as normal. It's only when one side is non-wasm that the distinction might become meaningful for determining how to interpret the call for that other language.

In particular, when a function's type was "method", a JS caller would know to pass the receiver as the first argument (shifting the actual args right by one), instead of ignoring it, and a JS callee would know to pass the first argument as the receiver (shifting the other actual args left by one), instead of passing undefined. Other dynamic languages should be able to have a similar interpretation, I think.

There's also the question of whether we really need to have a "constructor" distinction. If we wanted total JS co-expressivity, we would (and we'd need an explicit newTarget argument too), but I don't think that's a hard requirement. We do want efficient Web IDL calls, but, while Web IDL constructor functions are required to be called via new, that's in the ECMAScript binding; the WebAssembly binding can simply not make that requirement (and pass the default newTarget if there's any question about that). If we're calling a non-Web-IDL JS function, then we're calling JS anyway, so having to use a generated JS glue function isn't that bad. So I think that means we're fine without "constructor".

@alexcrichton
Copy link
Author

I think I may have missed the method-related instructions perhaps? I don't think they're currently in the repository, so to confirm are they either in PRs or in people's heads so far? Or is there documentation I'm missing?

I would also tend to agree that we can probably skip JS constructors until we hear otherwise. They're not necessarily the hot path we need to optimize for WebIDL integration.

@lukewagner
Copy link
Member

I don't currently see a need for a method-related instruction; just a "method" flag on interface function signatures.

@fgmccabe
Copy link
Contributor

fgmccabe commented Nov 26, 2019 via email

@lukewagner
Copy link
Member

Oh right, now I remember this idea. So what would a value of an interface interface type be lifted from and lowered into? Is the abstract interface value semantically a record containing an abstract type and a bunch of closures? Does this introduce a dependency on the type imports proposal?

@fgmccabe
Copy link
Contributor

fgmccabe commented Nov 26, 2019 via email

@lukewagner
Copy link
Member

Ok, but what core values do you lift/lower an interface value from/to?

@lukewagner
Copy link
Member

lukewagner commented Nov 27, 2019

Actually, let me rewind and revise my earlier position; I actually think we don't need any new interface types or adapter instructions to be able to effectively and efficiently call Web API methods.

The reasoning is basically the same as I gave above for why we don't need a "constructor" flag. In particular, since, as part of this overall proposal, we're defining a new "WebAssembly binding" in the Web IDL spec, we can simply say that, when the callee is a Web IDL method (as indicated by it being inside a Web IDL interface and not being marked static or a constructor), the receiver is taken as the first wasm argument (unconditionally). And conversely for when the host calls wasm through Web IDL.

This is backwards compatible because all existing calls today necessarily go through the Web IDL ECMAScript binding (and must continue to do so for backcompat reasons); only (new) adapted import calls can go through the new Web IDL WebAssembly binding.

I also think this is still polyfillable b/c if I want to polyfill Web API function f in JS, I know whether f is a method or a static function and so I can simply take all the wasm arguments (passed to my polyfill) and use them to call f appropriately.

The only use cases we lose are allowing wasm to call or be called by JS while directly passing a receiver. But, as already reasoned above, if we're calling to/from JS anyway, it's no big perf deal to use a little JS glue (which we are often using anyways, for other reasons).

FWIW, if we do want to pursue an interface interface type like @fgmccabe is talking about (probably renaming the type to avoid requiring typography to disambiguate "interface" ;), then I think it could serve as the basis for expressing idiomatic OO interfaces in JS et al without wrappers. But I think that's all post-MVP and we can meet our stated MVP Web API perf goals without them, using only the above approach.

@fgmccabe
Copy link
Contributor

This is interesting. I am ok with leaving it to be post ‘minimum awesome product’ .
The rationale for its eventual inclusion has to be whether having an object notion should be there or not. That in turn depends on whether it is important in modeling API designers’ intentions.

@alexcrichton
Copy link
Author

I'm personally a bit lost in how this is expected to work, but it sounds like y'all have this in hand. I suspect this would also be clarified pretty quickly once we get closer to having spec text being written. If y'all are ok seems fine to close this since it sounds like it's expected to all still be handled reasonably enough!

@lukewagner
Copy link
Member

Actually, @ajklein pointed out a faulty assumption in my logic above (perhaps the same thing tripping up @alexcrichton): I was assuming that, when calling JS, the JS is catered to the wasm caller. But in the "JS polyfill" case (in which someone has monkeypatched the global prototype chain, or something analogous in import-maps world), the JS polyfill is being used for both JS and wasm callers, so it's actually expecting the receiver as the receiver, not the first argument.

@ajklein
Copy link

ajklein commented Dec 10, 2019

@jgravelle-google has also pointed out that the use-case works the other way, too: being able to be the target of a call-with-receiver is important if we want Wasm to be able to seamlessly be the polyfilling function for a JS API.

@lukewagner
Copy link
Member

Ooh, unless I've missed a constraint, I think there's another option that supports polyfilling while avoiding adding the concept of "method" to interface types:

We can specify that, when an interface adapter is present and the caller/callee is JS or Web IDL, the first argument is always interpreted as the receiver. Thus, when wasm wants to call a Web IDL function, the wasm caller will take this fact into account and always pass the intended receiver as the first argument (passing ref.null in the (less common) case of calling a Web IDL static or namespace function). This way, whether the callee is actually Web IDL or a JS polyfill, the receiver is always well-defined. By gating this new behavior or the presence of an interface adapter, we avoid any breaking change.

WDYT?

(That of course doesn't help with constructors, but perhaps they aren't as hot and therefore Reflect.construct is sufficient.)

@ajklein
Copy link

ajklein commented Dec 17, 2019

I see that that approach might work, but the general direction here still doesn't seem right to me. What will we do once we end up trying to interop Wasm with some other language with its own "quirks"? Adding hard-coded special cases for JS seems strictly worse to me than including things in interface types to allow configurable JS interop.

@alexcrichton
Copy link
Author

One gotcha with an approach like that @lukewagner as well may be when you start using non-web-focused interface types modules. For example if WASI is defined with interface types we'd have to have dummy first arguments for all APIs in order to have a JS polyfill. Similarly if someone wrote a module not primarily for the web (but compatible with it) using interface types it may not work well with a JS polyfill of what needs to be imported.

@lukewagner
Copy link
Member

@ajklein The alternative seems to be for interface types to collect the union of quirks. But maybe that's fine, since a particular language binding can always ignore the quirk. (E.g., a functional language without a concept of "receiver" could simply ignore a "method" annotation, passing the receiver as the first arg.)

@alexcrichton Ah, good point. Technically, the JS polyfill could know that this was the first argument (which, in strict mode, can be any JS value) and polyfill accordingly, but that is admittedly awkward.

Ok, mostly I just wanted to explore the space of options before defaulting to either adding quirks or something fancier, but between the "polyfilling a Web API in JS" and "polyfilling a non-Web-API in JS" use cases, we might not have a simpler option.

@fgmccabe
Copy link
Contributor

Not sure I like either the automatic approach nor the special annotations.
OO patterns are pretty ubiquitous in API design; which, to me, suggests that we honor this properly. (The same logic supports u8-s64)
Having a service type (aka interface type (sic)) is a fine way of doing this.

@lukewagner
Copy link
Member

lukewagner commented Dec 19, 2019

After some offline discussion, I think I can see the opportunity for a new interface type that has the same runtime behavior/performance as what I was imagining above, but lets us improve how wasm interfaces with non-wasm. Not to bikeshed, but "service" has connotations of a distributed system (with partial failure, concurrency, persistence, ...), which feels too "big" to me, so perhaps we could call this a "protocol", like Fuchsia does?

So I think what we ultimately need in the core module is a type import and one function import for each Web IDL method the module calls. So, for firstChild, for example:

(module
  (type $Node (import "Node" "Type"))
  (func $firstChild (import "Node" "firstChild") (result (ref $Node)))
)

But using the new protocol interface type, we could adapt these two imports from one protocol import:

(module
  ... same two core imports as above
  (@interface protocol $Node (import "Node")
    (func $firstChild (export "firstChild") (result (ref $Node)))
  )
  (@interface type implement (import "Node" "Type") $Node)
  (@interface func implement (import "Node" "firstChild")
    (param $arg (ref $Node)) (result (ref $Node))
    (call-method $firstChild (local.get $arg))
  )
)

From a performance/impl perspective, this is the same as not using a protocol, but just importing the type and function separately.

So what does this buy us? Of course we don't need an ad hoc "method" attribute on functions as discussed above, but if that was the only benefit, then I'd question the value of having a whole separate interface type.

But I think this new protocol type also allows better binding to non-wasm. I'll describe what we could do in the JS API, but I think other languages could do likewise.

  • When a wasm module exports a protocol, the JS API can generate a constructor function whose prototype chain has the protocols methods.
  • When a wasm module imports a protocol, the JS value that is passed would be the JS/WebIDL constructor function and the JS API spec would specify how the methods were then plucked off the constructor's proto chain (such that we're still ultimately resolving the callee at instantiation-time). This plucking would walk the protochain in the same way as the get-originals proposal, which avoids the associated backcompat hazard (of methods moving up proto chains) without relying on all JS glue-code generators to realize and mitigate the hazard themselves, which is a concrete win.

WDYT? Is this kindof what you were thinking @fgmccabe ?

@fgmccabe
Copy link
Contributor

fgmccabe commented Dec 20, 2019

approximately, yes.
There are going to be times when the interface is more important than the type; and vice versa. I believe that the correct modeling is 'hasa' rather than 'isa': a type has an interface.
<bikeshed>one of the nouns I was contemplating instead of service was 'affordance'</bikeshed>

I believe that it is quite important that each method in a service/protocol/affordance has its own adapters. That gives us the necessary leverage to implement a given protocol the way that seems most pertinent.

Aside: it may be useful (I am currently exploring) to 'do to JS what we have done to wasm': to have a DSL for JS that allows us to express import and export adapters in 'almost Javascript'.

Simple example export adapter:

export getEnv(key:string):string{
return window.getenv_(key::string_to_jsstring)::jsstring_to_string

This would allow us to be precise about how JS can interoperate with WASM for particular APIs.

@lukewagner
Copy link
Member

lukewagner commented Dec 20, 2019

Agreed that each method of a protocol should have its own adapter. E.g., although the firstChild example above uses only (ref $Node), the methods could have used any interface type and this pretty much forces each method to have its own adapter.

E.g., to implement and export the above Node protocol, you could write something kinda like:

(module
  (type $Node ...something using GC or an `i31`...)
  (@interface func $firstChild (param (ref $Node)) (result (ref $Node))
     ... impl
  )
  (@interface protocol (export "Node") (type $Node)
    (export "firstChild" (func $firstChild))
  )
)

and thus there would be an adapter function on each side of a firstChild method call.

I'm a little more skeptical of the need to put explicit adapter functions into high-level scripting languages; I feel like the ideal here is that the interface types are high-level enough already that each scripting language can define an automatic mapping between interface values and the languages' values.

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

No branches or pull requests

5 participants