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

[inline-classes] Allow an inline class to implement a non-inline class #3090

Closed
eernstg opened this issue May 20, 2023 · 16 comments
Closed

[inline-classes] Allow an inline class to implement a non-inline class #3090

eernstg opened this issue May 20, 2023 · 16 comments
Labels
extension-types inline-classes Cf. language/accepted/future-releases/inline-classes/feature-specification.md small-feature A small feature which is relatively cheap to implement.

Comments

@eernstg
Copy link
Member

eernstg commented May 20, 2023

This issue describes a proposal to allow an inline class to implement a non-inline class, as long as it is a supertype of its representation type.

As it is currently specified, the implements clause of an inline class declaration is used to establish a subtype relationship to other inline classes. This allows the given inline class to "inherit" member implementations from those other inline classes, and it establishes a subtype relationship.

inline class A(int i) {
  int get next => i + 1;
}

inline class B(int i) implements A {}

var b = B(1).next; // OK.
int i = B(2); // Error: unrelated types.

Another subtype relationship which can safely be adopted is that the inline class is a subtype of its representation type, or any supertype of the representation type. We could broaden the applicability of the implements clause to establish that kind of subtype relationship as well:

inline class A(int i) implements int {} // `A` is now a subtype of `int`.

var a = A(2);
int i = a; // OK.
List<int> list = <A>[a]; // OK.

(I'm using primary constructors for brevity, assuming something like #3023)

This is sound because the run-time value of an expression of type A is an instance of int. It may be useful in the case where there is no desire to protect an object accessed as an A against being accessed as an int, and it is even considered to be better that it will readily "become an int whenever needed" than not.

The corresponding change to the feature specification could be that the following sentence in the section Composing inline classes is adjusted:

A compile-time error occurs if V1 is a type name or a parameterized type which occurs as a superinterface in an inline class declaration DV, but V1 does not denote an inline type.

It is adjusted to end as follows:

... declaration DV, unless V1 denotes an inline type, or V1 is a supertype of the representation type of DV.

Moreover:

A compile-time error occurs if the implements clause of DV contains two or more types that are non-inline types.

We have had discussions about how to handle invocations of members of such non-inline superinterfaces. Here is one of the options that we had on the table:

If DV has a non-inline superinterface S then invocations of members of S that are not redeclared in DV are
performed as if the receiver had had the representation type of DV.

Another option is to allow DV to redeclare some of those members, and only invoke members of the non-inline superinterface if there is no such redeclaration. If we make such redeclarations an error now then we can always add this as an enhancement.


[Edit] Further considerations that were raised in discussions:

The applicability of representation type members would presumably be transitive across subtype edges:

inline class A(int i) implements int {}
inline class B(int i) implements A {}

void main() {
  B b = B(42);
  b.isEven; // OK.
}

We could (probably easily) generalize this mechanism to allow the inline class to implement a non-inline class which is two or more steps in the inline-to-representation-type chain:

inline class A(num n) {}
inline class B(A a) implements num {} // OK

This is motivated by the fact that a the value of an expression of type B will always be an instance whose run-time type is a subtype of num, and hence it is sound to make B a subtype of num, and also to invoke members of the interface of num.

@eernstg eernstg added small-feature A small feature which is relatively cheap to implement. inline-classes Cf. language/accepted/future-releases/inline-classes/feature-specification.md labels May 20, 2023
@lrhn
Copy link
Member

lrhn commented May 20, 2023

Another related feature could be that any abstract member of an inline class (that's one declared by an abstract member declaration or an implements clause) will automatically be forwarded to the representation object, at the implemented interface type, which must therefore have a compatible member signature.

Then

inline class A(int _value) implements int {}

does two things: It adds int as supertype, and it adds all int's members as abstract members (just as it would on class A implements int {} - if int could be implemented by classes).
That's also what inline class A(..) implements OtherInlineClass {} does, it copies the signatures of the OtherInlineClass, and makes those unimplemented members on A invoke the same-named method on the representation object with static type OtherInlineClass.

(And with #2967, maybe we can extend a non-inline class too, as well as implement them.)

@eernstg
Copy link
Member Author

eernstg commented May 22, 2023

any abstract member of an inline class (that's one declared by an abstract member declaration or an implements clause) will automatically be forwarded to the representation object

It would indeed be nice to have a forwarding mechanism that gives the class designer more control!

I'd like to have a forwarding mechanism which is a bit more general, though: It would be nice if we could abstract over sets of members, say, to obtain forwarding to a subset of the List interface without writing up to 63 abstract member declarations, duplicating the member signature for every one of them.

The proposal in #2506 uses an export clause to introduce forwarding members (which may be eliminated by inlining, if we have sufficient static knowledge to justify that). This mechanism would be applicable to inline classes as well as any other constructs that declare instance members, and it would be able to forward to different forwardee objects as needed, not just the representation object of an inline class.

@srujzs
Copy link

srujzs commented May 22, 2023

Thanks for creating this issue, Erik!

I've mentioned this before, but for organization purposes, the JS interop use-case would be to have inline classes implement JS types, which are classes for now. They use @staticInterop, which was our prototype way of introducing inline classes, and to avoid a breaking change, it would be nice to have other @staticInterop classes and inline classes subtype JS types. This feature would enable that, but it's not the only way. The alternative is enabling subtyping between the inline class and representation type either by default or through a keyword.

This proposal addresses the case where the representation type is a subtype of the implemented non-inline class type. I've seen some interest in the case where the representation type isn't a subtype. This was discussed before in #2727 (comment), but I'm posting again here for organization and for an extra use-case. For example, we have a NodeList interface in JS. This is a type that behaves like a JS Array, but is not. It is desirable to write a JS interop interface for NodeList that can be iterated over using for loops. In order to do that, the interface needs to implement Iterable, but the representation type is JSObject, and therefore not a subtype. Note that the representation type isn't JSArray, because NodeList isn't a JS Array, so even if we do end up implementing Iterable for JSArray, this doesn't help the NodeList case.

Of course, doing this would be unsound. Even if the inline class implements all the needed members, casting the inline type to Iterable and then calling Iterable members on it would introduce issues. I don't know if there's any way to avoid these issues, so I can see this use-case being a dead end.

@lrhn
Copy link
Member

lrhn commented May 23, 2023

Having an inline class, which is erased at runtime, implement an non-inline-class interface, which is not implemented by its representation object, is impossible for the reasons you mention.

Simply doing:

inline class C implements Iterable<int> { final NotAnIterable _ref; ...}

int? firstOrNull(Iterable<int> values) {
  var it = values.iterator;
  if (it.moveNext()) return it.current;
  return null;
}

void main() {
  C c = C(NotAnIterable());
  firstOrNull(c);
}

will pass the representation object, a NotAnIterable, into firstOrNull, with no hint on how it implements Iterable.
It won't be sound to access iterator on it, since it won't have one.

We'll need a runtime wrapper to make that work, which is something inline class is designed to not require.

A feature, different from the current inline classes, which could be a saving over having to just writing and applying the wrappers yourself, is in automatic wrapping, so you can keep a C(NotAnIterable) unwrapped/unboxed while using C members on it, but the moment you pass it to an Iterable<int> parameter, where the inline class type which provides the Iterable members gets lost, it's auto-boxed in a runtime-retained C wrapper object.
See also #498 or #1491.

(So, basically, a autoboxed class which is more like an eagerly unboxed and lazily reboxed wrapper object, with no guaranteed consistent identity. If you have something of type C, you cannot know whether it's currently boxed or not. If you assign it to something where it needs to be boxed to retain its methods, it will be. Which is basically how rust traits are implemented.)

@srujzs
Copy link

srujzs commented May 23, 2023

Yup, there needs to be some type of runtime representation to make this work, which defeats the point of "zero-cost". It's not an ask we're going to push on, so limiting this to inline classes where the representation type is a subtype of the implemented non-inline class is reasonable for us.

@lrhn FWIW, we're in the very very early stages of thinking about boxing for JS types. There are two cases:

  • When you go from a JS type (e.g. JSString) to an Object or dynamic type either explicitly or implicitly. At this point, the object will be autoboxed. This is similar to what you mention.
  • JS null and undefined. Currently, they're coerced to Dart's null, but it's desirable to have the two hierarchies completely orthogonal, so we might box them. This will be a bit more complex as it might be a big breaking change.

It's not yet clear if we need something specific from the language to enable both, but good to see that there has been some precedence!

@eernstg, to make sure we're aligned, I know we've asked for (and you've graciously responded to all of them :)):

Considering the benefit of this feature vs the work to complete it, this is probably the feature the web capabilities team would prioritize the most out of the three in the short-term. Let me know if I'm mistaken and this is something that needs a lot more iteration/work.

@eernstg
Copy link
Member Author

eernstg commented May 23, 2023

@srujzs wrote:

the JS interop use-case would be to have inline classes implement JS types, which are classes for now.

If we adopt something like #3090 (this issue) then it should not be hard to do this:

class JSObject {...}
class JSArray extends JSObject {...}

// An inline class at the top of the type graph implements `JSObject`.
inline class Element(JSObject rep) implements JSObject {...}

// Other inline classes implement `JSObject` by transitivity:
// `HtmlElement <: Element <: JSObject`, hence `HtmlElement <: JSObject`.
inline class HtmlElement(JSObject rep) implements Element {...}

// Some inline classes implement a subtype of `JSObject`, but then we just
// mention that subtype (here: `JSArray`) in the implements clause:
inline class Something(JSArray rep) implements SomeOtherThing, JSArray {...}

So, presumably, the implements JSObject tax will only be paid by a small number of declarations.

I don't know how important it is in this context, but I'm assuming that when we have inline class I implements C where C is a non-inline class, the members of C are made available on receivers of type I. We can use something like export directives to provide more fine-grained control.

the case where the representation type isn't a subtype

I agree that this is much less approachable. In particular if we want to say inline class C implements JSArray in a situation where the representation type isn't actually a subtype of JSArray then we would have to do some very magic things in order to handle this one correctly:

List<C> cs = ...;
List<JSArray> arrays = cs; // OK if `C <: JSArray`.

more concise syntax

The proposal in #2967 (inline extends inline) allows for certain abbreviations. However, similar abbreviations are already considered using different mechanisms as well:

The current proposal requires each inline class to declare exactly one final instance variable, and it is probably going to be very common that there is a constructor taking one argument. However, those things would be abbreviated substantially if something like primary constructors is accepted:

// Without primary constructors:

@JS()
inline class Element {
  final JSObject obj;
  Element.fromJS(this.obj);
  external Element();
}

@JS()
inline class DomElement implements Element {
  final JSObject obj;
  DomElement.fromJS(this.obj);
  external DomElement();
}

// With primary constructors:

@JS()
inline class Element.fromJS(JSObject obj) {
  external Element();
}

@JS()
inline class DomElement.fromJS(JSObject obj) implements Element {
  external DomElement();
}

As mentioned, if we adopt #3090 (this issue again) then the need to declare implements JSObject is reduced to the top of the subtype graph, which is presumably a very small number of declarations.

With respect to concise syntax, the obvious missing part would be that we still need to choose and write the name of the representation variable in every inline class, whereas #2967 allows the "sub-inline-class" to "inherit" it.

scoped implicit conversions for JS types

This could be addressed using something like the implicit construction proposal.

@srujzs
Copy link

srujzs commented May 23, 2023

So, presumably, the implements JSObject tax will only be paid by a small number of declarations.

For the DOM, probably. For general JS interop, we expect classes to be more shallow, so this would be less true.

I don't know how important it is in this context, but I'm assuming that when we have inline class I implements C where C is a non-inline class, the members of C are made available on receivers of type I.

For JSObject, we probably don't have a strong need for this yet, but I can see this being very useful for JSArray. If some JS type implements JSArray, it is desirable to use JSArray's methods for stuff like iteration. Do you see any potential issues where this would be hard to add to the feature?

However, those things would be abbreviated substantially if something like #3023 is accepted.

I'm not entirely sure primary constructors help out too much for interop. It's useful to have a wrapping constructor like fromJS, but if the inline class only ever uses an external constructor (package:web only uses external factories with @staticInterop for example), I'm not sure we make things much briefer. We'd go from:

@JS()
inline class DomElement implements Element {
  final JSObject obj;
  external DomElement();
}

to

@JS()
inline class DomElement.fromJS(JSObject obj) implements Element {
  external DomElement();
}

which isn't bad, and we get a wrapping constructor out of it as well, but primary constructors seem to help make the non-external case much more concise than the external case. Obviously if we have primary external constructors, that'd make this much cooler:

@JS()
external inline class DomElement.cons(JSObject obj) implements Element {}

I haven't dug into the proposal yet to see if it does or doesn't include it. It does seem like a non-trivial amount of syntax design to support.

@eernstg
Copy link
Member Author

eernstg commented May 24, 2023

@srujzs wrote:

Do you see any potential issues where this would be hard to add to the feature?

No, every inline class member invocation is resolved statically and I would expect compilers to be able to compile invocations of JSArray members on an inline type which is a subtype of JSArray in a way which is treated the same (including: it is every bit as optimized) as if the receiver type had been JSArray.

if we have primary external constructors, that'd make this much cooler

The primary constructors proposal here does not take external constructors into account, but we could easily allow external as a modifier on the primary constructor:

@JS()
inline class external DomElement() implements Element {
  final JSObject obj;
}

I assumed that the external constructor would have no parameters (as shown in some earlier examples), and I also preserved the name (DomElement rather than DomElement.cons). Those things are of course easy to change.

However, if the external constructor does not have any parameters then we'd need to declare the representation variable using a regular declaration.

Turning this around, why don't you make the addition of an external constructor an implicit consequence of having @JS() on the class? You could then use

@JS()
inline class DomElement.fromJS(JSObject obj) implements Element {}

and get both constructors as before. The ability to implicitly induce an external constructor doesn't look like too much magic to me, given that you are already using @JS() to introduce a lot of implicitly induced code, right?

Another thing you could do (but that's probably too much magic ;-) would be to implicitly use the parameter type JSObject on every @JS() inline class with a primary constructor with a parameter with no type:

@JS()
inline class DomElement.fromJS(obj) implements Element {}

@srujzs
Copy link

srujzs commented May 31, 2023

I'll leave it up to you all to determine if inline class external DomElement... is worth it. I don't mind it but I can also see arguments that the syntax is kludgy.

However, if the external constructor does not have any parameters then we'd need to declare the representation variable using a regular declaration.

I suppose one alternative is inheriting the representation type from Element, but that's dependent on #2967.

why don't you make the addition of an external constructor an implicit consequence of having @JS() on the class?

IIUC, you're asking why we don't make the default generative constructor an external one in the compilers? It's certainly possible, and that same possibility exists for JS interop classes today. I'm personally opposed to it as it's confusing that an external object is created even though the default constructor is not marked external. It's also beneficial to make the declaration explicit so users can tell what the lowering will look like.

implicitly use the parameter type JSObject on every @JS() inline class with a primary constructor with a parameter with no type

That's definitely too much magic. :) I think the representation type here is inferred by the CFE as dynamic as well unless I'm mistaken.

Going back to this issue, do we have an idea on timelines for when a proposal will be finalized to allow inline classes to implement non-inline classes?

@srujzs
Copy link

srujzs commented Jun 8, 2023

I doubt we have enough time for this to be prototype-able for 3.1, but a friendly ping on timeline @eernstg :)

@eernstg
Copy link
Member Author

eernstg commented Jun 8, 2023

@srujzs wrote:

ping on timeline

Sure! However, there is no decision yet.

@dart-lang/language-team, what's your take on this feature? Do you think we should just forget about it, do something completely different, or are you willing to move ahead aim for a feature specification?

@lrhn
Copy link
Member

lrhn commented Jun 8, 2023

I think it's a very good idea, and I don't want to launch inline classes without it.
(Where "implement non-inline class" means be assignable to, and inherit interface members, with a default implementation forwarding directly to the representation object, and therefore only allowed only if the representation type also implements the same non-inline class.)

@eernstg
Copy link
Member Author

eernstg commented Jun 8, 2023

Here is a proposal for a feature specification of this feature, expressed as an addendum to the inline class feature specification: #3138.

@Cat-sushi
Copy link

Now, what class modifiers are appropriate for int?
The current API document says that classes cannot extend, implement, or mix in int, so I guess it is a final class.

@Cat-sushi
Copy link

eernstg added a commit that referenced this issue Jul 21, 2023
Modify the inline class feature specification to specify extension types. This is a feature renaming, but also a reintroduction of a special case of the primary constructor syntax, and an enhancement with support for non-extension type superinterfaces (cf. #3090). Note that many types cannot be used as superinterfaces (including `Function` and function types, records, `T?` for any `T`); we may choose to enable a broader set of types in the future, but at this time we prefer to keep it simple. For instance, `UP` may need to be revised if we accept a broader set of types.
@eernstg
Copy link
Member Author

eernstg commented Jul 21, 2023

Closing: This subfeature has been accepted: It is part of the extension types feature specification.

@eernstg eernstg closed this as completed Jul 21, 2023
lrhn pushed a commit that referenced this issue Jul 21, 2023
Modify the inline class feature specification to specify extension types. This is a feature renaming, but also a reintroduction of a special case of the primary constructor syntax, and an enhancement with support for non-extension type superinterfaces (cf. #3090). Note that many types cannot be used as superinterfaces (including `Function` and function types, records, `T?` for any `T`); we may choose to enable a broader set of types in the future, but at this time we prefer to keep it simple. For instance, `UP` may need to be revised if we accept a broader set of types.
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 small-feature A small feature which is relatively cheap to implement.
Projects
None yet
Development

No branches or pull requests

4 participants