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] Consider allowing inline classes to extend other inline classes #2967

Closed
leafpetersen opened this issue Mar 30, 2023 · 17 comments
Labels
extension-types inline-classes Cf. language/accepted/future-releases/inline-classes/feature-specification.md

Comments

@leafpetersen
Copy link
Member

Early feedback from users of the inline class prototype has called out that for certain use cases, there is a fair bit of unnecessary boilerplate associated with the current mechanism. For our javascript interop efforts, the representation field is never accessed in the inline class, and yet every subtype of an inline class needs to repeat the same representation type and field. Example:

@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();
}

We could consider allowing an inline class to extend another inline class, thereby "inheriting" the representation object of the super class.

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

@JS()
inline class DomElement extends Element {
  DomElement.fromJS(super.obj);
  external DomElement();
}

We would presumably still allow other inline classes to be used in an implements clause. The class listed in the extends clause is distinguished as the super class from which the representation field is chosen.

cc @dart-lang/language-team @joshualitt @srujzs @sigmundch @eernstg

@leafpetersen leafpetersen added the inline-classes Cf. language/accepted/future-releases/inline-classes/feature-specification.md label Mar 30, 2023
@lrhn
Copy link
Member

lrhn commented Mar 30, 2023

I am still not convinced that making inline classes look like classes it the best choice. Because they're not classes, they just look like it. Having to declare the representation object as an instance field feels like a loss of abstraction, because it then affects the interface. You have to make the representation value variable private to avoid it affecting the public interface.
And having to use extends to avoid having to declare two instance variables is only necessary because we have to declare one instance variable to begin with.
For example, the examples here should probably not have a public obj field.

I'd much prefer a primary-constructor-like syntax which does not introduce a "field", like:

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

where obj is just an identifier available inside the inline class, the same as type parameters of a class.

Every inline class would have precisely one primary constructor-like invocation, all further constructor must be factories.
That makes it extremely clear what's going on. And will possibly work well together with pattern parameters to
give access to parts of the representation object.

If that won't fly, sure, let's allow extends to avoid some of the annoying instance fields 😉.

@leafpetersen
Copy link
Member Author

I am still not convinced that making inline classes look like classes it the best choice. Because they're not classes, they just look like it.

I'm definitely open to alternative proposals.

Having to declare the representation object as an instance field feels like a loss of abstraction, because it then affects the interface. You have to make the representation value variable private to avoid it affecting the public interface.

Fair. This is true of normal classes as well though, so I'm not sure it bothers me much. My general position is that almost all fields should be private, but that's not how Dart works. You mark fields with an _ if you want them to be private. So fine, do that here.

And having to use extends to avoid having to declare two instance variables is only necessary because we have to declare one instance variable to begin with.

I don't really think this is true. Without extends, you have to respecify the representation somehow. In this proposal, you do so by declaring a field. In other proposals you do... something else, but you have to do it.

where obj is just an identifier available inside the inline class, the same as type parameters of a class.

So basically, you want to make inline class fields private regardless of name. That's fine, but... it's yet another divergence from other classes that you have to learn. We can do it (I've proposed making them final by default as well), but the delta between this and just declaring it as _obj if that's what you want seems really small to me.

If that won't fly, sure, let's allow extends to avoid some of the annoying instance fields 😉.

Your proposal above doesn't eliminate the annoying redundant instance fields, right? You still have to re-specify in each class (JSObject obj). You're saying "these aren't fields", which is fine I guess, but the objection we're trying to address here is the redundancy, not the "fieldness". And the redundancy is still there.

@lrhn
Copy link
Member

lrhn commented Mar 31, 2023

TL;DR: I don't mind the redundancy, only how it looks.

Using extends allows you to avoid restating the same representation type (and give it a name), but a sub-inline-class may also want to restrict the representation type to a subclass.
Like:

inline class MyNum {
  final num _value;
  MyNum operator^(int other) => pow(_value, other) as MyNum;
}
inline class MyInt implements MyNum {
  final int _value;
  MyInt operator^(int other) => pow(_value, other) as MyInt;
}

Here I cannot use extends because I don't want to inherit the num-typed representation variable.
And I guess that's fine, you'd have the same issue with inheriting a concrete field in normal classes, so you should use implements. It actually matches class behavior.

I guess I just don't have an issue with redundancy in declaring the representation type independently for each inline class,
because it is defined for each inline class.
The extends here is allowing you to not explicitly declare the representation type, and instead inherit the precise representation type and access getter name from the superclass. But you inherit the getter anyway using implements, so it's really just allowing you to not declare your own representation variable to shadow it.

My issue is that adding the "field" as a getter to the interface links the underlying object and the API on top of it, and that's two abstraction layers I'd prefer to keep separate. That's why I say "leaking abstraction", because you are forced to leak the underlying type in the overlay API. Sure, you can make it private so nobody else can see, but it's still mixing the levels of abstraction.

And if you don't have the same name as the superclass, it gets even harder to explain.

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

@JS()
inline class DomElement implements Element {
  final JSObject elementObject; // I liked this name better.
  DomElement.fromJS(this.elementObject);
  external DomElement();
}

Here you'd inherit the JSObject get obj; member from Element, and adding a an elementObject member in the subclass too, making it look like there are two fields, even though there really is only, well, none.
Because they're not fields, they're getters like:

  JSObject get obj => this as JSObject;
  JSObject get elementObject => this as JSObject;

Saying that an inline class can only have one field, and then still looking like two is confusing.
IMO more confusing than just saying that it's not a field. It's is a view on a value, that value is special, and it's only available internally in the class. Like a type variable, it's scoped to the class, but not declares as a member, because members are reserved for the API at that abstraction level.

(I have a very hard time saying precisely what irks me, but it's the "square peg, round hole" feeling which usually means headaches will follow in the future.)

@leafpetersen
Copy link
Member Author

TL;DR: I don't mind the redundancy, only how it looks.

Fair enough. Some of our early prototype users very much do find the redundancy bothersome though, hence this issue.

Saying that an inline class can only have one field, and then still looking like two is confusing.
IMO more confusing than just saying that it's not a field. It's is a view on a value, that value is special, and it's only available internally in the class. Like a type variable, it's scoped to the class, but not declares as a member, because members are reserved for the API at that abstraction level.

I don't really find it confusing. There are two getters that return the same value, possibly at different types. But it is a bit messy, I agree. As I say, I'm open to alternative suggestions (probably in a different issue though). We just haven't found anything else yet that addresses all of the issues in totality as this formulation.

@lrhn
Copy link
Member

lrhn commented Apr 1, 2023

ACK. Separate issue.

Using extends here does match plain class declarations in behavior - you inherit the precise type and name of the superclass field. We can allow you to call super-constructors (in fact, you probably must, in order to "initialize" the superclass field, whereas if you only implement it, you can implement the superclass getter with your own getter.)

It solves the problem of repeating the same representation type variable, and nothing more. But that's still a win.

So 👍 to that within the current model.

(Is there more we should add too, for consistency? We should still extend implements to allow both inline classes and normal interfaces supported by the representation type, as being assignable to and maybe exposing members off. I don't think with makes sense. What we have is really just an empty new type with sticky extension methods and a pretend field. It doesn't matter how we subtype, if we can inherit implementation along implements. And final is still the only modifier which makes sense, again because all subtyping is the same.)

@eernstg
Copy link
Member

eernstg commented Apr 3, 2023

We can very easily get rid of the representation field: Just go back to an approach where we specify the representation type using on T (or the same thing in some other syntactic clothing). We may then use a fixed name like this, super, rep, or whatever, to refer to the representation object with the representation type inside the declaration body. Or we could use on T name to allow developers to choose a name. No problem, that was the plan for many months.

However, I do think we gained something useful by switching to a model where an inline class is similar to a class with exactly one instance variable, and it is basically an implementation detail that there is no ordinary object holding that instance variable: There's a complete set of features supporting construction of the representation object, using normal generative constructor declarations whose semantics matches that of normal classes so closely that developers don't have to think about it.

For example, this allows us to use an inline class as a value class:

// Wanted:
value class Point {
  final int x, y;
  Point(this.x, this.y);
  ... // Various members.
}

// OK, use this:
inline class Point {
  final (int, int) rep;
  Point(int x, int y): rep = (x, y);
  int get x => rep.$1;
  int get y => rep.$2;
  ... // Various members.
}

This inline class emulation of a value class yields strictly enforced immutability ("inherited" from the underlying record), structural equality, lack of identity (that is, giving the permission to box and unbox freely in the implementation), and sound covariance. This means that it is a quite good approximation of designs that we've discussed under names like 'value classes'.

The representation type can leak, and the syntax of the declaration is not as concise as a real built-in value class mechanism, and we still have to write copyWith manually, so you might still want to have a built-in value class mechanism. However, this example shows that the ability to write a rather general kind of constructor can be useful, and we should remember that this kind of expressive power is available immediately to every Dart developer, because it's just like normal classes.

Based on that, I'd prefer to keep the model where an inline class is very similar to a regular class with a single instance variable, and then we may consider some abbreviated forms covering frequently used cases.

Taking inspiration from enum declarations, we could specify that every inline class is a subclass of a platform provided class (which would again have a representation which is just the unique instance variable, no wrapper):

class Inline<X> {
  final X rep;
  const Inline(this.rep);
}

and the unique instance variable would then be introduced by regular inheritance:

// `on String` means `extends Inline<String>`, but we probably
// won't support an explicit `extends` clause.
inline class Foo on String {
  Foo(super.rep);
}

This would work for the Point case as well:

inline class Point on (int, int) {
  Point(int x, int y): super((x, y));
  int get x => rep.$1;
  int git y => rep.$2;
  ... // Various members.
}

We could say that the default constructor (the one you get if you don't declare any generative constructors) has the form InlineClass(super.rep);, which would be even more concise than a primary constructor:

inline class IdNumber on int {
  // Implicitly gets `IdNumber(super.rep);`.
}

This means that we could get a standardized name (everybody knows that it's called rep, or whatever we can agree on), it has a standard justification ("that's how it is declared in Inline, and this declaration just inherits it!"), we will have a simple rule about the inline class (no instance variables, period).

We might want to make Inline non-denotable (there is presumably little value in being able to declare variables as having type Inline<T> for any T). That would also allow us to avoid answering why it's OK for the same type to have multiple different instantiations of Inline in their superinterface graph (say, Inline<String> as well as Inline<Object>).

@Wdestroier
Copy link

I would like to suggest to choose an easier to read and remember, unabbreviated, standard name for code examples instead of rep, such as value.

@eernstg
Copy link
Member

eernstg commented Apr 4, 2023

Right, we do have a general rule that identifiers should not be abbreviations.

However, we've had an issue for about 6 years about final being too long compared to var (which is an abbreviation, btw). So I'd expect an identifier / a keyword which is going to be used many, many times to be subject to very harsh scrutiny for being too long.

Also, 'representation' is a direct reference to the phrases 'representation type' and 'representation object', which are meaningful interpretations of the role played by the unique instance variable in an inline class (the inline class provides a given representation object with a new static interface). That's the reason why I'd like to have some connection to the word 'representation'.

Nevertheless, we can always use a thousand proposals and see if something is both informative and short.

@srujzs
Copy link

srujzs commented Apr 5, 2023

My quick 2 cents:

// `on String` means `extends Inline<String>`, but we probably
// won't support an explicit `extends` clause.
inline class Foo on String {
  Foo(super.rep);
}

I think the reference to super here gets a little confusing for me. When I see super, I'm thinking of String instead of Inline in that code. The "magic" of Inline is also unintuitive to me, but maybe that's an education thing.

I really like the final goal of saying that there's only a representation type visible, and if you want, you can access the field through a standardized name. I also like on or even of (as in, this is an inline class "of" some other type).

How would this work with implements? Would you still need implements if you want to expose the members of the supertype, or would on cover that like extends would in Leaf's proposal? How would people write inline classes if they don't want to re-expose a supertype's methods?

@lrhn
Copy link
Member

lrhn commented Apr 5, 2023

If we go with on String, we might as well say on String rep to give it a name.

The advantage of the class-look-alike declaration is that we have existing syntax to flow the value into the variable (constructors).

The similarity with classes breaks down if we start leaning into the "it's really the representation type with a view" perspective.
A class I'd like to introduce is:

inline class MapEntry<K, V> implements ({K key, V value}) {
  final ({K key, V value}) _entry;
  MapEntry(K key, V value) : _entry = (key: key, value: value);
}

where the implement ({K key, V value}) (hopefully) makes the inline class expose the key and value getters of the underlying record, and allows the MapEntry to be assigned to (maybe even be a subtype of) the record type.

The relation between inline class and representation type is not a relation we have anywhere else. It's like a subtype relation, because it really is an "is-a" relation, but it's not necessarily reflected as such in the declaration.
(I've suggested inline class SubType is Super1, Super2 {...} instead of inline class SubType implements Super1, Super2 {...}, to make the subtype an actual subtype of the supertypes, because I want to use the relation for other things than interface types, and implements is somewhat associated with interface types.)

@lrhn
Copy link
Member

lrhn commented Apr 5, 2023

I don't think the Inline superclass is viable.

Say we have an inline class

inline class Inline<X> { 
  external Inline(X representationValue); 
}

and say that all other inline classes have to extend another inline class, then Inline is automatically the base, like Object for classes.

If we don't give the representation value a name, we can't access it, which is annoying.
(You can always do this as R, but that's cumbersome and errorprone).

Giving it a public name clutters the API and would be highly annoying.
Giving it a private name doesn't help.
Giving it a protected name would be awesome, but we don't have protected names. Shucks.

Let's use on syntax.

inline class Inline<X> on X _ {
  external Inline(X _);
}

Then you can subclass by doing:

// Implicit `extends Inline<String>`.
inline class MyString on String base { // <- this declares the representation type and private access name.
  MyString(super.base); // This initializes the value by calling `Inline<String>(...)` as super-constructor.
  // ... members, can access `base` lexically only.
}
// Inherits all `MyString` members.
inline class MySuperString extends MyString on String base {
  MySuperString(super.base);
  // more members.
}

Still feels cumbersome.
And no implements. These are "classes" without interfaces, with no virtual members and no interface members.
Only one superclass.

OK, lets try another syntax, with primary constructor-like notation:

// On String and export string.
inline class MyString(String base) extends String {
  // members, access base only lexically.
}

// Exposes inherited MyString and String members.
inline class MySuperString(String base) extends MyString {
  // More members. Can shadow.
}

You can still declare constructors, but no other non-redirecting generative ones. They all have to forward to the primary constructor (or cast).
You can hide the primary constructor, by making it named and private:

inline class MySuperString._(String base) extends MyString {
  factory MySuperString(String base) {
    if (base.isEmpty) throw ArgumentError.value(base, "base", "Must not be empty");
    return MySuperString._(base);
  }
  factory MySuperString.tricky(String base) => base as MySuperString; // look, no constructor!
}

That's my proposal:

inline class Name<TypeParameters>.nameOpt(parameter) implements TypeList {
  ... members but not non-redirecting generative constructor or instance variables ...
}

The implements can be extends, implements or is. I really don't care, it's just a word.

The primary constructor-like syntax allows the constructor to be named, and therefore private. Or not.
Every inline class has precisely one primary non-redirecting generative constructor which accepts
every value of the representation type. That's how an instance is created. (Or by casting to Name<TypeArgs>,
which is basically what that constructor does.)

The primary constructor-like syntax allows precisely one parameter.
It can be named or positional, optional or required (as usual, optional means either nullable or a default value).
The declared type of that parameter is the representation type of the inline class.
The parameter name is in lexical scope inside the class, similarly to type parameter names.
It provides access to this at the representation type, where this itself has the inline class type.

The constructor is almost trivial. It does no validation, no super-constructor invocation, no nothing.
And there's no loss of power or control in that, even if the constructor is public, because anyone can get the same effect by simply casting a value directly to the inline class type.

The TypeList can contain "compatible" types. A type S is compatible with the inline class type with representation type R if:

  • S is a supertype of R.
  • S is an inline class type whose representation type is compatible with R.

The inline class type is a subtype of every type in TypeList.
The inline class inherits/re-exports the members of all those types. It can shadow them. (No show/hide).
In case of conflict, it must provide its own implementation.

An instance member declaration can be abstract. If so, it implicitly forwards to the representation type member with the same name, and the declaration in the inline class must be a supertype of the method signature it forwards to.

@srujzs
Copy link

srujzs commented May 18, 2023

@eernstg This issue discusses ease of syntax and being able to roll an on-type into an extends clause. Has there been an issue to discuss inline classes implementing non-inline classes? I know this has come up in our discussions around extends, but I wanted to track it in its own issue. I'm happy to create one if not.

@eernstg
Copy link
Member

eernstg commented May 20, 2023

Has there been an issue to discuss inline classes implementing non-inline classes?

No, that idea has been around for a long time, but there is no dedicated issue. I just created this one: #3090.

I'm happy to create one if not.

Thanks! I did want to put some of the ideas that we have had on the table into that issue, so I wrote #3090, but that's of course just a place where discussions about any and all such ideas can be taken.

@mit-mit
Copy link
Member

mit-mit commented Jul 18, 2023

@eernstg what does the code from #2967 (comment) look like if we settle on #3182 ?

@eernstg
Copy link
Member

eernstg commented Jul 18, 2023

Like this:

@JS()
extension type Element.fromJS(JSObject obj) {
  external Element();
}

@JS()
extension type DomElement.fromJS(JSObject obj) implements Element {
  external DomElement();
}

@srawlins
Copy link
Member

This is more-or-less settled and implemented , yes?

@lrhn
Copy link
Member

lrhn commented Nov 6, 2023

Not currently planned.

@lrhn lrhn closed this as completed Nov 6, 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

7 participants