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

"includes" instead of "implements" for view composition #2547

Closed
munificent opened this issue Oct 1, 2022 · 24 comments
Closed

"includes" instead of "implements" for view composition #2547

munificent opened this issue Oct 1, 2022 · 24 comments
Labels
extension-types inline-classes Cf. language/accepted/future-releases/inline-classes/feature-specification.md

Comments

@munificent
Copy link
Member

munificent commented Oct 1, 2022

The views proposal uses implements to allow one view type V1 to build off other view types V2, etc. As I understand it, this clause does two things:

  1. All of the instance members defined by V2 are made accessible as instance members on V1. By "instance" here, I mean that the constructors and static members of V2 are not made available. The dispatch is still purely static. It's almost as if the instance declarations in V2 have been textually copied to V1.

  2. V1 becomes a subtype of V2. This allows establishing a subtype hierarchy among a family of related view types who all share a related representation type.

This behavior seems totally fine to me. However, I find implements a confusing word to express that, for a couple of reasons:

  • In classes, implements specifically means that you do not inherit any implementation. Of course, with views, there is no real "inheritance" going on, it's just that the subtyping induced allows the members on V2 to be available as extension members on V1. But informally, it feels very strange that an implements clause gives you implemented functionality.

  • In classes, implements adds a requirement to the class. When you add an implements, your class now has an obligation to define or inherit implementations of all of those members. Adding implements can require you to do work in your class declaration. With views, implements gives you stuff "for free" and doesn't require you to add anything (except possibly redeclaring to avoid collisions).

  • In classes, and in other languages, implements implies some level of runtime polymorphism and dynamic or virtual dispatch. It suggests that the thing being implemented is an interface and the class can now be used wherever that interface is allowed, and its instance members are now polymorphic implementations of that protocol. None of that applies to views where all dispatch is entirely static.

In short, it feels like a confusing keyword choice to me. Instead, I propose includes:

view class V1(int i) {
  void v1() {}
}

view class V2(int i) {
  void v2() {}
}

view class V3 includes V1, V2 {}

I think that word conveys that all of the functionality defined by the superviews is now available by the subview.

It might be confusing that a new word includes also induces a subtype relation. But I believe we already have that in Dart and it doesn't seem to cause much confusion in practice. The keywords extends, implements, and with all also mean that the class containing that clause becomes a subtype of the type in the clause. So this would just be one more keyword where that's true.

I also think the subtyping aspect is less important than the code reuse aspect. When users are reading a program, they mostly want to know where the code that gets called is and how it is reached. Using includes suggests that "Ah, this method I can see being called on V3 must have been included from V1".

@munificent munificent added the inline-classes Cf. language/accepted/future-releases/inline-classes/feature-specification.md label Oct 1, 2022
@lrhn
Copy link
Member

lrhn commented Oct 2, 2022

I don't like includes for two reasons:

  1. It's new. We'd have to tell people what it means from scratch.
  2. It's incredibly generic. There is probably no hierarchy based relation which can't be imprecisely described by "includes" (in one direction or the other). I'd rather use alsoviews. For all its other brokenness, it's at least precise.

The benefit of implements is that if we introduce non-virtual methods on classes (and primary constructors),

view class Foo(int x) {
  int get foo => 42;
}
view class Bar(int x) implements Foo {
  int get bar => 37;
}

behaves equivalently to:

class Foo(int x) {
  nonvirtual int get foo => 42;
}
class Bar(int x) implements Foo {
  nonvirtual int get bar => 37;
}

wrt. resolution of non-virtual methods. A view is just a class-like thing where methods are non-virtual by default.

What I don't like is that implements is universally used for interfaces, and views do not introduce interfaces.

What I'd like about extends instead is that it describes the intent.

view class Foo(int _x) {
  int get foo => 42;
}
view class Bar(int _x) extends Foo {
  int get bar => 37;
}

suggests that the Bar members extend the Foo members and the Bar view is a specialization of Foo.

The corresponding classes-with-nonvirtual-members would be:

class Foo(int _x) {
  nonvirtual int get foo => 42;
}
class Bar(super. _x) extends Foo {
  nonvirtual int get bar => 37;
}

It includes all super-class/view member implementations. For views, that's only the non-virtual ones, but it's still correct.

The issue is that you can extend more than one view.
The benefit of using extends for that is that we can reasonably make the source order of the super-views matter for member shadowing, which would feel odd for implements.

Heck, we can allow all of the modifiers both:

view class Foo extends Bar, Baz with Qux implements Twix {}

Then you can use whatever you like, it's all the same for non-virtual methods.

@munificent
Copy link
Member Author

What I don't like is that implements is universally used for interfaces, and views do not introduce interfaces.

Perhaps another way to think about is that the verb here tells you something about what kinds of declarations are allowed there. In Dart today:

  • The thing after implements must be (or expose) an interface. Assuming we add type modifiers at some point and allow non-interface class declarations, you won't be able to put one of those after implements.
  • The thing after extends must be a class. If it's in another library, it must have a public generative constructor. Again, if we add type modifiers, it must be an open class.
  • The thing after with must be a mixin. If we eventually restrict it so that classes stop exposing mixins, then it will have to be an actual mixin declaration.

So the word tells you what you can do next. From that angle, implements is a clearly bad choice, because the thing after implements can't be a type that exposes an interface and must be a view class—currently the only kind of type in the language that doesn't have an interface.

@leafpetersen
Copy link
Member

leafpetersen commented Oct 4, 2022

This is going to be a pretty hard sell. The word "include" is probably one of the most ingrained terms in programming: 50 years or so of the C pre-processor has pretty well ingrained the notion that if you "include" something you are basically copying it in. It's really hard for me to believe that using "include" here is going to give users the right intuition.

In general, I'm really uncomfortable with using class syntax, but with non-standard affordances. We could talk about using extends and with instead of implements. Or I guess we could try to find an interpretation of include that works for normal classes as well? But if we feel we're going to add non-standard syntax here, then that pushes me pretty strongly towards not re-using the class syntax. But then we end up re-inventing 90% of the class syntax just so we can avoid using one piece of it. And I don't find that very appealing either.

From that angle, implements is a clearly bad choice, because the thing after implements can't be a type that exposes an interface and must be a view class—currently the only kind of type in the language that doesn't have an interface.

Well, there are lots of types that don't have an interface (e.g. function types), and we're about to release more (record types). I don't really see the problem here, but if it bothers you it's completely straightforward to allow class V(SomeType self) implements Foo where Foo is not a view type. We just need the requirement that Foo <: SomeType. It's true that view classes don't expose an interface. I actually have some ideas about how we could make that work, but I don't really think they're worth pursuing.

Perhaps another way to think about is that the verb here tells you something about what kinds of declarations are allowed there.

This may be necessary, but it is not sufficient. If you extend something and you are concrete, it's not enough for it to be a class: it must be a class with a generative constructor. Similarly for other things: there are requirements on the things that you extend, implement, or mixin. One of the requirements here is that any interfaces you implement must be view class interfaces. I'm not sure I find that all that strange. In fact, in the underlying semantic model, it's absolutely well-justified.

I think the main point of concern that I share here is that it is surprising to see members in a view class that do not conform to the signatures in the view class super-interface. Requiring the word extension before the member would make this much clearer, but would be verbose. Perhaps this is an argument for extension class as the naming. Another option would be to explicitly split these out as sticky extensions that are applied from outside the class (but which must be co-located in the same library). Then the semantics become much clearer.

view class V1(int i) {}
class extension on V1 {
  void v1() {}
}

view class V2(int i) {}
class extension on V2 {
  void v2() {}
}

view class V3 implements V1, V2 {}

I don't object to this entirely, and it is in many ways more general: you could perfectly well use these sticky extension on things other than view types, which is nice.

@lrhn
Copy link
Member

lrhn commented Oct 4, 2022

The way I read extends, with and implements is very much:

  • with: Mixes in a mixin.
  • implements: Implements an interface, becomes the type.
  • extends: Specialize the thing above.

I have pretty fixed expectations of the types after with and implements, but curiously not about what comes after extends - it must just be something of the same kind as the current declaration.
I have been prepared to add extends to mixins, to create composite mixins, since we introduced the mixin declaration.

A class extends a class, a mixin extends a mixin, and a view would then extend a view (or more than one, because multiple inheritance is not a problem).

I guess it could be argued that implements works for interfaces of the same kind too, and view interfaces are different from class interfaces. You still inherit all the member signatures and becomes a subtype, so it's a really good match. You then also get all the implementations, because that's how extension/non-virtual methods work, they attach to the type and work through subtyping.

The one thing that makes me prefer extends over implements is really that implements is unordered, but we can choose to make extends ordered, to define a chain like we normally do with extends clauses, which resolves the inherited member conflicts (last one wins) in a user-configurable way.

@eernstg
Copy link
Member

eernstg commented Oct 4, 2022

Here is an attempt to gather the main points which have been made in this thread.

I added one more option: Use with (not extends V1 with V2, V3, just with V1, V2, V3). The point is that extends V1 with V2, V3 seems to imply that V1 is one kind of thing and V2 and V3 are of a different kind, and that's misleading; with V1, V2, V3 suggests that they are all the same kind of thing.

Subtyping

Keyword Reason why it's consistent/good Reason why it's not
includes New word for new semantics Unfamiliar, might not suggest any subtype relationship
implements The declared view is a subtype of implemented views, as expected
extends The declared view is a subtype of extended views, as expected Surprising that we can extend >1 view
extends, with The declared view is a subtype of extended and mixed-in views, as expected Surprising that operands have different syntactic positions when they are all views
with Subtype of mixed-in types, as expected

"Inherited" member implementation availability

Keyword Reason why it's consistent/good Reason why it's not
includes Suggests textual copy, so it's natural that we get the implementation Textual copy is an imprecise model
implements Same semantics as nonvirtual and extension methods: always applicable to subtype (but there may be some more specific declarations with the same name) implements elsewhere does not provide an implementation, we have to write it or get it from somewhere else
extends Implementation is inherited, as expected "inherited" is an imprecise model (static resolution)
extends, with Implementation is inherited, as expected "inherited" is an imprecise model (static resolution)
with Implementation is mixed in, as expected "mixed in" is an imprecise model (static resolution)

Member declaration conflict resolution

Keyword Reason why it's consistent/good Reason why it's not
includes Redeclare conflicting member, which will cancel the 'textual copy'
implements Same semantics as nonvirtual and extension methods (most specific applicable declaration wins) Non-standard: Classes don't have implementation conflicts due to implements
extends Redeclare to resolve conflict, or report conflict at call sites Non-standard: Classes don't have conflicts in extends
extends, with Linearization resolves conflicts What if we want selected members, and no linearization works?
with Linearization resolves conflicts What if we want selected members, and no linearization works?

Correct overriding

Developers may very well expect override constraints to be enforced when a view method is declared in a superview and is also redeclared in a subview. We could enforce this, but we do not have to do it. The current proposal allows an arbitrary relationship (including: a view can declare a getter foo and a subview can redeclare foo as a method).

Keyword Reason why it's consistent/good Reason why it's not
includes A textual copy does not suggest any constraints
implements Same semantics as nonvirtual and extension methods: no constraints Non-standard: In a class you have to override members from implements correctly
extends Redeclare to resolve conflict, or report conflict at call sites Non-standard: In a class you have to override members from extends correctly
extends, with (Linearization resolves conflicts) Non-standard: In a class you have to override members from extends and with correctly, but the views here do not have that constraint
with (Linearization resolves conflicts) Non-standard: In a class you have to override members from with correctly, but the views here do not have that constraint

Run-time polymorphism

The subtyping relationship among views allows for run-time polymorphism in a way that appears to be very similar to the run-time polymorphism which is supported for objects that are accessed using class types as their static type:

view class V0(num n) {...}
view class V1(int i) {...}

void main() {
  V1 v1 = V1(42);
  V0 v0 = v1; // OK.
  v1 = v0 as V1; // `v0 as int` at run time.
}

The invocation of members is resolved statically, but that doesn't change the fact that we can have a reference of type V1 to a given object, and a reference of type V0 to the same object, and the former is a proper subtype of the latter. In other words, we do have run-time polymorphism.

Keyword Reason why it's consistent/good Reason why it's not
includes Unfamiliar, might not suggest any subtype relationship
implements Run-time polymorphism exists, as expected
extends Run-time polymorphism exists, as expected
extends, with Run-time polymorphism exists, as expected
with Run-time polymorphism exists, as expected

@lrhn
Copy link
Member

lrhn commented Oct 4, 2022

Good summary. (In case it was not clear, my vote is for extends + linearization of multiple super-views).

@leafpetersen
Copy link
Member

I added one more option: Use with (not extends V1 with V2, V3, just with V1, V2, V3). The point is that extends V1 with V2, V3 seems to imply that V1 is one kind of thing and V2 and V3 are of a different kind, and that's misleading; with V1, V2, V3 suggests that they are all the same kind of thing.

This is a good point.

@munificent
Copy link
Member Author

I'm not in love with with or extends, but I would prefer either of those (slight preference towards extends) over implements.

Have we considered is? Possibly confusing since that may sound more like the representation type instead of the supertypes.

@leafpetersen
Copy link
Member

Have we considered is? Possibly confusing since that may sound more like the representation type instead of the supertypes.

The perspective that I keep coming back to is the perspective of the reader of the code. If I go to definition on a view class, and I see:

view class V1(int i) implements V2 {
  int foo() => ....
}

then if I just make the obvious assumptions, I will draw the following working conclusions:

  • V1 is a subtype of `V2
  • V1 has a foo method with the signature listed above
  • V1 has all of the methods of `V2

Those are all, to a first approximation, correct. It's true that V2 may have a different signature for foo, which is different, but that's second order. Similarly, the dispatch is static, no wrapper object, etc. But again, largely second order.

The same is true for with. Any of the obvious assumptions that I make, will be true.

Whereas if I go to definition and see is or includes or something else, which is new, then I don't know what I'm reading. I have to learn something new.

So from the reader's perspective, it seems to me to be universally better to use implements or with.

The objection (which is fair) is that from the writer's perspective, implements and with behave a bit differently. You can only implement other view classes, and you don't get override signature checking, etc. But in general I'm inclined to greatly prioritize the reader of the code here over the writer of the code. That at least has been the perspective that I've been coming from.

@munificent
Copy link
Member Author

  • V1 has all of the methods of V2

If I see a class that says class V1 implements V2 { ... } I expect to see all of the methods declared by V2 implemented in V1. If the methods aren't in V1, I'd be confused because... where the hell are they? They aren't coming from V2, because I'm not extending V2. Using implements specifically means "do not inherit any implementation from this class".

If I knew that V2 declared a foo() and I didn't see a foo() in V1, it would look like a broken class declaration to me. How is V1 possibly implementing V2 without declaring foo()?

@leafpetersen
Copy link
Member

If I knew that V2 declared a foo() and I didn't see a foo() in V1, it would look like a broken class declaration to me. How is V1 possibly implementing V2 without declaring foo()?

Yes, this is probably a good argument for using with. Would that alleviate your concern here? That said....

If I see a class that says class V1 implements V2 { ... } I expect to see all of the methods declared by V2 implemented in V1. If the methods aren't in V1, I'd be confused because... where the hell are they?

Here is a class declaration that our users work with every day. That class definition claims (indirectly) to implement Iterable, and yet there are no definitions of those methods implemented anywhere in its body or its superclass hierarchy.

Are you confused by it? Do you think our users are confused? I mean, it is a bit confusing, if you dive into it. But mostly, you don't care. Most of the time, you're just going to say "oh, this class implements Iterable<int>, so in some way it has the right methods, who cares how". It's pretty rare that you actually care where the methods come from. And in fact, they may not come from anywhere - so if you really want to understand it, you need to understand that an abstract class doesn't have to provide implementations of the things that it implements, and that the actual implementation will be provided by some concrete class which implements the abstract class, yadda, yadda, yadda. And likewise, if you really want to understand where the V2 methods come from, you need to understand that a view class provides extension methods, which are attached to the type, and so the methods come along with the super-interface, yadda, yadda, yadda.

Now it's definitely true that the interface/implementation split is pretty standard now, and people have internalized it in a way that they won't have for something new like a view class. So I certainly don't claim that this is a non-issue. But I do question whether this is something that's actually going to be a pain point for users in practice?

@eernstg
Copy link
Member

eernstg commented Oct 5, 2022

One area where pre-existing mental models may be helpful for developers who are getting acquainted with views is conflict resolution:

  • implements on a class can give rise to member signature conflicts (two different member signatures with the same name), and this kind of conflict is in general resolved by overriding the given member (if it's possible to resolve the conflict). There is no notion of ordering or priority.
  • extends ... with ... on a class resolves every conflict by linearization (if it can be resolved).

So we might want to link these properties to the handling of conflicts with views: If every conflict must be resolved by redeclaring the conflicted name then we might want to use implements; if conflicts should be resolved silently by linearization then we might want to use extends ... with ... or with ....

@lrhn
Copy link
Member

lrhn commented Oct 5, 2022

One advantage of implements is that it's clear that you won't call superclass constructors. Which we won't. Unless we want to?
Can one write:

view class FooView implements BarView {
  final int x;
  FooView(this.x) : super.special(x); 
}

to explicitly invoke the superclass constructor, if I really, really want to?

Or should I just write it as:

view class FooView._(BarView x) { 
   FooView(int x) : this._(BarView.special(x));
}

(Can I have view class Foo(super.value) to avoid introducing a new name for the this as RepresentationType, if I don't want one).

Apart from that, after talking more with both @eernstg and @leafpetersen, I'd probably be satisfied with extends ... with ...,..., where extends can only take one view class, and with can take any number of views. Both extends ... and with ... are optional.
The super-views are linearized, like extends and with normally do.

It is as if view classes can be used both as view superclasses, and also as view mixins, like some classes can.

Most views will only extend one other view, in order to inherit its API, and using extends feels fine for that.

Views which combine more than one super-view will likely have some of the other views be "mixin-like" (intended to go on top of another view and add or modify a few members), and it might make sense to the author to use with for that.

You get to show a kind of intent in whether you use extends or with, even if it doesn't matter and you can just use with ... for everything.

(But not calling superclass constructors makes me worry a little about extends).

@munificent
Copy link
Member Author

Yes, this is probably a good argument for using with. Would that alleviate your concern here?

I think with could be reasonable, yes. It's already sort of vague (no one really knows how mixins are applied) but does generally mean "you get the stuff from that type here too".

That said....

If I see a class that says class V1 implements V2 { ... } I expect to see all of the methods declared by V2 implemented in V1. If the methods aren't in V1, I'd be confused because... where the hell are they?

Here is a class declaration that our users work with every day. That class definition claims (indirectly) to implement Iterable, and yet there are no definitions of those methods implemented anywhere in its body or its superclass hierarchy.

Are you confused by it? Do you think our users are confused? I mean, it is a bit confusing, if you dive into it.

It's confusing for users who don't understand abstract, yes. But for others, they can figure out that, "Ah, the methods aren't here yet because this is an incomplete class. Once I find the non-abstract class that extends this, I'll be able to find the implementations of everything."

In other words, with abstract class List implements Iterable, the List class indeed does not have any implementations of those methods on Iterable. I don't see them when I look at the class, and they are in fact not there. It's only some concrete subclass of List that will have them.

Whereas with a view class, if we use implements, I also won't see the implementation of methods. But this time they are there because they were inherited from the superinterface. That feels like an entirely different mechanism to me.

@eernstg
Copy link
Member

eernstg commented Nov 28, 2022

Closing; it's implements.

@eernstg eernstg closed this as completed Nov 28, 2022
@lrhn lrhn reopened this Jan 30, 2023
@lrhn
Copy link
Member

lrhn commented Jan 30, 2023

I'm going to take one last shot of making us reconsider the syntax. After having thought about it more, I worry that using implements will prevent us from doing something more useful in the future, and that it matches badly with the class-like declaration syntax.

TL;DR: We should use extends/with for one/multiple super-inline classes with implementation inheritance, and implements for exposing APIs supported by the representation type.

Because that's how I'd write a similar non-inline wrapper class: Extend another wrapper to get its implementation, and implement a type supported by the wrapped object by forwarding.

It's more consistent with how we write classes, and we've chosen to make inline classes look like classes, so I think we should be consistent with that.

Inline class members are not extension members, which apply to all subtypes. The current implements declaration on an inline-class makes the inherited inline class members look more like (sticky) extension members. Which isn't wrong, that could be what they are, but it's not the perspective our syntax suggests, so it can be confusing.
It's inconsistent with the class view of inline classes.
We don't sell inline class members as extension members, so we should be careful about making them act like ones.

I've gotten used to the inline class syntax now, and stopped treating it as an oddly-shaped view declaration. By looking at it as a class, the places where things don't match class declarations starts to feel off.
If users start from the class perspective, which they likely will without years of thinking about views and extension types, I think they will respond better to using extends for declaring an inline-class super-class whose members are inherited, and if you ever need more than one inline-class super-class, it's likely that some of them will be "mixin-like". (Who knows, maybe we'll get inline mixin declarations eventually.)

The reason I try to push for this now is that it opens up using implements for another other use-case, introducing non-inline-class superclasses (which must also be superclasses of the representation type) and forwarding those members to the representation object.

We have precedence for doing that: Inline classes have Object?, or Object if the representation type is non-nullable, as supertype, are assignable to that type, and inherits the members of Object. Inheriting the members mean that the member is available on the inline class type (can be invoked), and that the member invocation uses the implementation of the representation object.

We allow those invocations because the representation object is guaranteed to have the methods, but we don’t go the extra step of saying that inline class Foo (final Bar _bar;…) has all Bar methods, even though we know the representation object will support those too. We treat Object specially, which is reasonable, but we do not provide that affordance to other types even if the author wants it.

I want to allow implements T on an inline class, where T is a supertype of the representation type, to:

  • Make the inline class a subtype of T (making it assignable to T).
  • Inherit and expose the member API of T (each such member forwarding to the representation object.)

And I want to free up implements for that use.

We can allow using implements like that, and also use implements for inline class subtyping. (If implementing an inline-class type which is also a supertype of the representation type, the semantics of the two interpretations coincide.)
I just think it will be more readable if the two roles were separated:

  • extends/with: class-like subtyping and implementation inheritance among inline classes.
  • implements: The inline class implementing the interface, without inheriting it, by (implicitly) forwarding to the representation object.

Just like a normal wrapper class.

We don't need to introduce the implements feature in the first release, but I'd prefer that our initial syntax doesn't get in its way.
I'd be perfectly fine with only allowing an inline class to have a single extends type, no with and no implements, and then allow more in the future. Or allow extends and with (but then we have to argue about linearization vs. parallel inheritance again).

@Wdestroier
Copy link

One of the features I LOVE in @projectlombok is the @Delegate annotation. Mostly to create wrapper classes. A @delegate annotation could not only work with inline classes, but any class instead. Can be worth to take a look at it and possibly implement a similar feature. @jakemac53

class A extends B {}

// When B is a final class and can't be extended

class A implements B {
  @delegate
  final _b = B();
}

// Then generates

class A implements B {
  final _b = B();

  C c() => _b.c();
  D d() => _b.d();
  E e() => _b.e();
}

@eernstg
Copy link
Member

eernstg commented Jan 30, 2023

@Wdestroier wrote:

A @delegate annotation could not only work with inline classes, but any class instead.

I think this is covered pretty well by #2506.

@Wdestroier
Copy link

Awesome, @eernstg! I didn't see it before.

@eernstg
Copy link
Member

eernstg commented Jan 30, 2023

I tend to prefer using implements for all kinds of subtype relationships: extends/with may seem very natural, but I think the best mental model of inline members is that they are similar to extension methods when it comes to their invocation semantics, and extension methods are applicable to all subtypes of the specified receiver type (with extension that's the on type, with an inline class it's all supertypes in the implements list).

The really big difference comes up when we consider constructors. I don't think it should be implied that an inline class must call every constructor of the inline classes which are specified to be its supertypes. With extends/with, we'd expect to have to invoke the superclass constructor, and we'd expect that the operands of with can't have constructors at all. With implements the whole topic simply goes away, because there's never a need to call constructors of other types that are mentioned in the implements list.

I would be quite happy about having the ability to request a subtype relationship from an inline class to any given list of supertypes of the representation type, e.g.:

inline class C implements C1, C2, int {
  final int i;
  C(this.i);
  ... // Other members.
}

I would prefer to keep the addition of members to the interface of C separate (cf. #2506 again), such that it is possible to add any desired subset of members from the interface of the representation type. As @Wdestroier mentioned, this could be considered to be a kind of forwarding; however, it should be done in such a way that the forwarding semantics is faithful (so we should get the actual default values and the actual signature of the delegatee method, not just the statically known ones).

The point is that this allows us to create an inline class C with representation type T, and then we can make C assignable to T or to any supertype of T that we may wish, and on top of that we can tailor exactly which members of the interface of T we want to delegate. In particular, we can "outlaw" specific methods if we want, e.g., creating a ReadOnlyList which has all List members except add and a few others. Following #2506, we might be able to use the following syntax to get this kind of effect:

inline class C implements C1, C2, num {
  final int i;
  C(this.i);
  export i show int hide isEven;
  ... // Other members.
}

This means that we're obtaining forwarders for every member of the interface of int except isEven (in particular, we can call isOdd on a receiver of type C), and C is a subtype of num (such that we can promote List<num> to a List<C> just by having a test like myList is List<C>).

You could of course offer these features as a package and take away some of the flexibility (e.g., you could say that implements ... int implies that we always export exactly all members of the interface of int, and then we don't get to show/hide anything). With that, we wouldn't have to have separate syntax for exports/forwarding. On the other hand, I'd like to have separate syntax for exports/forwarding because I'd expect that feature to be useful with regular classes, mixins, and whatever else we might have.

@lrhn
Copy link
Member

lrhn commented Jan 30, 2023

We're going with a "like a class" approach, and with that mindset, I'm not too worried about subtypes that don't inherit supertype members.
It's not necessary for a subtype to expose the same API as the supertype (it's not necessary for classes either, as long as the supertype API is available when the instance is cast to the supertype), but it's common and expected.

I also want to allow you to forward members to the representation class without having to implement its interface, probably by declaring abstract members (which adds members to your interface without adding implementation, just like an implements clause.)

In practice, I’d expect inline classes to fall into one of three categories:

  • Reinterpretation: For example, integer seen an ID number or a length in meters. Completely replaces the API and is not assignable to int. Directly supported by not implementing int.
  • Extension: Extend an existing type with more members, say an IntegerList which is a List<int> with more members. Competes with extension members. Directly supported by implements List<int>.
  • Restriction: Remove specific members from an existing type, like SafeString with no operator[]. Probably won’t want easy assignability to a type which allows the removed members. Will have to list all the allowed members individually as abstract declarations, so inconvenient but possible.

The case which is not supported is being assignable to (a superclass of) the representation type, but not inheriting its members. That’s probably also going to be confusing even if it is technically possible.
I don't think I'd mind missing out on that, not while we are pushing the "it's a class, but inline" perspective.

If it was a completely stand-alone feature, I might think differently.

In any case, moving away from using implements for implementation inheritance now, doesn't require us to decide how we use implements in the future, it just keeps the door open. That's really the most important part.

(Also, all these complications, and extensions too, would be moot if we had Rust-style traits as interfaces. Just define an interface, give an implementation of that interface to an existing class, then cast an instance of the class to the interface, and voila, you have an "inline class". Extension members are the same, they're interface and interface implementation in one.)

@eernstg
Copy link
Member

eernstg commented Jan 30, 2023

The case which is not supported is being assignable to (a superclass of) the representation type, but not inheriting its members. That’s probably also going to be confusing even if it is technically possible.

We could introduce a default export clause if there is an implements of a type which is a supertype of the representation type and no export clause in the body: We could make it export name show I1, .. Ik;, where name is the name of the representation object and I1 .. Ik is the list of supertypes of the representation type in the implements list. This would export every member of the representation type which is also available after assignment to any of those implemented types.

(We would need to handle conflicts, of course, because Ii and Ij might implement void foo(int, [String]) and void foo(num), and we don't automatically resolve that kind of conflict elsewhere. But we would also need to handle conflicts in the case where there is no support for export clauses and we just get all members. It would probably suffice to resolve the conflict by writing a declaration in C. Any declaration with the given name would do, because we do not have a notion of correct overrides for inline classes.)

This would also be a non-breaking enhancement of the treatment of Object (that nobody has complained about for a long, long time ;-): An invocation of a member of Object on a receiver whose type is an inline type will call the instance members of the given representation object. This could now be described as a default, a mere convenience, of the form export representationName show Object. (Yes, we would need to say that the default is export representationName show Any in order to handle nullable representation types, but that's a fix that we need anyway. Any would be a class which is now denoted by Object?).

I’d expect inline classes to fall into one of three categories:

I think those categories illustrate quite nicely that we might want to express forwarding and subtyping separately: 'Reinterpretation' calls for a near-complete removal of the existing interface members (everybody has so far agreed that Object members remain); 'restriction' calls for a much more selective removal, but still a removal. Only 'extension' doesn't need removal of members, but support for such removals won't hurt that case, either—just don't remove anything!

moving away from using implements for implementation inheritance

This is the change that I do not consider to be an improvement.

I think there's a lot of truth in the explanation that inline members are similar to extension members (no polymorphism / late binding, no dynamic invocations, applicable to every receiver whose static type is a subtype), and I think the handling of object construction is very different.

Also, if you say extends or with, how would you explain that the unique final instance variable isn't inherited? So if an inline class has a "super-inline-class" then why doesn't it have at least two fields? When we maintain the subtype (not subclass) relationship, we can also claim that there is a getter for the representation object. That getter is implicitly induced, and for an inline class, for the unique instance variable name, it is of the form T get name => this as T; where T is the representation type, which will work also when applied to a receiver whose static type 'inherits' that getter.

@lrhn
Copy link
Member

lrhn commented Feb 2, 2023

I'm now going to give an argument that what we are doing now is perfectly good and reasonable.
Which proves conclusively that I don't actually care what the syntax is, as long as it is consistent and explainable, and doesn't get in the way if other features that we (well, at least I) also want.

The consistent and explainable story I'll use for using implements is:

Inline classes are static-only skin-deep types that act as wrappers around a real object, represented by a new type. Member invocations on that type are resolved statically to the members of the inline class. Subtyping and assignability is only to Object or Object? by default.

Since every member is statically resolved, there is no inheritance, no overriding.
Instead we allow the new type to designate specific supertypes, by writing, e.g., implements Supertype1, Supertype2.

Such a supertype must (currently) be another inline class type.

A supertype must be compatible with the type of the representation object of the inline class.
An inline class with representation type R is compatible with a type T if R <: T. (A value of that type would be a valid represenation object for that inline class.)

The effect of an inline type I with representation type R implementing a type T is that:

  • R is a subtype of T.
  • For every instance member of T, where R does not declare a member with the same name, R gets an implicit member with the same name, memberName, and signature as in T, which is effectively a "function alias" for (this as T).memberName. Any access to r.memberName where r has type R is statically resolved to (r as T).memberName.

If two implements types introduce the same member name, we have a conflict, and the inline class must declare a member with that name to resolve the conflict.

This is a simple explanation because it is based on the rule "a value of type R is also valid as a type T" and the inline class R is allowed to make such a cast implicit through subtyping.

(We don't event have to disallow cycles. We may want to, for practical reasons, but it's not a structural relation. Even member forwarding in both directions will work, because there won't be a member unless one of the types declares one.)

This description is also compatible with implements InterfaceType where the interface type must be a supertype of the representation type. Again, members of that interface can be forwarded to (this as InterfaceType).member which can again never fail.

(Then we can discuss extensions, like allowing show/hide on the implements types to avoid conflicts or hide members, or allow abstract declarations on the inline class, which always forward to the representation type, without needing to implement it.)

So, all in all implements for super-types, as currently defined, can work consistently and be extensible to non-inline types.
It's a way to define supertypes, without actually creating a structural relation, and implicitly define forwarding functions for members of those supertypes.

@eernstg
Copy link
Member

eernstg commented Oct 17, 2023

I'll close this issue: This proposal was not accepted.

@eernstg eernstg closed this as completed Oct 17, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
extension-types inline-classes Cf. language/accepted/future-releases/inline-classes/feature-specification.md
Projects
None yet
Development

No branches or pull requests

5 participants