language
language copied to clipboard
What declaration syntax should structs and extension structs use?
The proposal for structs and extension structs here uses the declaration forms:
struct Foo(int x, int y) {
// members here
}
extension struct Bar(int x) {
// members here
}
The idea of re-using the basic class syntax is fairly core to the proposal (though we could certainly incorporate pieces of this proposal into other proposals), but the choice of keywords is fairly arbitrary. The proposal lists a number of alternatives, largely around either choosing a new keyword (e.g. view
, or record
), or around using a modified on class
(e.g. data class
and view class
).
This issue is for discussion of main declaration syntax. Discussion of other syntactic choices (e.g. the primary constructor syntax) should be done in a separate issue.
cc @mit-mit @lrhn @eernstg @chloestefantsova @johnniwinther @munificent @stereotype441 @natebosch @jakemac53 @rakudrama @srujzs @sigmundch @rileyporter @mraleph
I'll admit that the data class
and view class
variant of the syntax is growing on me.
I'm slightly worried about the "view class"/"extension struct" syntax looking too much like class syntax, even though it really isn't a class. The implements InterfaceName
syntax is particularly worrisome, because it suggests a connection between the view and the interface, even though there is none.
The value might be assignable to the interface type, and it might forward interface methods to the underlying object, but there is no relation between the members declared in the extension struct/view and the interface.
Also, if it is really about assignability, we may want to allow non-interface types in the implements
clause, which is extra confusing. (Like extension struct Foo(void Function(int)) implements void Function(int) { ... }
for the call
method?)
I think I prefer the view Foo on TargetType
, because the on
makes it clear that it's not an interface relationship, it's just a type requirement. With show
clauses, the view
proposal can still expose the members of the underlying object, but not in a way which suggests that the new view type actually implements the interface.
I think we should avoid struct
because this keyword is rather overloaded and means different things in other languages (e.g. C# and C++ has mutable structs), it also clashes with things like ffi.Struct
we already have in our core libraries. Maybe record
would work better if we are looking for a single keyword.
For struct
specifically I would prefer value class
or data class
. value
clearly signals that we forgo an identity and are likely to treat an instance of this class as a value (rather than working with it through a reference). view
is also somewhat an overloaded term (e.g. UI views come to mind).
facade class
is another option for view
@lrhn
The value might be assignable to the interface type, and it might forward interface methods to the underlying object, but there is no relation between the members declared in the extension struct/view and the interface.
I don't understand what you are saying here. In this proposal I believe there is exactly the same relation between members declared in an extension struct and the interface as there are between members declared in a class and an interface that it implements. I don't understand what relation you see the latter that is missing in the former.
Also, if it is really about assignability, we may want to allow non-interface types in the
implements
clause, which is extra confusing. (Likeextension struct Foo(void Function(int)) implements void Function(int) { ... }
for thecall
method?)
I don't see an argument for doing this, and I don't propose to do this. Can you expand on why you see it as something we should do?
I think I prefer the
view Foo on TargetType
, because theon
makes it clear that it's not an interface relationship, it's just a type requirement.
I don't know what you mean by a type requirement here, can you expand?
With
show
clauses, theview
proposal can still expose the members of the underlying object, but not in a way which suggests that the new view type actually implements the interface.
That syntax is reasonable, but to be clear it provides different affordances. In this proposal the new view type does actually implement the interface. In the view proposal, the new view type cannot implement an interface.
In the view proposal, the new view type cannot implement an interface.
Indeed, there is no subtype relationship, we only check that every member is implemented with a correct override.
But when the view has an implements
clause, the corresponding boxing class has the same implements
clause, with completely normal semantics. So we just need to take one step to get the ability to be a subtype (of any non-sealed type that the author of the view has chosen to implement, they don't have to be supertypes of the type of the underlying representation).
Also note that the boxed object preserves the implementation of all methods declared in the view, so myView.box.foo()
will run the code in the view if foo
is declared in the view, but (myExtensionStruct as ImplementedType).foo()
will run the code in the class of the underlying representation, whether or not there is an implementation of foo
in the extension struct (of course, it may be an error to implement foo
in the extension struct, cf. https://github.com/dart-lang/language/issues/2370).
But when the view has an
implements
clause, the corresponding boxing class has the sameimplements
clause, with completely normal semantics.
Yes, I was being brief.
In this proposal, if an extension struct S
has an implements Foo
, then:
- The type
S
is a subtype ofFoo
(with all the usual compositionality, including thatList<S>
is a subtype ofList<Foo>
- The members of
S
are a superset of the members ofFoo
- If we disallow overrides, then dynamic dispatch and subsumption will not change the dispatch semantics, but if we allow overrides, dynamic dispatch and subsumption will cause a change in dispatch
In the view proposal, if a view V
has an implements Foo
, then:
- The type
V
is not a subtype ofFoo
, but the typeV.class
(or whatever it is called) is. - The members of
V
are a superset of the members ofFoo
- dynamic dispatch causes a change in dispatch, and subsumption doesn't apply (I think), but the object produced by
V.box
will always dispatch to the locally defined methods.
My primary concerns on this front with the view proposal are as follows:
- I think that it is surprising to users to see
implements Foo
on a declaration ofS
, and not haveS
be a subtype ofFoo
- The
.box
affordance pushes the complexity onto the end users. API designers cannot hide the complexity from end users, except by not relying on.box
. If.box
is part of the design of their API, then end consumers must understand and manually follow the required protocol. This pushes cognitive load onto consumers of APIs.
We could actually start with a zero-cost abstraction proposal (view / extension struct, whatever) where there is no support for implements
, and no subtype relationship between the zero-cost abstraction and any other types than top and bottom, or type equivalence (mutual subtyping) between the zero-cost abstraction and the underlying representation type.
We could then add boxing as an enhancement later on, yielding some extra time to think about it and implement it.
Here's an example using views (with no support for implements
, but an explicit boxing modifier box
on the wrapper):
view V on T {
... // Methods as needed for `V`.
}
box struct C(V that) implements /*Whatever you want*/ {
... // Manually implemented members, where you want custom behavior.
// Remaining members will be implicitly induced forwarders to `that`.
}
The struct C
would then be filled in by the compiler. This would work a bit like a class declaring a noSuchMethod
, where the compiler will generate noSuchMethod forwarders for every unimplemented member.
The difference is that C
would receive a forwarding member for every member of V
(calling the implementation in V
), and a forwarding member for every member (which is not already handled as a forwarder to V
) of an interface in the implements
clause of C
, with the combined member signature. It is a compile-time error if the resulting class isn't concrete (that is, some members aren't implemented at all).
I think this means that the boxing mechanism is made slightly more general (it could be used on any type, not just zero-cost abstractions), and we do get to specify the subtype relationships and check that they are satisfied, and we allow for some implementations to be provided by a mixin or a superclass, etc. etc.
A boxing view is really just a wrapper with implicit forwarding. It's a class, it creates objects. It's not just a view.
We can make it as struct instead, in case we don't want to guarantee identity, and want to box/unbox as needed, but it's still something which retains its runtime type across assignments, even to Object
. That's what's important.
So, do we want both:
- A cheap static-only view which can be lost on up-casts.
- A persistent runtime-reified view which survives across up-casts.
They're different things.
A boxing view is really just a wrapper with implicit forwarding.
What I'm proposing is that we remove everything about boxing from the view / extension struct declaration, and then we introduce a box
modifier on other declarations, such as a class or a struct. That box
modifier makes the class/struct capable of boxing the view, and this implies that the class/struct will receive a compiler-generated set of member implementations, doing the forwarding.
The point is that (1) a user can write whatever they want in order to override the forwarding implementation, and get all the forwarding methods for free; (2) you can have multiple ways to box a view / extension struct, if you want to implement
multiple different interfaces, possibly with conflicting member signatures; and (3) you can box an ordinary class, which would also allow for things like "changing its interface" and "intercepting a couple of methods" with regular objects.
This makes a view / extension struct a pure zero-cost abstraction, and it provides physical wrapping as a separate mechanism (which is by the way able to do everything which was otherwise handled via the boxing part of the view proposal, and then some).
I think my main issue here is that I don't understand the motivation for implements
on extension structs.
In classes implements
provides four things:
- Interface member signatures which can be called (and which must be satisfied)
- Interface member signatures and superinterface for the implicit interface.
- Subtyping
- Assignability (through subtyping)
An extension struct seems to get three of those:
- interface member signatures for calling (by forwarding).
- subtyping
- assignability (through subtyping).
An extension struct does not introduce an implicit (implementable) interface:
Extension structs do not define a signature, and hence cannot be implemented.
So extension structs introduce a type (with an object signature - the set of member signatures), but not an interface (they're not implementable). That's not new, so does function types.
The motivation for this is given as:
COMMENTARY(leafp): The driving motivation for this design choice is keep the behavior of extension structs consistent with general structs. For general structs and classes, implementing an interface means that the newly defined type is both a subtype of that interface, and supports all of the methods of that interface. If we wish to preserve the former for extension structs, then we must ensure that the underlying representation object also implements the same interface, so that when we assign it, we do not break soundness. We could give up on fully subtyping and instead only allow assignability, with conversion to the implemented interface requiring boxing, but I have chosen not to do that, since it still makes subtyping unavailable. That is, under this proposal, for an extension struct Foo that implements Bar,
Foo
is assignable toBar
with no boxing, andList<Foo>
is assignable toList<Bar>
. If we auto-boxed on assignment toBar
, we could preserve the former, but not the latter.
It says "If we want to preserve the former ...". Why do we want to preserve subtyping?
We can allow assignability without subtyping, like we do for callable objects and function types. That would be a more in line with what actually happens: a tear-off of a single field of the struct. It's not just storing the view when you assign.
If we want to allow a List<Nat>
to be used as a List<int>
, then we might need subtyping.
And proper subtyping, because a List<int>
is not necessarily a valid List<Nat>
.
(We probably still shouldn't allow it, because you can add -1
to the List<int>
.)
Or we can simply say that covariant assignability is allowed inductively at any position in the type, so a List<Nat Function(int)>
is assignable to <List<int Function(Nat)>
too.
I don't think I want the subtyping. A view is not an object, it's a fleeting locally-applied static wrapper.
In fact, I think that I don't want the extension struct types to be reified at runtime at all. At runtime, there is only the underlying type. Views is an entirely static thing. There should be absolutely no need for the type at runtime. And because of that, I don't think a class-like syntax is appropriate, because it makes it look too much like it's a real type.
Whether we want assignability depends on the more precise details of the "view" feature. I think the current "views" proposal handles it well.
I'm very much aligned with @lrhn here.
When I read X implements Y
, I expect (X x) => x is Y
to return true
. If X
is an extension structs/view, this returns false
instead. Now my mental model of what it means to implement Y
is broken.
@leafpetersen, in particular, when you said:
In this proposal, if an extension struct
S
has an implementsFoo
, then:
- The type
S
is a subtype ofFoo
...
I am having a hard time connecting this with the example above. If x
has type S
and x is! Foo
, is really S
a subtype of Foo
?. Maybe if Foo
happens to be a supertype of the on
type, but I don't see this being generally true for an arbitrary interface.
I'd go back here to the original goal: we are not looking at creating additional subtyping relationships, we are looking at a succinct way to synthesize nonvirtual forwarding stubs. I believe a different keyword, like the show/hide
in the previous view proposal, would make that clearer.
Another (possibly different) way to say the same thing: When a thing implements an interface, I expect to be able to cast it to that interface and get the implementation from that thing. That's what implementing an interface means.
Like @sigmundch says, when I read X implements Y
and Y
has a method foo()
, I expect that x.foo()
and (x as Y).foo()
does the same thing. That's what X
implementing Y
means: That X
provides the implementation of Y
's methods.
Extension structs/view are not using dynamic dispatch. It's all static dispatch on the static extension struct type that the object is currently viewed at. Saying that that view implements an interface is meaningless because it does not actually provide anything when the object is used at the interface type. The methods on the view are entirely separate from the methods of the interface. They may have the same name, but they are not hit by any method invocation which tries to target the interface method. The extension struct methods could have completely different signatures, and it wouldn't affect whether the view could "implement the interface" in any way. The moment it matters whether it actually does implement the interface, when the value gets used at the interface type, the extension struct type is no longer in play.
For consistency with records, I would like syntax with wavy brackets for user named fields, like
data class Foo({int x, int y});
I've opened new issue #3198.