language icon indicating copy to clipboard operation
language copied to clipboard

Should extension structs support sub-classing?

Open leafpetersen opened this issue 1 year ago • 9 comments

In the extension struct proposal (#2360), I propose that extension structs should be forbidden from extending other extension structs. At the end of the proposal, there is a discussion of allowing this. This issue is for discussion of whether to allow this, and with what constraints and semantics.

leafpetersen avatar Jul 29 '22 23:07 leafpetersen

cc @mit-mit @lrhn @eernstg @chloestefantsova @johnniwinther @munificent @stereotype441 @natebosch @jakemac53 @rakudrama @srujzs @sigmundch @rileyporter

leafpetersen avatar Jul 30 '22 00:07 leafpetersen

@srujzs and @sigmundch have indicated fairly strongly that this is close to a requirement for the JS interop use case, so this discussion may be somewhat pro-forma. Overall, I see almost nothing objectionable to allowing inheritance without overriding. The discussion of overriding is tracked separately in #2369 .

There is one concern around allowing inheritance across libraries, not because of extension structs, but because of structs. Discussion of this for structs is tracked in #2367 . We of course do not need to be consistent between extension structs and structs here, but it's slightly odd if we don't. This might push towards splitting the two features further apart (e.g. into the data class and view class syntax) to reflect the fact that the semantics diverge.

leafpetersen avatar Jul 30 '22 00:07 leafpetersen

What does it mean to extend a view/extension struct. Presumably it means that you include the extended view's methods and add your own on top (whether overriding or not).

That sounds safe.

The big question is whether the extension struct types has a subtype relation. If they do, what does that mean?

  • Assignable from subtype to supertype.
  • ???

That doesn't seem particularly valuable, since you can always cast back to the underlying viewee type and back into the supertype. It's the type of the underlying object which really matters for which assignments are valid, not the extension struct types.

lrhn avatar Jul 31 '22 13:07 lrhn

The subtype relationship may be more convenient, to such an extent that it matters:

extension struct Node(JSObject that) {...}
extension struct Element(JSObject that) extends Node(that) {...}

void f(List<Node> nodes) {...}

void g(List<Element> elements) {
  ... f(elements) ... // OK when `extends` implies subtyping.
  // As opposed to:
  ... f(elements as List<Node>) ... // Required when not.
}

In particular, we may get as expressions wrong, without getting any heads up from the analyzer or compiler, because we are unable to specify that this particular cast is from an extension struct type to another extension struct type that happens to be similar (and the type system doesn't even recognize that they are similar at all).

eernstg avatar Aug 01 '22 14:08 eernstg

The big question is whether the extension struct types has a subtype relation. If they do, what does that mean?

My goal with this proposal is to minimize surprise for consumers of the API. With that in mind, I had the design goals in mind that I listed here. Specifically for extension, I think a consumer will be very surprised if A extends B and A is not a subtype of B, and if A does not have a superset of the methods of B. So I have set this up so that this is always the case.

leafpetersen avatar Aug 01 '22 20:08 leafpetersen

Rocking the boat a bit here - I'd like to revisit our requirements from JSInterop and see if "extension/subclassing" is the right model here.

Some alternatives to think about:

  • Could we achieve what we need through composition instead? What's missing in that case?
  • If non-virtual methods were their own feature (#2400) - would we design things differently?

I believe most of our goals around subclassing are really about having the ability to selectively forward nonvirtual methods. By selectively here I mean that sometimes we want to forward all, sometimes some and hide others, and sometimes we want to shadow some.

Note: the fact that we need to hide APIs brings already a semantic difference from the traditional extends: the set of methods in the subclass is no longer guaranteed to be a superset of the methods in the superclass. That is enough for me to require using a different syntax here (similar to the issues raised by @lrhn with implements in https://github.com/dart-lang/language/issues/2363#issuecomment-1210393395)

One proposal that comes to mind is to explicitly introduce the concept of forwarding nonvritual methods. For example:

extension struct B(int x) {
  get isOne => x == 1;
}

extension struct A(int x) forwardsto B {
  // the forwardsto gets expanded into:
  // get isOne => B(x).isOne
}

Such forwarding concept will allow us to handle several of our use cases:

  • We can use it to model inheritance in the DOM (e.g. extension struct HtmlElement(JavaScriptObject node) fowardsto Node)
  • We can use it to implement shadowing libraries, such as:
    • A shim that translates old dart:html APIs to new JSInterop based APIs. (Note that this requires hiding some APIs or shadowing them with a new signature (e.g. return type will not match)).
    • A safe-html layer that can enforce stronger security standards than the low-level JSInterop APIs (this too may require shadowing, for example, an API takes a SafeURL or a SafeHtml instead of a raw String).

Also worth noting that some of these use cases sometimes introduce the need to shadow multiple classes in a class hierarchy together, which creates non-traditional multiple inheritance relationships. For example:

// library 1 defines:
extension struct Node(JavaScriptObject x) {}
extension struct HtmlElement(JavaScriptObject x) forwardsto Node {}
// library 2 defines:
extension struct Node(JavaScriptObject x)  forwardsto library1.Node /*with some shadowing */ {}
extension struct HtmlElement(JavaScriptObject x) forwardsto library2.Node, library1.HtmlElement /* with some shadowing */ {}

That said, this kind of shadowing of class hierarchies may be too specific to one single use case of ours. As such, we would be OK if we decide this is not something we will design for. If needed, we can address all the shadowing through codegen instead (it would mean though that we replicate every definition and manually do the expansion I presented earlier with "forwardsto", though)

sigmundch avatar Aug 10 '22 17:08 sigmundch

What if we introduced method forwarding in general, in a way that also applies to classes.

class Something implements Foo {
  FooBar x;
  // ...
  export x {  // or whatever keyword.
    foo, 
    bar, 
    operator+, 
    interface Foo,
  }
}

which means that the Something class exposes the member signatures of FooBar.foo, FooBar.bar, FooBar.+ and all the member signatues of the interface Foo (which FooBar must implement), and forwards all those to the same member on x (unless otherwise implemented in Something).

The Something class doesn't have to implement Foo for this, it can expose the members anyway.

The expression after export is a full expression, it's evaluated each time a member is forwarded. Typically it'll just be a getter invocation.

Shorthands:

  export e;  // same as `export e {interface <static type of e>}`

(Getters/setters may be a problem, if export x {foo} exports only the getter, then export x {foo, foo=} gets verbose and repetitive. If export x {foo} exports both getter and setter, then there is no way to only export the getter.)

Since each forwarding is independent, you can forward extension/non virtual methods as well, it all depends on the static type of the exported expression.

This can then be used in any declaration, whether it's a class, mixin, struct or view. It's just shorthand for writing forwarders, so export this.list {add} just means void add(X value) => this.list.add(value);, which is a valid way to declare a method in any context.

(Hmm, that means we can even do static export e { ... }. Probably not top-level exports, that would conflict with actual exports :frowning_face:.)

lrhn avatar Aug 10 '22 17:08 lrhn

I do like that. and the export syntax is growing on me. I could even imagine using the show/hide as well:


class A {
...
  export e 
     show add, remove;
  export f 
     hide foo;
}

sigmundch avatar Aug 11 '22 22:08 sigmundch

What if we introduced method forwarding in general, in a way that also applies to classes.

I like this syntax. This would cover my use case in https://github.com/dart-lang/sdk/issues/31483

natebosch avatar Aug 16 '22 22:08 natebosch

Everything about the export declaration has been deleted from the most recent version of the view specification. It's certainly possible that we will introduce that kind of mechanism in the future (on views, classes, mixins, and whatnot), we just need to reserve some time to get it right.

eernstg avatar Sep 22 '22 10:09 eernstg