-
Notifications
You must be signed in to change notification settings - Fork 10
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Proposal for supporting JavaScript method inheritance using Rust traits #3
Proposal for supporting JavaScript method inheritance using Rust traits #3
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks so much for writing up this RFC @Pauan! It is so helpful to have an example/strawman proposal to guide the discussion and get things started.
General questions for everybody:
Do we want these INode
and IEventTarget
etc traits to be object safe? Having generic methods breaks that. I think we can get away without object safety, since I don't think trait objects for these things will be generally useful (see inline comment below).
For the WebIDL frontend, are we expecting to stop emitting normal methods and only emit trait methods? If not, will we break method resolution, or will it and type inference do the Right Thing? I'd have to investigate further by playing around with some code, but maybe someone else already has a good intuition for the behavior of method resolution and TI.
Do we expect "normal" users using the proc-macro frontend to use this stuff? Does it make sense to limit the scope to only the WebIDL frontend?
Thanks again @Pauan for writing this up!
As an example, it should be possible to use the | ||
[`appendChild`](https://developer.mozilla.org/en-US/docs/Web/API/Node/appendChild) | ||
method with all of the classes which inherit from `Node` (e.g. `HTMLElement`, | ||
`HTMLDivElement`, `SVGElement`, and many more). |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it would be good to clarify whether the goal is
- to enable writing generic functions like
fn takes_element<E: IElement>(elem: E) {...}
, or - to enable ergonomically calling inherited methods without upcasting with
Into
orFrom
(e.g.span.appendChild(...)
rather thanlet element: Element = span.into(); element.appendChild(...);
), or - both.
I personally think that (1) should be a non goal, since there is no benefit for user code to do fn takes_element<E: IElement>(elem: E) {...}
over fn takes_element(elem: Element) {...}
other than potentially saving an into()
at call sites. The downside is that we will get monomorphizations that don't pay for their extra code size bloat with better optimization potential because all the calls are going to be the same anyways and aren't inlinable (since they are FFI calls).
Nor does it make sense to do fn takes_element(elem: &IElement) {...}
99.99% of the time, since our investigation into overridden methods showed that it doesn't happen often and the few times it does, you probably want the original method anyways. It also implies indirect calls, and potentially boxing (if taking a Box<IElement>
).
Therefore, I think we should focus on (2) and we should better reflect that in this motivation section.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree with your assessment that we should focus on (2).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we accomplish 2, doesn't that necessarily mean that 1 will be supported as well?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I personally think that (1) should be a non goal, since there is no benefit for user code to do
fn takes_element<E: IElement>(elem: E) {...}
overfn takes_element(elem: Element) {...}
other than potentially saving aninto()
at call sites.
I thought about this some more, and doesn't that statement completely invalidate this RFC? Since you said that writing <E: IElement>
"only saves an into()
at call sites", that's also true with the current status quo, since people can always write html_element.into().append_child(node)
, right?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also, I would like to make an argument in favor of users using generic trait bounds.
I wrote a DOM library called dominator. It contains a DomBuilder
struct.
This struct is intentionally designed so that it can work with a wide variety of HTML elements, with correct static typing.
For example, it can work with Node
, HTMLElement
, SVGElement
, HTMLInputElement
, etc. That is accomplished by having it take a generic A
type argument.
Then it contains a bunch of methods which are bounded by traits:
IEventTarget
INode
IElement
IHtmlElement
The end result is that users can do things like this:
html!("input" => HtmlInputElement, {
.attribute("foo", "bar")
.style("qux", "corge")
})
And that macro will be expanded into this:
{
let x: DomBuilder<HtmlInputElement> = DomBuilder::new(create_element_ns("input", HTML_NAMESPACE));
x.attribute("foo", "bar")
.style("qux", "corge")
.into_dom()
}
Most of the details are unimportant, but the important thing is that you can use the html!
macro with a wide variety of DOM types, and then call methods (such as attribute
or style
) on those DOM types, and everything will be fully statically typed: you will get a compile-error if you call the wrong method on the wrong DOM type.
The generic bounds like <A: IElement>
are needed to allow the same method to be called on multiple different DOM types. And code bloat isn't a problem because I've carefully made sure that all of the methods are small and inlined.
I think perhaps you are underestimating how useful generic code is, even for external stuff like the DOM.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Regarding html_element.into().append_child(...)
, this won't work because the rust compiler doesn't know what you are trying to convert into. This is always a downside with generic return values. You would have to expand it as
let node: Node = html_element.into();
node.append_child(...)
As for generic arguments, I agree with you that they are useful and far more ergonomic, but they also fit into a bigger picture of function overloading (along with optional arguments and union type arguments). While I would probably have gone down a path much more similarly to what you are proposing, I think there is a general desire to keep the bindings pretty low level, and so far that has meant avoiding generics. This is definitely something that I want to revisit in the future in the form of a unified function overloading proposal, but I don't think that it should be a blocking concern at the moment.
#[wasm_bindgen] | ||
extern { | ||
#[wasm_bindgen(method, structural, js_name = addEventListener)] | ||
fn add_event_listener(this: &JsValue, name: &str, listener: &Closure<FnMut(Event)>, use_capture: bool); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Rather than generating this: &JsValue
receivers, I think we would want to use the name of the super class that introduces the method. I fear that using JsValue
might break host bindings support.
The structural
attribute will certainly break host bindings support, as it forces a JS shim between the wasm and the native DOM function. We shouldn't automatically add that attribute. Is there a reason you added it here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As explained in the "Unresolved Questions" section, I wasn't sure how to support non-structural
uses.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I believe most of the issues you are having with structural would be handled by placing the methods on the corresponding interface type rather than on JsValue (as per @fitzgen's suggestion).
It is absolutely essential that we don't use structural everywhere.
fn append_child<A: INode>(this: &JsValue, child: A) -> A; | ||
|
||
#[wasm_bindgen(method, structural, js_name = removeChild)] | ||
fn remove_child<A: INode>(this: &JsValue, child: A) -> A; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think extern
functions can be generic. In the general FFI case, it doesn't make sense because a generic function is actually many functions, one for each type that the generic is instantiated with, and the extern function can't know all the the types that it will be instantiated as by some downstream user. Rust even disallow generics in extern functions:
error[E0044]: foreign items may not have type parameters
--> src/lib.rs:2:5
|
2 | fn gen<A: Into<usize>>(a: A);
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ can't have type parameters
|
= help: use specialization instead of type parameters by replacing them with concrete types like `u32`
To make this kind of generics sugar work, we would want to as_ref()
to concrete types before the extern function call, something like this:
#[wasm_bindgen]
extern {
#[wasm_bindgen(method, js_name = appendChild)]
fn append_child(this: &Node, child: &Node);
// ...
}
pub trait INode: AsRef<Node> + IEventTarget {
fn append_child<N: INode>(&self, child: &N) {
append_child(self.as_ref(), child.as_ref());
}
// ...
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This also relates a bit to method overloading (which occurs at least for constructors), in that the generic argument here is really serving as a way to handle multiple signatures. I would say that any generic bindings should be discussed as a future extension to the bindings. So for now I would use the following signature which should be forward-compatible with @fitzgen suggestion:
pub trait INode: AsRef<Node> + IEventTarget {
fn append_child(&self, child: &Node) {
// ...
}
// ...
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think extern functions can be generic.
To be clear, the generic is on an extern
which is annotated with wasm_bindgen
, so wasm-bindgen could replace the generic extern
with a non-generic extern
.
For example, this:
#[wasm_bindgen]
extern {
fn foo<A: Foo>(A) -> A;
}
...can be compiled into something like this:
#[wasm_bindgen]
extern {
fn __generated_foo(JsValue) -> JsValue;
}
#[inline]
fn foo<A: Foo>(value: A) -> A {
__generated_foo(value.into()).unchecked_into()
}
(Where Foo
extends from JsCast
)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@ohanar The purpose of the generic with append_child
is to retain type information, because the append_child
method always returns the argument you pass in, so if you pass in an HTMLElement
you get back an HTMLElement
.
Having it instead always return a Node
causes a loss of type information, which can only be re-obtained by using dyn_into
, with some runtime cost and loss of ergonomics.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So two things here.
- First is that there is no way to know that what more specific return type you have on a method if you have a more specific input type by examining the WebIDL. Consider the following snippet:
interface Node : EventTarget {
Node appendChild(Node node);
...
};
There is no way to know that if you actually pass an HTMLElement
into the appendChild
method you get back an HTMLElement
. This could be addressed by adding our own custom attribute to the operation (e.g. WbgReturnType=typeof(node)
), but that would require us to comb through many files and add such annotation (in addition to the work involved in codegen).
- Secondly, the return value for this particular method is actually pretty useless. Interface objects are passed by (shared) reference, not by value. In the case that you pass in an
HTMLElement
, you would still have complete access to the original object after the method call, meaning that you don't need the return value to have access to that particularHTMLElement
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
First is that there is no way to know that what more specific return type you have on a method if you have a more specific input type by examining the WebIDL. Consider the following snippet:
That's a really good point! It's true that in practice the appendChild
method always returns its argument, but that isn't actually specified in the WebIDL.
That's a good argument for avoiding the generics for now, since in my opinion the Rust bindings should be close to the WebIDL bindings.
Secondly, the return value for this particular method is actually pretty useless.
Yes, but according to the WebIDL it does return something, so unless you add in a custom attribute it will generate a return value automatically.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, but according to the WebIDL it does return something, so unless you add in a custom attribute it will generate a return value automatically.
Completely agree, it should still return a Node
, and we shouldn't change that (regardless of how useful it is).
Then the trait methods are rewritten so that they call the extern functions (using `as_ref()` to convert `&self` to a `&JsValue`), | ||
and they are marked as `#[inline]`. | ||
|
||
Now that we have defined the desired traits and methods, we can add the methods to a type like this: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are we expecting users to write these impl
s by hand? Or is this automatically generated? If so, how does wasm-bindgen know when to emit an impl
? Which new or existing attributes trigger that, and on which items do these attributes need to appear?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For now, I think just supporting WebIDL is enough, but eventually users will be writing the traits (and impls) by hand (which is why I chose a convenient syntax for it).
Even though WebIDL is by far the biggest usage of class-inheritance, there are plenty of other uses of class-based inheritance in JavaScript.
If we want to instead focus solely on WebIDL for now, I can change the RFC to be more focused on that.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As for a new attribute, I had considered that, but the default impls make it so easy to apply the traits, so I figured an attribute was overkill.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it is fine to include mock syntax for what macros might look like in the future, but I think we should definitely focus on WebIDL, and make it clear that macro support is not part of this RFC, merely an example of how it could potentially be extended in the future.
would have to duplicate all of the `EventTarget`, `Node`, and `Element` methods on `HtmlElement`, etc. | ||
|
||
This is an incredible amount of duplication, so it's only really feasible for a tool which automatically | ||
generates the methods (such as the WebIDL generator). Trying to do this duplication by hand is unmaintainable. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Additionally, it means we will end up emitting larger .wasm
binaries that have more imports, and larger JS glue that grabs what ultimately end up being the same functions many times.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not necessarily, if most of the implementations are along the lines of the following:
impl HtmlElement {
#[inline]
fn append_child(&self, node: &Node) -> Node {
let this: &Node = self.as_ref();
this.append_child(node)
}
}
And have the real implementation lie in a Node
impl block.
|
||
```rust | ||
// Now all of the methods work! | ||
use web_sys::traits::*; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This would be a second .rs
file emitted by the WebIDL frontend?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It could also be an inline module, e.g.:
// ... all the bindings that we currently generate + trait definitions
pub mod traits {
pub use super::{
INode,
IHtmlElement,
// ...
};
}
It would be easy enough to track each of the interfaces to add such an inline module at the end of the generated module.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Regardless of whether it's a separate .rs
or an inline module, either way I expect it to be auto-generated by the WebIDL generator.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
All I was saying is that it would be pretty straightforward to implement an extra traits module.
Regarding @fitzgen's questions.
|
|
||
## Mixins | ||
|
||
The above technique works great for class-based inheritance, but it can also be used to support mixins. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The WebIDL spec explicitly says that mixins are not meant to be exposed in language bindings, but are merely an editorial tool. In particular, for the same reason as just stuffing everything onto JsValues, this could be incompatible with the host-bindings proposal.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since the methods are a part of a mixin and not a class, how will that work with the host-bindings proposal?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Mixins are a shorthand way of including methods and/or attributes in multiple interfaces.
Consider the webgl vs webgl2 contexts. Many of the methods are the same, but there is no real relation between them from a class hierarchy sense. To simplify spec specification, you can use place all the redundant declarations in a mixin and then include it for each of the contexts. This is the same as just copying and pasting all of the redundant declarations into each of their interface declarations.
We already support mixins (and partial interfaces, which are similar in concept) in our webidl crate, so I would really just remove the section on mixins from the RFC.
impl INode for Node {} | ||
``` | ||
|
||
Because the traits contain default implementations, this is all that is needed to make it work. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So the main difference if we want to stop emitting normal methods for interface objects, and only expose the methods on traits would be here. The default implementations would be more or less the same (modulo @fitzgen comment about how they shouldn't be on JsValues), but the impl for an interface object for the corresponding interface trait would contain the real implementation. E.g.
impl IEventTarget for Node {} // Use default implementation
impl INode for Node {
fn append_child(&self, node: &Node) -> Node {
// the messy generated extern stuff would be in here.
}
// ...
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why does IEventTarget
have default impls, but INode
doesn't?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll try to write up something a bit more clear on this tomorrow.
Got some time to write this up while proctoring an exam. Consider the following snippet of WebIDL: interface EventTarget {
void addEventListener(DOMString type, EventListener listener);
};
interface Node : EventTarget {
Node appendChild(Node node);
}; Right now we more or less get the following generated API (not complete, but most of what is relevant): pub struct EventTarget { ... }
impl EventTarget {
pub fn add_event_listener(&self, type_: &str, listener: &EventListener) {
// ffi mess goes here
}
}
pub struct Node { ... }
impl Node {
pub fn append_child(&self, node: &Node) -> Node {
// ffi mess goes here
}
}
impl AsRef<EventTarget> for Node { ... } As I understand it this proposal as currently written would change this to the following (ignoring generic stuff, which seems to be going away): pub struct EventTarget { ... }
pub trait IEventTarget: AsRef<JsValue> {
#[inline]
pub fn add_event_listener(&self, type_: &str, listener: &EventListener) {
let this: &JsValue = self.as_ref();
this.add_event_listener(type_, listener)
}
}
impl IEventTarget for EventTarget {}
impl JsValue { // How is this being done in the web-sys crate? Would break orphan rules.
fn add_event_listener(&self, type_: &str, listener: &EventListener) {
// structural ffi mess here
}
}
pub struct Node { ... }
pub trait INode: IEventTarget + AsRef<JsValue> {
#[inline]
fn append_child(&self, node: &Node) -> Node {
let this: &JsValue = self.as_ref();
this.append_child(node)
}
}
impl INode for Node {}
impl JsValue { // Again, orphan issues.
fn append_child(&self, node: &Node) -> Node {
// structural ffi mess here
}
}
impl AsRef<EventTarget> for Node { ... } Beyond the issues with structural, I just realized while writing this that there would likely be orphan rule issues. I would propose the following output (largely based on the current proposal, but should address both the structural issues and the orphan rule issues): pub struct EventTarget { ... }
pub trait IEventTarget: AsRef<EventTarget> {
#[inline]
pub fn add_event_listener(&self, type_: &str, listener: &EventListener) {
let this: &EventTarget = self.as_ref();
this.add_event_listener(type_, listener)
}
}
impl IEventTarget for EventTarget {
fn add_event_listener(&self, type_: &str, listener: &EventListener) {
// ffi mess here
}
}
pub struct Node { ... }
pub trait INode: IEventTarget + AsRef<Node> {
#[inline]
fn append_child(&self, node: &Node) -> Node {
let this: &Node = self.as_ref();
this.append_child(node)
}
}
impl INode for Node {
fn append_child(&self, node: &Node) -> Node {
// ffi mess here
}
}
impl IEventTarget for Node {}
impl AsRef<EventTarget> for Node { ... } The main difference here is that we only cast up to where a method is introduced, rather than all the way up to |
@ohanar How is that better than this? pub struct EventTarget {
fn add_event_listener(&self, type_: &str, listener: &EventListener) {
// ffi mess here
}
}
pub trait IEventTarget: AsRef<EventTarget> {
#[inline]
fn add_event_listener(&self, type_: &str, listener: &EventListener) {
let this: &EventTarget = self.as_ref();
this.add_event_listener(type_, listener)
}
}
impl IEventTarget for EventTarget {} pub struct Node {
fn append_child(&self, node: &Node) -> Node {
// ffi mess here
}
}
pub trait INode: IEventTarget + AsRef<Node> {
#[inline]
fn append_child(&self, node: &Node) -> Node {
let this: &Node = self.as_ref();
this.append_child(node)
}
}
impl IEventTarget for Node {}
impl INode for Node {} P.S. This RFC proposes to use |
By the way, I am working on a rewrite of this RFC (taking into account all of the feedback), but I've been a bit busy with Monster Hunter World. I plan to have it done by tomorrow. |
@Pauan That works equally well, assuming rust will choose the normal method over the trait method when they both have the same name. You could also have the normal method have a prefix or suffix (e.g. |
I rewrote the RFC to take into account all of the feedback. |
Thanks @Pauan! This looks pretty good to me. One thing that I would make sure is very clear in the RFC, is that this is a breaking change compared to the current impromptu API, but that it is also easy for a user to fix (by adding a |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There is still the unresolved question of what we do with static methods and constants. I would say that these should stick on the type, rather than on the trait, with the main motivation being that the new
static method (i.e. the bare constructor) will likely have ambiguity issues if placed on the traits. (Also, there isn't really anything to be gained by putting them on the traits.)
}; | ||
``` | ||
|
||
Based upon that WebIDL spec, the WebIDL generator will generate this Rust code: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Small nitpick, the WebIDL frontend doesn't actual use the wasm-bindgen macros, it directly generates code. We often think about what is equivalent between the two, but they fundamentally both go directly to codegen. E.g. we would say that the snippet above might de-sugar into the extern block below, but it doesn't actually ever create the extern block below, it creates the same post-processed output of the extern block.
``` | ||
|
||
However, due to the potential for code bloat, this sort of pattern is *not* encouraged | ||
by this RFC. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe make clear that the user should take a Node
argument instead?
fn remove_event_listener(this: &EventTarget, type: &str, callback: &Closure<FnMut(Event)>, options: bool); | ||
|
||
#[wasm_bindgen(method, js_name = dispatchEvent)] | ||
fn dispatch_event(this: &EventTarget, event: Event); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should have a bool
return.
#[wasm_bindgen] | ||
extern { | ||
#[wasm_bindgen(method, js_name = addEventListener)] | ||
fn add_event_listener(this: &EventTarget, type: &str, callback: &Closure<FnMut(Event)>, options: bool); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not exactly what will be generated due to recently merged support for union arguments. (Regardless, this isn't yet supported since callback interfaces don't have support yet.)
Maybe use slightly a slightly simplified WebIDL snippet for this RFC (the full thing isn't really needed to give an example).
|
||
3. This trait has an `AsRef<Type>` constraint | ||
|
||
4. If the WebIDL interface extends from another interface, then that is also added as a constraint (e.g. `INode` inherits from `IEventTarget`) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe make clear that you only put direct inheritance as a constraint? For example IElement
would not have the direct constraint of IEventTarget + INode
, but only INode
(the IEventTarget
constraint would be implied).
other than WebIDL, because of the maintenance burden. | ||
|
||
And because class-based inheritance is used outside of WebIDL, we want to be able to support non-WebIDL use | ||
cases. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
One other alternative is to use Deref
to model single inheritance. This doesn't suffer from the same drawbacks mentioned here, but it is generally considered an anti-pattern.
|
||
---- | ||
|
||
This proposal requires a lot of boilerplate (to define the inherit and trait methods). This is acceptable |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe move this stuff out of unresolved questions and into a new Future Extensions
section?
Do you have any examples of static methods? I could only find constants. At first I agreed with you, however I tested it and you can use I don't know if that will still be true with the host bindings proposal, but it is the web reality today. Since the WebIDL seems to treat constructors and constants as different, I think we can do the same with Rust: put constants onto the trait, but put the (Also, my understanding is that the interfaces like On the other hand, I don't see much benefit to putting them into the trait, since the constant will be the same for all of the types, so I'm fine with just defining So I guess the question is whether we want to be super-pedantic with following the WebIDL exactly or not. Also, are there any situations where a constant or static method is overridden by a sub-class? That would be a good reason to put them onto the trait. |
Thanks for taking the time to write this up @Pauan! I'm personally a little hesitant to explore this sort of trait hierarchy and/or abstractions in Now we can't entirely do that because Some concrete thoughts as to why I think we should stick with the low-level bindings we have today are:
Overall I think it's good motivation to strive to make calling superclass methods more ergonomic, but I don't think we want to bend over backwards to satisfy this goal. The goal of ergonomics will always be secondary for the |
er sorry, didn't mean to close! |
I was originally very gung-ho about the prospect of using traits to represent inherited base methods, but seeing it all written out in all of its complexity, I have to agree with Alex's last comment. However! It has also made me reconsider #[wasm_bindgen]
extern {
pub type Base;
#[wasm_bindgen(extends = Base)]
pub type Derived;
} would also generate impl Deref for Derived {
type Target = Base;
fn deref(&self) -> &Base { ... }
} We originally dismissed this as an anti-pattern, but it would not only enable base method calls without casts, it would also enable passing references to bases as arguments: // Can do this:
derived.base_method();
// Instead of this:
let base: &Base = derived.as_ref();
base.base_method();
// Also, can do this!
takes_base(&derived);
// Instead of this:
takes_base(derived.as_ref()); The simplified version of this proposal does not handle the latter, and still introduces more complexity than emitting a The "disadvantages" and "discussion" section from the anti-pattern link aren't enough to dissuade me here -- the DOM is already designed how it is and we are forced to match it. If we were designing something Rust-y from scratch, then no I wouldn't suggest What do y'all think? |
That is covered by the RFC, it's not a problem because the user can simply do
That is a good point! I'll add it to the drawbacks section.
That's not quite true: I made an argument that the trait bounds are useful for more than just calling superclass methods. Perhaps I should add that argument to the RFC.
I think the complexity of traits is about the same as However, I agree that |
In principle, I'm fine with the proposed trait system not being in web-sys. However, to have another crate do it requires processing the same webidl, and generating the same method signatures (including overloaded names) inside of traits rather than on normal methods. It feels a bit silly to put this logic anywhere else than the webidl crate, at which point, why not just include it in web-sys? As for the Deref alternative, I still find it a compelling alternative. It also could be introduced at a later date in a backwards-compatible fashion. |
I removed some of the downsides of So essentially it's a choice between these two (largely) incompatible strategies:
Honestly, I think both options are bad, but traits at least handle every situation correctly, whereas Deref lulls you into a false sense of correctness. |
Thanks for the update and sorry for the delay! I personally remain unconvinced that traits are the best option here. I don't understand how traits solve the overriden method downside of Deref (they seem to both suffer the same problem), and while it's true that WebIDL shouldn't be different than the ecosystem it seems like this still comes up so rarely in practice that it's not worth designing everything around it. I would personally be in favor of either using |
@alexcrichton Oops, I accidentally removed that section when I did some cleanup. First, to explain the problem with let foo: Foo = ...;
let bar: Bar = ...; fn my_fn(x: &Foo) -> bool { x.some_method() }
// Calls Foo::some_method
my_fn(&foo);
// Also calls Foo::some_method
my_fn(&bar); As you can see, it calls This problem happens even if we use fn my_fn<A: AsRef<Foo>>(x: &A) -> bool { x.as_ref().some_method() }
// Calls Foo::some_method
my_fn(&foo);
// Also calls Foo::some_method
my_fn(&bar); However, traits do solve it: fn my_fn<A: IFoo>(x: &A) -> bool { x.some_method() }
// Calls Foo::some_method
my_fn(&foo);
// Calls Bar::some_method
my_fn(&bar); This works because impl IFoo for Bar {
fn some_method(&self) -> bool {
...
}
} As far as I know, this is the only way to override methods in Rust.
I disagree that it comes up rarely enough that we can just ignore it. There are plenty of JS libraries which use class-inheritance, and so we need some sort of solution for them. The solution might be "use I agree that we don't need to solve it right now, but we do need a solution eventually.
I agree, after examining the issue thoroughly, I'm personally in favor of postponing this RFC (i.e. doing nothing), until we have more real-world experience. And who knows, maybe one of the inheritance RFCs which are floating around will get implemented in the Rust compiler, thus solving the issue completely. |
Ok yes that makes sense, but note that a solution to all of this is "simply use I would personally prefer to keep pushing on this in the sense that ideally we would not postpone this RFC but rather accept the |
@alexcrichton Oh! That's really interesting! I hadn't realized that |
Hi. I've read the RFC and the comments and I have a few comments of my own. Maybe first a word of who I am and where I'm coming from: I'm creating a tool that transpiles TypeScript definition files to rust. Typescript definition files are kind of similar to WebIDL, but they support the full flexibility of the TypeScript type system, which goes pretty far beyond WebIDL or even Rust's type system (just scroll through this page to get an idea), so I won't be able to transpile everything accurately. But I want to do as much as possible. I'd like to support both stdweb and wasm-bindgen, and I'd like to compile to "idiomatic" stdweb and wasm-bindgen code, which is why I'm here: to find out what idiomatic wasm-bindgen code should be. Seems like that's not set in stone yet. So, on to my comments: Ergonomics
I think it would be better if Overridden methodsLike everyone in this thread, I like the However, I disagree with the previous claims in this thread that the trait solution does solve problem of overridden methods. While I admit that traits handle the situation better than the In my discussion, I'll use mostly the same names that @Pauan used in his example here. Specifically, I'll assume that there's
The quick summary of the issue, if I understand it correctly, is that there is no way to make a function that accepts a " The solution with traits does in fact solve this specific issue. With the trait solution, you can make a generic function that accepts a But note that you have to implement Having to define many impls is still just an inconvenience, it technically still works. But only in this case, not in all cases. What if I want to store " By using traits and generic functions you can carry compile-time information of whether a certain Everything
|
In that case the best thing would probably be to put them into separate modules. I do agree that the
That is true, which is why for casual usage we would have to implement some sort of macro to smooth it out.
You would use By the way, Deref does not fix that problem at all, since it only applies to references. So without traits you would need to first use On the other hand, with traits,
That is a good point, it's something that stdweb has struggled with. Though it's unrelated to trait vs Deref, since the same issue exists in both. If you mark the method as So it would be more correct to say that it's an issue with non- I do agree that if methods are marked as
This isn't actually true. It is true that the easiest and shortest syntax is structural, but it is entirely possible to use JavaScript methods in a non-structural way: // Structural
someBar.someMethod(...);
// Non-structural
Foo.prototype.someMethod.call(someBar, ...); This is a common technique when needing to call the super-class' method inside of the sub-class' method: Bar.prototype.someMethod = function (...) {
Foo.prototype.someMethod.call(this, ...)
};
I would personally say yes.
Yes, that is the trade-off: if you know there won't be any overridden methods, then it's faster because it can statically jump directly to the appropriate method implementation. But if you need runtime dynamicism, then you will need to pay a performance cost. I'll note that traits do not have that trade-off: you can get full performance even with overridden methods. Instead the trade-off with traits is different: you're trading compiled code size for performance.
I agree, the tiny performance gain is not worth the silent bugs.
That's a good point, though I believe it should be possible to use the We can't test it yet, but I believe the performance of using
The host bindings are still up in the air, but my understanding is that they will only make certain specific APIs faster, they won't magically make all JS methods faster. When host bindings are implemented, it'll probably be necessary to add in a new So even with host bindings, any calls to a regular JS method will be the same performance as doing So the performance of non- The connection between non-
Since
That's a good question. Benchmarks would be good.
Perhaps, though that can quickly be offset by the need to lookup Browsers are pretty damn good at optimizing prototype lookups though, so I doubt there's a significant performance difference between structural and non-structural. As said, benchmarks would be good.
I agree, I think structural should be the default. Yes in some cases it might be slower, but it's never any slower than standard JS method calls (which JS programmers already use all the time, non-structural method calls are rare). And I think the correctness is much more compelling than some (possible) minor speed boost.
I think that's a good topic for a new RFC (since it's pretty far off-topic from this RFC).
Hmm, I had assumed that that was the case, but it seems I didn't spell it out clearly in the RFC (probably because the RFC focused on traits). In any case I agree, the "top-level" classes should Deref to JsValue (which also means that all sub-classes indirectly can Deref to JsValue). |
Thanks for the reply @Pauan. I agree with pretty much all of it. Anything I don't mention in my reply here, I agree with.
Is it possible for a macro to do this? I mean, such a macro would have to understand the entire superclass chain of a type, including all the methods defined on all those superclasses, to know which methods are overridden and therefore need to be implemented explicitly. Do macros have that kind of information?
I admit, I didn't consider trait objects at all in my post. Trait objects do partially solve this problem, but there's a big flaw with trait objects:
Does anyone do this besides for calling the super method in an overriding method? Or more specifically, since
Only for methods not marked
Can this code size bloat be avoided with sufficient inlining or is it unavoidable?
I don't agree that it's off-topic. If we make it so that methods are assumed to be structural unless marked otherwise (marked how? On the other hand, if we assume that methods are non-structural unless marked otherwise, which we do now, then I'm in favor of the trait solution, but only as a form of "damage control". The trait solution would make it slightly harder to shoot yourself in the foot, but it's certainly still possible. |
Hmmm, you're right, it may be too difficult for a macro. Which I guess means that even though traits technically solve the issue, they do so at extreme annoyance for sub-classes.
Could you explain more? I did some testing, and it seems to work fine.
I couldn't find a way to upcast, so you may be right, but since it's possible to call
I don't know of any, no.
I think the answer is a bit complicated. Deref has to do some unchecked coercions to the right type (and it has to use references), whereas traits do not. However, those unchecked coercions can probably be inlined, so the performance is probably the same (excluding the code bloat from generics).
Yes, inlining can fix the code size bloat (at least sometimes). You can also use trait objects to avoid the code bloat (but that's probably worse performance than Deref).
Both structural and non-structural methods exist right now, so it's already possible to just mark every method as structural. There are situations where structural makes sense for Deref and traits, and also situations where non-structural makes sense for Deref and traits. The default is just about ergonomics and convenience, so which one is default shouldn't affect the Deref vs trait decision, in my opinion. However, making |
I apologize, I was wrong. I read this Github issue which reports that upcasting trait objects isn't possible and links to threads and discussions about multiple-trait trait objects ( It seems like there are even ways to make upcasting work, by essentially manually adding methods to the vtable for the purpose of upcasting. Seems clunky, but I guess it works. Still doesn't help in the situation where a JS function returns a |
Thanks for the post here @migi, I think you bring up some interesting points! I wanted to elaborate a bit more on the In host bindings function calls can be flagged with a "this" parameter, which means that wasm-bindgen will actually need zero shims for JS Now that all sounds nice, but how does it actually perform today? I haven't ever measured myself! I've been slowing trying to work on a small suite of benchmarks with wasm-bindgen and such, and I've recently added one for structural-vs-final. The UI is pretty terrible, but you can run the benchmark by visiting this page and then clicking "Run test" on the "structural vs not" line. Eventually you'll see two bars below. The left-hand bar is The measurements I got in a few browsers were:
I've been getting sort of wildly different results though over time, sometimes Firefox is seemingly up to 15% faster with In any case, I feel that we should let empirical data drive us towards a conclusion about whether to use |
@alexcrichton (Edit: @lukewagner corrected me on this, host bindings are always non- So as I said, it's not about "host bindings vs not host bindings", it's just about the trade-off of performance (looking up the prototype once, with non- P.S. It's great to see some benchmarks! The results are pretty much what I expected: not much difference between I tested out the benchmark, I get similar results as you. @lukewagner Sorry for pinging you in here, but I have five important questions:
|
Great questions!
FWIW, this is all based on the straightforward Host Binding that has been proposed ( |
@lukewagner Thanks a lot for answering my questions! So I was mistaken about the host bindings, they do indeed correspond to non- But as you said, it's possible for Another question: when you are importing a method, how do you specify which class it should use? In other words, how would you import Or are you saying that it will still be necessary to create a JS shim that basically does
One of our motivations is to be able to (ideally) remove all JS shims and just use host bindings to directly call APIs. Even if we can't use host bindings 100% of the time, we want to be able to use them as much as possible (for improved performance, and smaller file size, due to not needing JS shims). There are situations in wasm-bindgen where we need to do prototype lookups (i.e. the same as |
I also like (and originally advocated for) the original idea of using the intended wasm Host Bindings semantics. But if there's a significant performance advantage to generating
By default, wasm is simply given values directly (from the import object) or, later, with ESM integration, from some other module's exports. To avoid the boilerplate of plucking a bunch of properties off various global objects/ctors/prototypes, one could imagine yet another Host Binding to do this directly as part of the JS wasm instantiation process, but again this likely wouldn't be a performance improvement, just glue-code reduction. If Built-in Modules become real, then wasm could import those directly without glue. Furthermore, if builtin modules reflected existing builtins currently on the global (a variation of getOriginals), then potentially no glue would be needed for any builtin. But this is all rather speculative at the moment, so I think we need to wait a bit to see (and participate) in how that develops.
I like that goal, although there's obviously a balance between spec/engine complexity and supporting all the possible use cases. So at least for a "Host Bindings MVP", I'm interested to focus on just the set of things that remove JS glue thunks between wasm and Web APIs, allowing fast, direct calls. |
The discussion here is pretty long at this point and a bit winding with respect to alternative, so I've opened up a formal alternative to this PR of using |
For those interested, we also had a discussion in the last working group meeting about this RFC recapping a few things. |
@lukewagner Thanks a lot, that's very useful information! So my understanding is that JS shims will still be necessary even after the host-bindings proposal is implemented. The only thing the host-bindings proposal buys us is the ability to make function calls with So in order to avoid JS shims, we would need something even further, such as the built-in modules proposal. That definitely makes |
I'd like to now propose that we enter the Final Comment Period for this RFC. Disposition: close @rustwasm/core members to sign off: This is done at the same time of proposing that we merge #5 |
In favor of closing this RFC and pursuing #5 |
#5 has finished its FCP and is now merged, so I'm going to close this |
Rendered