WebAssembly / interface-types

Other
641 stars 57 forks source link

How to handle JS methods/constructors? #87

Open alexcrichton opened 4 years ago

alexcrichton commented 4 years ago

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 commented 4 years ago

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

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 commented 4 years ago

This is not forgotten. We currently have instructions for calling a function and for invoking a method.

On Fri, Nov 22, 2019 at 2:49 PM Gus Caplan notifications@github.com wrote:

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

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

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.

— You are receiving this because you are subscribed to this thread. Reply to this email directly, view it on GitHub https://github.com/WebAssembly/interface-types/issues/87?email_source=notifications&email_token=AAQAXUHYBLBRSGWDAE3MVV3QVBOYDA5CNFSM4JQWWYG2YY3PNVWWK3TUL52HS4DFVREXG43VMVBW63LNMVXHJKTDN5WW2ZLOORPWSZGOEE7C3WI#issuecomment-557723097, or unsubscribe https://github.com/notifications/unsubscribe-auth/AAQAXUCIEH7M5WFI6WQETG3QVBOYDANCNFSM4JQWWYGQ .

-- Francis McCabe SWE

devsnek commented 4 years ago

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 commented 4 years ago

It is not really within the remit to support all JS features. As far as wasm is concerned; calling a constructor is indistinguishable from calling a function; (think calling a factory method in Java).

There are two halves to each adapted function: an internal import adapter and an external export adapter.

If the external adapter chooses to invoke a constructor that seems a reasonable strategy to me.

On Sun, Nov 24, 2019 at 12:54 PM Gus Caplan notifications@github.com wrote:

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)

— You are receiving this because you commented.

Reply to this email directly, view it on GitHub https://github.com/WebAssembly/interface-types/issues/87?email_source=notifications&email_token=AAQAXUCRXCAWX6KBIHTUFTDQVLSXJA5CNFSM4JQWWYG2YY3PNVWWK3TUL52HS4DFVREXG43VMVBW63LNMVXHJKTDN5WW2ZLOORPWSZGOEFAUTZQ#issuecomment-557926886, or unsubscribe https://github.com/notifications/unsubscribe-auth/AAQAXUDKU5LLVAP3B6MOBADQVLSXJANCNFSM4JQWWYGQ .

-- Francis McCabe SWE

lukewagner commented 4 years ago

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 commented 4 years ago

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 commented 4 years ago

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

fgmccabe commented 4 years ago

I believe that we only need two elements: an ‘interface type’ similar to that in webidl and an ‘invoke method’ adapter instruction.

On Tue, Nov 26, 2019 at 10:53 AM Luke Wagner notifications@github.com wrote:

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

— You are receiving this because you commented.

Reply to this email directly, view it on GitHub https://github.com/WebAssembly/interface-types/issues/87?email_source=notifications&email_token=AAQAXUEZE4YMBNMQVSRVISTQVVA6BA5CNFSM4JQWWYG2YY3PNVWWK3TUL52HS4DFVREXG43VMVBW63LNMVXHJKTDN5WW2ZLOORPWSZGOEFGPWIA#issuecomment-558693152, or unsubscribe https://github.com/notifications/unsubscribe-auth/AAQAXUHRY7E5K5MDNUJOPRTQVVA6BANCNFSM4JQWWYGQ .

-- Francis McCabe SWE

lukewagner commented 4 years ago

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 commented 4 years ago

Actually I think not.

Type imports as far as I understand them are used to introduce opaque types. This is kind of the opposite: the interface says what you can do with the type.

Some languages (such as java) have a concept of method which is effectively an inseparable set of functions and instance variables. JS does not have this really but it is implied by the way webidl is written.

One can rationalize this in terms of the related functionality; JS does not honor the inseparability aspect of it but it is ‘a thing’ when you want to model APIs

On Tue, Nov 26, 2019 at 12:56 PM Luke Wagner notifications@github.com wrote:

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 https://github.com/WebAssembly/proposal-type-imports/blob/master/proposals/type-imports/Overview.md ?

— You are receiving this because you commented.

Reply to this email directly, view it on GitHub https://github.com/WebAssembly/interface-types/issues/87?email_source=notifications&email_token=AAQAXUEKHUVT4YJ6JHDIXG3QVVPL5A5CNFSM4JQWWYG2YY3PNVWWK3TUL52HS4DFVREXG43VMVBW63LNMVXHJKTDN5WW2ZLOORPWSZGOEFG44SI#issuecomment-558747209, or unsubscribe https://github.com/notifications/unsubscribe-auth/AAQAXUARGAUOBWS3SRJVZ4LQVVPL5ANCNFSM4JQWWYGQ .

-- Francis McCabe SWE

lukewagner commented 4 years ago

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

lukewagner commented 4 years ago

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 commented 4 years ago

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 commented 4 years ago

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 commented 4 years ago

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 commented 4 years ago

@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 commented 4 years ago

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 commented 4 years ago

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 commented 4 years ago

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 commented 4 years ago

@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 commented 4 years ago

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 commented 4 years ago

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.

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

fgmccabe commented 4 years ago

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 commented 4 years ago

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.