language icon indicating copy to clipboard operation
language copied to clipboard

What declaration syntax should structs and extension structs use?

Open leafpetersen opened this issue 2 years ago • 14 comments

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.

leafpetersen avatar Jul 29 '22 22:07 leafpetersen

cc @mit-mit @lrhn @eernstg @chloestefantsova @johnniwinther @munificent @stereotype441 @natebosch @jakemac53 @rakudrama @srujzs @sigmundch @rileyporter @mraleph

leafpetersen avatar Jul 30 '22 00:07 leafpetersen

I'll admit that the data class and view class variant of the syntax is growing on me.

leafpetersen avatar Jul 30 '22 00:07 leafpetersen

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.

lrhn avatar Jul 31 '22 12:07 lrhn

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).

mraleph avatar Aug 02 '22 12:08 mraleph

facade class is another option for view

mit-mit avatar Aug 03 '22 04:08 mit-mit

@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. (Like extension struct Foo(void Function(int)) implements void Function(int) { ... } for the call 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 the on 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, 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.

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.

leafpetersen avatar Aug 03 '22 05:08 leafpetersen

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).

eernstg avatar Aug 03 '22 07:08 eernstg

But when the view has an implements clause, the corresponding boxing class has the same implements 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 of Foo (with all the usual compositionality, including that List<S> is a subtype of List<Foo>
  • The members of S are a superset of the members of Foo
  • 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 of Foo, but the type V.class (or whatever it is called) is.
  • The members of V are a superset of the members of Foo
  • 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 of S, and not have S be a subtype of Foo
  • 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.

leafpetersen avatar Aug 03 '22 20:08 leafpetersen

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.

eernstg avatar Aug 04 '22 14:08 eernstg

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.

lrhn avatar Aug 05 '22 19:08 lrhn

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).

eernstg avatar Aug 06 '22 07:08 eernstg

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 to Bar with no boxing, and List<Foo> is assignable to List<Bar>. If we auto-boxed on assignment to Bar, 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.

lrhn avatar Aug 08 '22 08:08 lrhn

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 implements Foo, then:

  • The type S is a subtype of Foo ...

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.

sigmundch avatar Aug 10 '22 04:08 sigmundch

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.

lrhn avatar Aug 10 '22 09:08 lrhn

For consistency with records, I would like syntax with wavy brackets for user named fields, like

data class Foo({int x, int y});

Cat-sushi avatar Jul 06 '23 06:07 Cat-sushi

I've opened new issue #3198.

Cat-sushi avatar Jul 06 '23 09:07 Cat-sushi