Description
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
Activity
lrhn commentedon 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:
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 commentedon Mar 31, 2023
I'm definitely open to alternative proposals.
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.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.
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.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 commentedon 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:
Here I cannot use
extends
because I don't want to inherit thenum
-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 usingimplements
, 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.
Here you'd inherit the
JSObject get obj;
member fromElement
, and adding a anelementObject
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:
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 commentedon Apr 1, 2023
Fair enough. Some of our early prototype users very much do find the redundancy bothersome though, hence this issue.
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 commentedon 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 thinkwith
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 alongimplements
. Andfinal
is still the only modifier which makes sense, again because all subtyping is the same.)eernstg commentedon 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 likethis
,super
,rep
, or whatever, to refer to the representation object with the representation type inside the declaration body. Or we could useon 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:
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 writecopyWith
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):and the unique instance variable would then be introduced by regular inheritance:
This would work for the
Point
case as well: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: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 inInline
, 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 typeInline<T>
for anyT
). That would also allow us to avoid answering why it's OK for the same type to have multiple different instantiations ofInline
in their superinterface graph (say,Inline<String>
as well asInline<Object>
).Wdestroier commentedon Apr 3, 2023
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 commentedon 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 tovar
(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 commentedon Apr 5, 2023
My quick 2 cents:
I think the reference to
super
here gets a little confusing for me. When I seesuper
, I'm thinking ofString
instead ofInline
in that code. The "magic" ofInline
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 evenof
(as in, this is an inline class "of" some other type).How would this work with
implements
? Would you still needimplements
if you want to expose the members of the supertype, or wouldon
cover that likeextends
would in Leaf's proposal? How would people write inline classes if they don't want to re-expose a supertype's methods?lrhn commentedon Apr 5, 2023
If we go with
on String
, we might as well sayon 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:
where the
implement ({K key, V value})
(hopefully) makes the inline class expose thekey
andvalue
getters of the underlying record, and allows theMapEntry
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 ofinline 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, andimplements
is somewhat associated with interface types.)lrhn commentedon Apr 5, 2023
I don't think the
Inline
superclass is viable.Say we have an inline class
and say that all other inline classes have to extend another inline class, then
Inline
is automatically the base, likeObject
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.Then you can subclass by doing:
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:
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:
That's my proposal:
The
implements
can beextends
,implements
oris
. 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, wherethis
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: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 commentedon 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 aroundextends
, but I wanted to track it in its own issue. I'm happy to create one if not.super
an error? #30879 remaining items