Skip to content
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

Should extension structs allow overrides in sub-classes? #2369

Closed
Tracked by #2360
leafpetersen opened this issue Jul 29, 2022 · 25 comments
Closed
Tracked by #2360

Should extension structs allow overrides in sub-classes? #2369

leafpetersen opened this issue Jul 29, 2022 · 25 comments
Assignees
Labels
data-classes extension-types inline-classes Cf. language/accepted/future-releases/inline-classes/feature-specification.md structs

Comments

@leafpetersen
Copy link
Member

leafpetersen commented Jul 29, 2022

In the extension struct proposal (#2360) I initially propose to disallow extension structs to extend other extension structs. #2368 discusses whether to allow this. If we allow this, the question then arises of whether or not to allow overriding. This is discussed briefly in this addendum to the proposal. A concrete concern with allowing overriding, is that the semantics for extension structs members is non-virtual dispatch (think extension methods), and hence override behavior can be surprising:

extension struct EvenInteger(int x) {
    bool get isEven => true;
}
// Truly odd integers.
extension struct OddInteger(int x) extends EvenInteger {
    bool get isEven => false;
}

void test() {
  OddInteger i = OddInteger(2);
  assert(!i.isEven); // Dispatch goes to OddInteger.isEven
  EvenInteger e = i; // Ok
  assert(i.isEven); // Dispatch goes to EvenInteger.isEven
}

While this is surprising, it also seems like a large restriction to forbid API authors from modifying the implementation when extending. This issue is for discussion of this design choice.

@leafpetersen
Copy link
Member Author

In general, allowing this concerns me less than the question of overriding things from interfaces (discussed in #2360), since we only allow extending other extension structs, and there should be no expectation of interface polymorphism around extension structs. In contrast, with interfaces, when implementing a class interface, there is a definite expectation of polymorphism.

@lrhn
Copy link
Member

lrhn commented Jul 30, 2022

I see no problem with overriding and changing behavior based on static type. That's what extension structs do (or views, or extension types, or even normal extension methods).

The moment someone chose to use "extension"-something, they opted in to static type being the determining factor.

I'm more concerned with whether EvenInteger and OddInteger should be type-related at all, but if they are, and the author opted into that, this is expected behavior.

@eernstg
Copy link
Member

eernstg commented Aug 1, 2022

they opted in to static type being the determining factor.

I think this is a bit too much of a footgun, because static dispatch contradicts the behavior of object-oriented dispatch and member overriding, and yet it looks so similar.

extension struct BaseInteger(int x) {
    String get foo => 'base';
    String getFoo() => foo;
}

extension struct DerivedInteger(int x) extends BaseInteger {
    String get foo => 'derived';
}

void test() {
  var i = DerivedInteger(2);
  print(i.foo); // 'derived'.
  print(i.getFoo()); // 'base'.
}

It's tempting to say that "static overriding" is so different from standard OO overriding that we just shouldn't have the former.

If we disallow overriding then we'll have to rename DerivedInteger.foo to something else, and that's exactly what is needed in order to indicate the actual semantics.

@ds84182
Copy link

ds84182 commented Aug 1, 2022

Maybe this should be called "shadowing" instead of "overriding"? And maybe have a lint for adding an @shadow annotation as well, to make it clearer.

@srujzs
Copy link

srujzs commented Aug 1, 2022

If the concern is around the call site looking too similar to OO overriding, can we imagine a lint telling users to explicitly cast when they're using a supertype that contains a method that is overridden in a subtype?

For example:

extension struct BaseInteger(int x) {
    String get foo => 'base';
}

extension struct DerivedInteger(int x) extends BaseInteger {
    String get foo => 'derived';
}

void test() {
  var i = DerivedInteger(2);
  print(i.foo); // OK
  var b = i as BaseInteger;
  print(i.foo); // LINT, `foo` is overridden in DerivedInteger - prefer a cast to avoid confusion.
  print(BaseInteger(i).foo); // OK
}

Perhaps this is excessively verbose. It might also be noisy, as the value may never have been used with DerivedInteger anyways, so the confusion could never arise. My opinion is still that the surprising behavior is not surprising (or as surprising), since extensions behave that way today anyways:

class A {}

extension E1 on A {
  String get name => 'A';
}

class B extends A {}

extension E2 on B {
  String get name => 'B';
}

void main() {
  var b = B();
  var a = b as A;
  print(a.name); // prints A, not B
}

@eernstg
Copy link
Member

eernstg commented Aug 1, 2022

If the concern is around the call site looking too similar to OO overriding

My main concern is that the direct invocation of an overridden method "works" (the "overriding declaration" is executed), but an indirect invocation may well invoke the "overridden declaration":

In the example, we call i.foo where i has type DerivedInteger, and we get the implementation in DerivedInteger. But when we invoke i.getFoo(), we invoke a method declared in BaseInteger (that is, an "inherited" method), and when that method in turn invokes foo, we get the implementation of foo in BaseInteger.

I think that's too deceptively similar to OO overriding. Here's the example again, for easy reference:

extension struct BaseInteger(int x) {
    String get foo => 'base';
    String getFoo() => foo;
}

extension struct DerivedInteger(int x) extends BaseInteger {
    String get foo => 'derived';
}

void test() {
  var i = DerivedInteger(2);
  print(i.foo); // 'derived'.
  print(i.getFoo()); // 'base'.
}

@srujzs
Copy link

srujzs commented Aug 1, 2022

Ah, I was wondering why you included getFoo, sorry. :) But yes, I can see why that might be a bit more confusing than my example, and I don't see a great way around it. The above lint would catch that, but I still don't like it.

You may have discussed this elsewhere, but would maybe changing the keyword extends here help move users away from viewing this from an OO perspective?

@eernstg
Copy link
Member

eernstg commented Aug 2, 2022

If we don't have overriding (or shadowing, or whatever we want to call it) then the situation may actually be OK, because it is consistent with OO dispatch. So we don't need to remember that overriding works differently with extension structs, because there is no overriding, and the other things work the same:

extension struct Base(Object? _) {
    String get foo => 'base';
    String getFoo() => foo;
}

extension struct Derived(Object? _) extends Base {
    String get bar => 'derived'; // Can't override, use a different name.
}

void test() {
  var i = Derived(2);
  print(i.bar); // 'derived'.
  print(i.foo); // 'base'.
  print(i.getFoo()); // 'base'.
}

If the static type is Derived then it's fair to say that the dynamic type is also Derived. If we invoke a member like bar, where Derived contains an implementation, we get that implementation. So we're running the most specific implementation of bar, just like OO dispatch. Similarly, if we invoke foo then we get the implementation from Base, and that is the most specific implementation of foo, just like OO dispatch.

The story is quite simple: Overriding is an error with extension structs. If we need it, we should use a class or a struct.

@leafpetersen
Copy link
Member Author

Maybe this should be called "shadowing" instead of "overriding"?

@ds84182 I would like to have some verbiage to distinguish between arbitrary shadowing (e.g. replacing a method returning an int with a method returning a String), and what we have here, where we still propose to enforce a proper override relationship on the types.

@leafpetersen
Copy link
Member Author

Perhaps this is excessively verbose. It might also be noisy, as the value may never have been used with DerivedInteger anyways, so the confusion could never arise.

I don't really see why we would ship a feature that allows you to override foo, but where we would yell at anyone who actually called foo. I guess it sort of makes it easier on the API designer: they don't have to think about coming up with a new name? But it does so by punishing the API consumer (who must always explicitly name mangle on behalf of the API designer using the classname). In other words, why not just use DerivedInteger_foo as the overriding name, if we're going to require qualified names anyway?

My opinion is still that the surprising behavior is not surprising (or as surprising), since extensions behave that way today anyways:

Extensions don't define new types, so I think they're a little different. But I agree that particularly for extends OtherViewtype, this is less of a worry to me.

@srujzs
Copy link

srujzs commented Aug 5, 2022

Extensions don't define new types, so I think they're a little different.

Right, I agree, but I feel like it's a little more intuitive to grasp this when they're tied to a feature that exists already (extensions).

@rileyporter had some concrete examples that I believe are harder to work around without overriding that I don't remember but I hope she can share. If we can address some of these or at least have a consistent answer to them, then I feel less iffy about disallowing overriding.

@rileyporter
Copy link

For the dart:html static JS interop use case, we would like to be able to override some functionality. Ideally, we'll have a base dart:html layer, and then allow users to extend and override functionality with their own layers of extensions on top.

For example, returning a List<Node> instead of the DOM type NodeList (something we thought we might be able to do with having views extend other views) would have looked something like:

library 'dart:html';

class Node {}

extension NodeExtension on Node {
  NodeList get childNodes => js_util.getProperty(this, 'childNodes');
}
library 'my_list_library';
import 'dart:html' as html;

// Doesn't work, the `on` type needs to be Node and have NodeExtension.childNodes hidden
extension ListNodeExtension on html.NodeExtension {
  List<html.Node> get childNodes => wrap(js_util.getProperty(this, 'childNodes'));
}

The behavior we'd hoped for was when my_list_library is imported, any access to childNodes on a Node object would give the List<Node> version, and everything else not overridden from NodeExtension would still be accessible.

With extension structs, I don't think that really makes sense, but it might look something like:

// library 'dart:html'
extension struct Node() {
  NodeList get childNodes => js_util.getProperty(this, 'childNodes');
}

// library 'my_list_library'
extension struct ListNode() extends html.Node {
  List<html.Node> get childNodes => wrap(js_util.getProperty(this, 'childNodes'));
}

In order to get the behavior we want, I think we'd have to add casts to ListNode in user code (or change the other APIs to return ListNodes in appropriate places) to access the List version of childNodes.

In that example, the return type of childNodes is different, which may be more like the shadowing case instead of overrides, but we want users to be able to do the same thing, even if the signature for the overridden method is identical, e.g.

// library 'dart:html'
extension struct Node() {
  set nodeValue(String? newValue) {
    js_util.setProperty(this, 'nodeValue', newValue);
  }
}

// library 'my_safe_html'
extension struct SafeNode() extends html.Node {
  set nodeValue(String? newValue) {
    js_util.setProperty(this, 'nodeValue', sanitize(newValue));
  }
}

The desired behavior being when my_safe_html is imported, any accesses on Node.nodeValue uses the safe version.

@eernstg
Copy link
Member

eernstg commented Aug 10, 2022

@rileyporter, I think we can have the kind of features you want, with a twist. Here is a variant of your examples using views:

// Library 'dart.html'.
class Node {}

view NodeView on Node {
  NodeList get childNodes => js_util.getProperty(this, 'childNodes');
}

// Library 'my_list_library'.
import 'dart:html' as html;

view ListNodeView on html.NodeView {
  List<html.Node> get childNodes => wrap(js_util.getProperty(this, 'childNodes'));
}

With views we will have the subtype relation html.NodeView <: ListNodeView, because the on-type of a view is a subtype of the view type itself, for a plain view (that is, a view that doesn't have the open or closed modifier). This is upside down if you want to think of NodeView as the supertype and ListNodeView as the subtype.

I also tend to think that layering views on top of views is a subtle and complex approach, and it's much better to use a simpler relationship where each view in a family of related views are directly related to the underlying non-view-type (Node).

The simplest possible fix is to keep the two views unrelated:

// Library 'dart.html'.
class Node {}

view NodeView on Node {
  NodeList get childNodes => js_util.getProperty(this, 'childNodes');
}

// Library 'my_list_library'.
import 'dart:html' as html;

view ListNodeView on Node {
  List<html.Node> get childNodes => wrap(js_util.getProperty(this, 'childNodes'));
}

Now both views are connected with Node, but they have nothing to do with each other. That might be perfectly OK, depending on the intended usage.

However, if NodeView contains a lot of nice methods that we'd like to reuse with ListNodeView then we will need to have a connection. We could try to use extends to create this connection:

// Library 'dart.html'.
class Node {}

view NodeView on Node {
  NodeList get childNodes => js_util.getProperty(this, 'childNodes');
}

// Library 'my_list_library'.
import 'dart:html' as html;

view ListNodeView extends html.NodeView on html.Node {
  List<html.Node> get childNodes => wrap(js_util.getProperty(this, 'childNodes')); // ERROR!
}

However, this is a compile-time error because there is a collision between the two members named childNodes. We could just decide that this is no problem, and everything would "work", but the reason why I proposed that it should be an error is that we get a very fragile semantics:

import 'dart:html';
import 'my_list_library';

void main() {
  Node node = ...;
  NodeView nodeView = node; // OK.
  var list1 = nodeView.childNodes;
  ListNodeView listNodeView = nodeView; // OK.
  var list2 = listNodeView.childNodes;
}

We don't get any errors (compile-time or run-time), and we do get a list1 and a list2, but list1 is a NodeList and list2 is a List<Node>. In other words, the "override" relation between the two declarations named childNodes is only effective as long as the static type of the node is ListNodeView. But the static type of that same object can become NodeView with no errors, silently, and suddenly you're back to calling the "overridden" methods.

If we had given those two methods the same return type then we're not very likely to see the problem immediately. As seen from a class point of view, we are calling super.childNodes where we would expect to call childNodes. I think that's highly error prone, and it could very well be a subtle bug which is hard to find.

We could use a modified extends relation that hides selected methods:

// Library 'dart.html'.
class Node {}

view NodeView on Node {
  NodeList get childNodes => js_util.getProperty(this, 'childNodes');
}

// Library 'my_list_library'.
import 'dart:html' as html;

view ListNodeView extends html.NodeView hide childNodes on html.Node {
  List<html.Node> get childNodes => wrap(js_util.getProperty(this, 'childNodes')); // OK.
}

This means that there is no subtype relationship between ListNodeView and NodeView. We are using the extends clause to inherit members from html.NodeView, but because we don't inherit everything, there is no subtype relationship. This means that we won't silently switch from calling ListNodeView methods to calling NodeView methods.

import 'dart:html';
import 'my_list_library';

void main() {
  Node node = ...;
  NodeView nodeView = node; // OK.
  var list1 = nodeView.childNodes;
  ListNodeView listNodeView = nodeView; // ERROR! But we could do `= nodeView as node`.
  var list2 = listNodeView.childNodes;
}

We will use static resolution of member invocations (that's basically the core property of a zero-cost abstraction), but we won't silently switch from one set of implementations to a different one, so we have to have an explicit cast to "leave" the abstraction NodeView. When we have obtained a plain Node we can freely "enter" the abstraction ListNodeView.

I expect that we would have the same trade-off with extension structs:

// library 'dart:html'
extension struct NodeES(Node node) {
  NodeList get childNodes => js_util.getProperty(node, 'childNodes');
}

// library 'my_list_library'
extension struct ListNodeES(Node node) extends html.NodeES {
  List<html.Node> get childNodes => wrap(js_util.getProperty(node, 'childNodes')); // ERROR!
}

Again, we could choose to say "No problem, go ahead and override what you want!", but I think that the semantics of doing so is error prone.

@Levi-Lesches
Copy link

The simplest possible fix is to keep the two views unrelated:

// Library 'dart.html'.
class Node {}

view NodeView on Node {
  NodeList get childNodes => js_util.getProperty(this, 'childNodes');
}

// Library 'my_list_library'.
import 'dart:html' as html;

view ListNodeView on Node {
  List<html.Node> get childNodes => wrap(js_util.getProperty(this, 'childNodes'));
}

Now both views are connected with Node, but they have nothing to do with each other. That might be perfectly OK, depending on the intended usage.

However, if NodeView contains a lot of nice methods that we'd like to reuse with ListNodeView then we will need to have a connection.

Could the "connection" then be to cast the Node to a NodeView and call the methods on there, similar to today's extensions, or am I misunderstanding views?

extension NodeView on Node {
  NodeList get childNodes => js_util.getProperty(this, 'childNodes');
  String get name => "NodeView";
}

extension ListNodeView on Node {
  List<Node> get childNodes => wrap(js_util.getProperty(this, 'childNodes'));
  void printName() => print(NodeView(this).name);
}

@eernstg
Copy link
Member

eernstg commented Aug 10, 2022

Could the "connection" then be to cast the Node to a NodeView and call
the methods on there, similar to today's extensions, or am I misunderstanding views?

I'd expect views to be used for a while with the same object. So the typical situation would be that we get hold of an object, and the static type is a specific view, and then we use that object according to that static type for a while.

NodeView someFunction(...) {...}

void main() {
  var nodeView = someFunction(...);
  // Now do 100 things with `nodeView`.
}

The point is that the view is a complete abstraction (like a class), so we should be satisfied with the affordances offered by that view, that is, we should be able to do what's typically done with such objects without ever considering which type is the on-type of the view.

However, we need to get started, e.g., to obtain a reference of type NodeView with a given Node. When the view is open or plain (no keyword before view) then we can just assign: NodeView nodeView = node;. When the view is closed, we must call a "constructor", ClosedNodeView closedNodeView = ClosedNodeView(node);. That's how we "enter" the abstraction.

If the abstraction isn't sufficient to handle the given task we may need to "exit" from the abstraction again, that is, we may need to get a reference to the underlying object using the on-type. If the view is open then we can directly assign it: Node otherNodeVariable = nodeView;. If it is a plain view then we must cast Node otherNodeVariable = nodeView as Node; (this is a downcast, and it is guaranteed to succeed). If it is a closed view then we must also cast (and that's a cast from one type to a completely unrelated type, which could trigger a lint, if we want to flag this situation as dangerous). In any case, that's how we "exit" the abstraction and go back to the underlying representation type, the 'on-type'.

So I wouldn't expect casting into and out of a view type (or an extension struct type) to be very common, it's basically a violation of the soft notion of encapsulation that the view / extension struct provides.

If you are more happy about a perspective where the underlying object keeps its own type (so there's no encapsulation) and the extension/view members are just added to the existing members, then it's probably better to use an extension (the ones we have today). Extension members are always available, so you could say that you are entering and exiting the abstraction all the time, as needed (except that there is no abstraction, because the type of the underlying object is present all the time, and members of that interface will always prevail if a given member can be obtained from the underlying type as well as from the extension).

@rileyporter
Copy link

Thanks for the input! I do think views are what we want, but maybe extension structs would allow for enough of the same functionality.

I think the closest to what we'd want is your example with the view syntax and hiding any overridden members to avoid silently using the subtype view members. I agree silently calling the "overridden" members could be a cause of tricky bugs.

One tweak though, I think we'd want the NodeView to just be named Node on JSObject, so users could keep using the dart:html Node type as the abstraction. Would that still allow users to cast from a variable of type Node to ListNodeView, or would we have to go through the on type of Node, which would be JSObect?

For example:

// Library 'dart.html'.

view Node on JSObject {
  NodeList get childNodes => js_util.getProperty(this, 'childNodes');
}

// Library 'my_list_library'.
import 'dart:html' as html;

view ListNodeView extends html.Node hide childNodes on JSObject {
  List<html.Node> get childNodes => wrap(js_util.getProperty(this, 'childNodes'));
}

And the usage would be:

import 'dart:html';
import 'my_list_library';

void main() {
  Node node = ...; // likely some other JSObject view API like getRootNode()
  var list1 = node.childNodes; // OK, type of list1 is NodeList
  ListNodeView listNodes1 = node;  // ERROR?
  ListNodeView listNodes2 = node as JSObject; // OK
  var list2 = listNodes2.childNodes; // OK, type of list2 is List<Node>
  ListNodeView listNodes3 = node as ListNodeView; // Could we make this be OK?
  var list3 = listNodes3.childNodes; // OK, type of list3 is List<Node>

  var list4 = (node as JSObject).childNodes; // Error? What would the type of list4 be?
}

Also, I could see extends html.Node hide childNodes getting verbose if there are many members to hide. Do we want users to explicitly have to hide "overridden" members, or could we automatically include any members with the same name? I don't have a strong preference if we want to ensure that users are explicit in which members are hidden. I would expect if users have to hide a member, we would make it a static error if there is a name collision in a view that extends another view.

@eernstg
Copy link
Member

eernstg commented Aug 11, 2022

I think we'd want the NodeView to just be named Node on JSObject, so users
could keep using the 'dart:html' Node type as the abstraction.

Yes, the views should actually be declared as you mention. That's also how it was done in discussions we've had about JS interop.

Would that still allow users to cast from a variable of type Node to ListNodeView,
or would we have to go through the on type of Node, which would be JSObect?

The cast from Node to ListNodeView is allowed (and there is no further action taken at run time, this is just the same underlying JSObject which is viewed from a different angle).

However, like all casts, it is a forced move for the compiler, because we can cast whatever we want to whatever we want (e.g., 1 as String). It is still safe in this case, though, because the requirement that we have to enforce in order to ensure sound semantics is that the underlying representation of the Node (which is a JSObject) is also an appropriate representation for ListNodeView (which requires JSObject). That's OK, because JSObject <: JSObject.

Given that the run-time representation of a view / extension struct type is the underlying representation type, this requirement will be enforced at run time, no matter which cast we're using.

import 'dart:html';
import 'my_list_library';

void main() {
  Node node = ...;
  var list1 = node.childNodes; // OK, type is `NodeList`.
  ListNodeView listNodes1 = node;  // Compile-time error.
  ListNodeView listNodes2 = node as JSObject; // OK.
  var list2 = listNodes2.childNodes; // OK, type is `List<Node>`.

  // This is probably the most direct and natural approach.
  ListNodeView listNodes3 = node as ListNodeView; // OK.
  var list3 = listNodes3.childNodes; // OK, type is `List<Node>`.

  var list4 = (node as JSObject).childNodes; // Compile-time error.
}

I think the cast from Node to ListNodeView is the most natural approach. This is because it maintains the abstractions: we'd want to think about this particular JSObject as a specific kind of JS object (one that can appropriately be viewed as a node, and also as a list node), and that perspective is expressed by the Node view and by the ListNodeView view.

In contrast, the cast from Node to JSObject reveals the underlying representation. There is no difference at run time, but conceptually we would be "unwrapping" the Node to get the JSObject, and then we'd "wrap" it again as a ListNodeView.

In this particular case we do not have the subtype relationship ListNodeView <: Node, because of the hide clause and the dangers of fake overriding. So we can't even perform the assignment in the other direction without a cast.

However, if we are able to avoid the overriding conflict and just describe BaseView with a set of members and DerivedView that adds more members (and doesn't override any), then we can have the subtype relationship DerivedView <: BaseView, and then we can work on the underlying object using those two types without any casts.

class SomeType {}
class SomeSubtype implements SomeType {}

view BaseView on SomeType {
  void base() {...}
}

view DerivedView extends BaseView on SomeSubtype {
  void derived() {...}
}

void f(BaseView baseView) => baseView.base();

void main() {
  DerivedView derivedView = SomeSubtype();
  derivedView.derived();
  f(derivedView); // OK.
}

@eernstg
Copy link
Member

eernstg commented Aug 11, 2022

Also, I could see extends html.Node hide childNodes getting verbose if there
are many members to hide. Do we want users to explicitly have to hide "overridden"
members, or could we automatically include any members with the same name?

True, that could yield a verbose declaration. The reason why I proposed that every non-inherited member should be mentioned separately is that this allows views to be optimized for code reuse.

Classes are optimized for conceptual modelling and encapsulation, and it's crucial that a subclass is a subtype, and subtypes admit substitutability, such that we can provide an instance of a subtype S whenever an instance of T is requested and S <: T. But views are inherently static, and this means that we never encounter a subtype of the statically known entity. In other words, substitutability isn't relevant.

So I aimed for optimal freedom in the management of sets of method implementations, i.e., code reuse. This is also the reason why views support multiple superviews in the extends clause. You can pick and choose the methods from the entire world (of view methods, whose on-type is a supertype). On the other hand, every conflict has to be handled directly and explicitly. If you attempt to inherit two members named m then you have to choose.

We could come up with some mechanisms to make the syntax less verbose. E.g., we could use hide * to say "hide whatever you have to hide in order to avoid conflicts".

@leafpetersen
Copy link
Member Author

@rileyporter

For example, returning a List<Node> instead of the DOM type NodeList (something we thought we might be able to do with having views extend other views) would have looked something like:

library 'dart:html';

class Node {}

extension NodeExtension on Node {
  NodeList get childNodes => js_util.getProperty(this, 'childNodes');
}
library 'my_list_library';
import 'dart:html' as html;

// Doesn't work, the `on` type needs to be Node and have NodeExtension.childNodes hidden
extension ListNodeExtension on html.NodeExtension {
  List<html.Node> get childNodes => wrap(js_util.getProperty(this, 'childNodes'));
}

The behavior we'd hoped for was when my_list_library is imported, any access to childNodes on a Node object would give the List<Node> version, and everything else not overridden from NodeExtension would still be accessible.

I'm a little confused. I do not believe that any of the proposals we have out there give you this behavior. Not views, not extension structs. And not really extension methods either.

1 similar comment
@leafpetersen
Copy link
Member Author

@rileyporter

For example, returning a List<Node> instead of the DOM type NodeList (something we thought we might be able to do with having views extend other views) would have looked something like:

library 'dart:html';

class Node {}

extension NodeExtension on Node {
  NodeList get childNodes => js_util.getProperty(this, 'childNodes');
}
library 'my_list_library';
import 'dart:html' as html;

// Doesn't work, the `on` type needs to be Node and have NodeExtension.childNodes hidden
extension ListNodeExtension on html.NodeExtension {
  List<html.Node> get childNodes => wrap(js_util.getProperty(this, 'childNodes'));
}

The behavior we'd hoped for was when my_list_library is imported, any access to childNodes on a Node object would give the List<Node> version, and everything else not overridden from NodeExtension would still be accessible.

I'm a little confused. I do not believe that any of the proposals we have out there give you this behavior. Not views, not extension structs. And not really extension methods either.

@rileyporter
Copy link

@leafpetersen you're right, I don't think any of the proposals really provide the behavior to override specific members on a type.

I think how we'd get close to this right now with extension methods is redefining the class we want to "override" behavior in:

library 'dart:html_basic';

class Node {}

extension NodeExtension on Node {
  NodeList get childNodes => js_util.getProperty(this, 'childNodes');
}
library 'dart:html_full';

class Node {}

extension ListNodeExtension on Node {
  List<Node> get childNodes => wrap(js_util.getProperty(this, 'childNodes'));
}

The user code can import one or the other library and refer to just Node to get the different behavior:

import 'dart:html_basic'; 

Node n = ...;
n.childNodes; // returns a NodeList
import 'dart:html_full';

Node n = ...;
n.childNodes; // returns a List<Node>

The way we'd make this work for dart:html coverage is something like:

library 'dart:html_full';

import 'dart:html_basic' as html_basic;
export 'dart:html_basic' hide Node; // provide the rest of the DOM API for dart:html

class Node {}

extension ListNodeExtension on Node {
  List<Node> get childNodes => wrap(js_util.getProperty(this, 'childNodes'));
  // re-implement all other html_basic.Node members, possibly with forwarding to the basic definition
  void appendChild(Node n) => html_basic.Node.appendChild(n);
}

That kind of gives us "overriding" behavior, since we're replacing the functionality of Node.childNodes, but it depends on which version of the library you import and you can't import both without namespacing them to specify which Node you mean.

And if we don't want to override behavior, we can implement the html_basic version of the class to get the extension methods defined in that library for free:

class Foo implements html_basic.Foo {}

extension FooExt on Foo {
  // get all of html_basic.Foo extension members for free
  void someNewBehavior() => ...;
}

@eernstg
Copy link
Member

eernstg commented Aug 12, 2022

I think I'll dive one step deeper on this one! 😄

@rileyporter wrote:

For example, returning a List<Node> instead of the DOM type NodeList (something we thought we might be able to do with having views extend other views) would have looked something like:

library 'dart:html';

class Node {}

extension NodeExtension on Node {
  NodeList get childNodes => js_util.getProperty(this, 'childNodes');
}
library 'my_list_library';
import 'dart:html' as html;

// Doesn't work, the `on` type needs to be Node and have NodeExtension.childNodes hidden
extension ListNodeExtension on html.NodeExtension {
  List<html.Node> get childNodes => wrap(js_util.getProperty(this, 'childNodes'));
}

Let me try to understand the situation. I can see that html.NodeExtension is used as the on-type of ListNodeExtension, and 'Doesn't work' implies that we can't do that (but we might change something to enable it).

I'm not 100% sure how to understand the semantics of these enhanced extensions, but I think we can simplify the situation by going back to the JS interop approach that I mentioned earlier (that is, we use on JSObject):

// ----- Library 'dart.html'.

view Node on JSObject {
  NodeList get childNodes => js_util.getProperty(this, 'childNodes');
  Node? get firstChild => js_util.getProperty(this, 'firstChild');
}

// ----- Library 'my_list_library'.

import 'dart:html' as html;
export 'dart:html' hide Node;

view Node extends html.Node hide childNodes on JSObject {
  List<html.Node> get childNodes => wrap(js_util.getProperty(this, 'childNodes'));
}

This looks promising. In particular, we have the following properties:

An importer of 'my_list_library' receives a definition of the type Node where all the members of the Node from 'dart:html' are present, with the same implementation, except that childNodes has a different signature and a different implementation, such that it yields a List<Node> rather than a NodeList.

We're getting close to this:

The behavior we'd hoped for was when my_list_library is imported, any access to childNodes on a Node object would give the List<Node> version, and everything else not overridden from NodeExtension would still be accessible.

However, it is still fundamentally broken: We already get a hint in the signature of childNodes: It returns a List<html.Node>. So if we start working on those children then we'll get the NodeList version of their childNodes.

We could change the return type to be List<Node> (which would resolve to the enclosing view, that is, the one that returns List<Node>). But that wouldn't update all the other locations where a declaration in 'dart:html' refers to Node (and that means html.Node in the context of 'my_list_library').

So we're really looking at a situation where we wish to change the meaning of one entity declared together with a rather large set of other entities (and there is a lot of mutual recursion). This requires all of them to be updated simultaneously.

(Mumbo jumbo: If we consider a library to be similar to a class and introduce virtual classes as members of libraries, then we can "update" a set of mutually recursive classes by redefining just the one(s) we actually want to change, and then all the others will be re-wired to use the updated declarations. But we won't do that with Dart, because that's a completely radical change of the language.)

The only realistic approach I can see for that is to keep the two sets of classes completely separate, and duplicate all the declarations that have any elements of mutual recursion (and that's probably every single declaration except things like length that returns an int and hence doesn't depend on anything in 'dart:html').

As an alternative, we could consider changing extension declarations such that they are allowed to "override" members of the on-type. Currently, an instance member will always prevail over an extension member unless it is invoked explicitly on the extension:

class C { void foo() { print('C.foo!'); } }
extension E on C {
  int get foo => 42;
}

void main() {
  var c = C();
  c.foo(); // OK, no name clash, prints 'C.foo!'.
  var i = E(c).foo; // OK, we can force the use of the extension member.
}

If we allow an extension declaration to have higher priority than an instance member with the same name (surely we won't do that, but let's just consider that possibility) then we could force all invocations of childNodes on a receiver of type Node to call the implementation in 'my_list_library' and obtain a List<Node>.

Then we'd have the same 'dart:html' as shown above, plus this 'my_list_library':

// ----- Library 'my_list_library'.
import 'dart:html';

// Assume that we add support for `override` on extension members.
extension ChildNodesIsList on Node {
  override List<Node> get childNodes => wrap(js_util.getProperty(this, 'childNodes'));
}

However, that implementation (and signature) of Node.childNodes would only take effect in a context where ChildNodesIsList is in scope, and that makes it harder to read the code: If you're looking at a given snippet of code then you would have to check the imports in order to see whether myNode.childNodes resolves to the regular definition of childNodes in 'dart:html' or it resolves to the declaration in 'my_list_library'. In that case we might as well just use a different name like childNodeList.

@rileyporter
Copy link

If we were to do something like you hypothesized with override for extension members, then I think this:

However, that implementation (and signature) of Node.childNodes would only take effect in a context where ChildNodesIsList is in scope, and that makes it harder to read the code: If you're looking at a given snippet of code then you would have to check the imports in order to see whether myNode.childNodes resolves to the regular definition of childNodes in 'dart:html' or it resolves to the declaration in 'my_list_library'.

becomes a larger problem. If you have another library third_party_library that uses my_list_library nodes, then you'd need to import my_list_library for it to work correctly and you'd run into strange behavior if you didn't e.g.:

library third_party_library;
import 'my_list_library';

Node n = ...;
List<Node> getNodes => n.childNodes;
import 'dart:html';
import 'third_party_library';

Node n = ...;
var list1 = n.childNodes; // type NodeList
var list2 = getNodes(); // type NodeList, but third_party_library expected to return `List<Node>`

I think what we'll end up doing for dart:html with views is shadowing any view that we want to override members in and extending any view to add extra behavior, something like this:

library dart:html;

view Node on JSObject {
  NodeList childNodes => ...;
  String name => ...;
}

view Element on JSObject {...}
library dart:html_extra;
import 'dart:html' as html;

view Node on JSObject {
  List<Node> childNodes => ...;
  String name => html.Node.name;
}

view Element extends html.Element on JSObject {
  // get html.Element definitions through extension
  void addExtraBehavior() => ...;
}

User code will either get the dart:html or dart:html_extra definitions, depending on which they import. I think that solves the third party library problem too:

library third_party_library;
import 'dart:html_extra';

Node n = ...; // html_extra.Node
List<Node> getNodes => n.childNodes; // List<html_extra.Node>
import 'dart:html';
import 'third_party_library';

Node n = ...; // html.Node
var list1 = n.childNodes; // html.NodeList
var list2 = getChildNodes(); // List<html_extra.Node>

Is that correct, or am I misunderstood on how views would allow this?

@eernstg
Copy link
Member

eernstg commented Sep 22, 2022

Note that the recently updated view class proposal does allow for one view method to override another one.

As I mentioned here, this relation is very different from an object-oriented method override, because the choice about which implementation to execute is made based on the static types.

However, there is one safe step that will make the distinction: Control-click (or whatever it is that yields jump-to-declaration for a method call with the given tools) and check whether the enclosing declaration is a class or a mixin.

If so (then it's a regular instance method) and you have just looked up the statically known declaration; the one that actually runs is the most specific override of that declaration in the run-time type of the receiver. If it's not (then it's an extension method or a view class method) and you have just found exactly the code which will be executed at run time.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
data-classes extension-types inline-classes Cf. language/accepted/future-releases/inline-classes/feature-specification.md structs
Projects
None yet
Development

No branches or pull requests

7 participants