WebAssembly / component-model

Repository for design and specification of the Component Model
Other
933 stars 79 forks source link

Allow consuming methods #226

Open badeend opened 1 year ago

badeend commented 1 year ago

Currently the spec states:

Validation of [method] names requires the first parameter of the function to be (param "self" (borrow $R)), where $R is the resource labeled r.

Are there any plans to allow methods that consume their self argument?

My specific use case would be; to define a specialized destructor for a resource, which takes additional arguments and is async.

lukewagner commented 1 year ago

Great question! I was also wondering about this. A starting idea is to add a [destructor] prefix to <name> that implies a self parameter of type (own $R) that is expressed in Wit syntax as destructor(...args...) (symmetric to constructor). There's an interesting wrinkle for how to bind this to languages where destructors are nullary and thus where there's no good way to pass args (e.g., to a C++/Rust destructor, C# (and maybe JS?) using, or Python with); I was thinking maybe the validation rules require any ...args... to be option<T> for some T, and that's what gets called by those abovementioned language constructs, but you're also able to explicitly call the destructor earlier (with some specially-designated name) and pass arguments?

But how to make this "async" in the Preview 2 timeframe is tricky. Usually we express an async operation as a "pseudo-future" which is a resource that has a listen method that returns a pollable, so naively an async destructor would return a pseudo-future, which doesn't sound great. But instead perhaps we can put the listen method on the resource itself and say that if you want to asynchronously wait for the resource to be ready to be destroyed, you wait on its listen method, and then you're guaranteed that calling the destructor will be non-blocking. (And if you don't, then the destructor will either block or detach.)

badeend commented 1 year ago

I think it might be practical to separate the general case (arbitrary consuming methods) from the special destructor case. Precisely because of what you said; many languages have built-in mechanisms or otherwise well-known idioms on how to handle these destructors, and have other (or no) vocabulary for dealing with arbitrary methods that consume their this parameter.

Destructors

For interoperability, I don't think they should be allowed to have parameters other than self. Also, they must return () or future<()> If we have this, does this effectively supersede resource.drop ?

Arbitrary consuming methods

These are just regular methods like any other. Ie. the can take any kind of parameters and return any kind of value. Except that they consume themselves.

WIT can already express these functions as static methods:

resource my-res {
    build: static func(%self: my-res) -> u32
}

So the only change would be in the generated name; from %[static]my-res.build to %[method]my-res.build

Examples:

resource database-transaction {
    commit: consume func() -> future<_>
    rollback: consume func() -> future<_>
}
resource url-builder { // Mutable
    set-scheme: func(scheme: string)

    // ...

    build: consume func() -> url
}

resource url { // Immutable
    scheme: func() -> string
    // etc...
}
lukewagner commented 1 year ago

That's a really cool idea. consume is also more general than destructor in that it doesn't necessarily "destroy" the resource, it may just change the resource's state and return a new handle with a new type to reflect the new state and set of available operations (emulating "typestate"). So yeah, maybe it's consume we want; I'd like to noodle on it a bit more and hear what others think.

As for the question about whether destructors supersede resource.drop: I think it's useful to ensure that every resource type implementation is always able to add a destructor as a private impl detail (i.e., without changing the public interface). This effectively means that, from a public interface pov, every resource must have a dtor and so, if we're not wanting to allow the signature to vary (as I was considering above), there's no point in requiring the destructor to be written in Wit or explicitly imported, and then that's basically resource.drop :)

juntyr commented 1 year ago

That's a really cool idea. consume is also more general than destructor in that it doesn't necessarily "destroy" the resource, it may just change the resource's state and return a new handle with a new type to reflect the new state and set of available operations (emulating "typestate"). So yeah, maybe it's consume we want; I'd like to noodle on it a bit more and hear what others think.

I also like the more general design of a consume method, since they may just modify the resource (but require ownership for this) or actually consume them. When experimenting with wit-bindgen, I noticed that taking an owned resource handle currently provides no way of fully consuming the resource into an owned value of the type implementing the resource (think Rust's into_inner methods). Perhaps resources could gain a new drop function, which invalidates the owned resource handle but does not invoke its destructor, allowing the contained value to be retrieved?

badeend commented 1 year ago

if we're not wanting to allow the signature to vary (as I was considering above), there's no point in requiring the destructor to be written in Wit

Almost. The way I sketched it above, the destructor was able to be async. The signature could change in its return type (() or future<()>).

I think it's useful to ensure that every resource type implementation is always able to add a destructor as a private impl detail

If destructors are only synchronous, then; Yes, that would indeed be nice. If destructors are allowed to be asynchronous, then it wouldn't a private implementation detail anymore.

lukewagner commented 1 year ago

@badeend Great points! Given that it seems like one should always be able to drop any own handle one is holding (even if you haven't imported any other functions for it), it seems like this "destruction is sync/async" distinction needs to be on the resource type itself (to be clear: in a Preview 3 timeframe), so that you can always drop and also always know whether dropping is a sync or async operation.

badeend commented 11 months ago

FYI, just came across some examples in wasi-http that could benefit from this:

resource response-outparam {
  set: static func(param: response-outparam, response: result<outgoing-response, error>);
}

resource incoming-body {
  finish: static func(this: incoming-body) -> future-trailers;
}

resource outgoing-body {
  finish: static func(this: outgoing-body, trailers: option<trailers>);
}
lukewagner commented 10 months ago

Yep, those are great examples! I think we probably don't have time to add this to Preview 2, but I'm definitely interested to consider it for inclusion in Preview 3 next year.