dart-lang / language

Design of the Dart language
Other
2.67k stars 205 forks source link

Record spreading #2128

Open lrhn opened 2 years ago

lrhn commented 2 years ago

The current records proposal does not introduce a "spreading" operator. I think it should have one.

A "record spread", with syntax ... recordExpression, would be allowed inside any record expression or argument list. The recordExpression must have a static type which is an actual record type (not dynamic and not Record or T extends Record).

It works by, effectively, inlining every member of the tuple at the spread-point. So,

(num, num) point = ...;
(num, num, {Color color}) colorPoint = (...point, color: Color.red};

would inline the elements of point as elements of the new record at the point of the spread.

It would also work in argument lists.

This is a static operation which requires knowing the type of the spreadee and allows knowing the structure of the result. It won't allow something like

Record concat(Record a, Record b) => (...a, ...b); // Not allowed!

because neither a nor b have known structures.

That also means that it can be desugared into, e.g, (point[0], point[1], color: Color.red) if we have a syntax for directly accessing members of a record. (If we don't, the desugaring would include a pattern match of some sort, and might not be directly expressible as an expression in the language.)

munificent commented 2 years ago

I think it should have one.

Me too!

The recordExpression must have a static type which is an actual record type (not dynamic and not Record or T extends Record).

Assuming we stick with the idea to use Destructure_n_ interfaces for positional destructuring, it might be reasonable to also allow types that implement Destructure_n_ for some n. (Though that raises questions of which n if it implements multiple...)

It would also work in argument lists.

YES. [jack nicholson nodding.gif]

Also, we should allow if in record literals and argument lists. And potentially even for for positional fields and arguments.

lrhn commented 2 years ago

I really do not want the DestructureN interfaces. Implementing a Destructure4 interface suggests that there is an inherent and natural view of the class as a four-tuple (and not with any named record entries), but then, maybe just make the class a "view" on a four-tuple to being with.

If you want to represent your class as a tuple, I'd just have a method or getter returning that tuple. That makes it explicit, and documented by name, which semantic view of the object you're using. If you can't find a good name, then that tuple view might not belong as part of the API anyway.

I'm not sold on if and for in records, if they mean that we cannot statically determine the structure of the record/argument list. If I write var p = (1, if (b) 2, 3);, what will be the type of p? If it's (int, int?, int), then maybe, but we already have the explicitb ? 2 : null` for that. For argument lists, omitting an optional argument makes sense, but again, it should not change the position of any later argument. Repetition does not make sense in the current argument lists.

The places where it makes sense to use if/for/spread is in arbitrary-length sequences of similarly typed "things", because there it doesn't change the static type to omit or insert more of those things. (Monoids, basically.) So, if we had functions with rest parameters (arbitrary number of similarly typed arguments collected into a single parameter), then it would make sense to spread/if/for into those. (Strings with concatenation is a monoid, so string interpolations should allow for/if/spread too, #1478).

munificent commented 2 years ago

I really do not want the DestructureN interfaces.

I'm not particularly attached to them, but they do seem to solve the problem that I want to solve which is giving classes a way to opt into and control their positional destructuring.

Implementing a Destructure4 interface suggests that there is an inherent and natural view of the class as a four-tuple (and not with any named record entries), but then, maybe just make the class a "view" on a four-tuple to being with.

I think implementing Destructure4 only says that the author of the class has decided that there is a natural way to destructure the class into four positional fields. I think that's directly analogous to a class having an unnamed constructor with four positional arguments. In that case, the author isn't claiming that that there's something deeply intrinsic to that way to construct the instance, but they are saying "Here's the way I've decided it can be constructed from four positional arguments."

Implementing Destructure4 just says, "Here's the way I've decided it can be destructured to four positional subpatterns."

I'm not sold on if and for in records, if they mean that we cannot statically determine the structure of the record/argument list. If I write var p = (1, if (b) 2, 3);, what will be the type of p? If it's (int, int?, int), then maybe, but we already have the explicitb ? 2 : null` for that.

Good point.

The places where it makes sense to use if/for/spread is in arbitrary-length sequences of similarly typed "things", because there it doesn't change the static type to omit or insert more of those things. (Monoids, basically.) So, if we had functions with rest parameters (arbitrary number of similarly typed arguments collected into a single parameter), then it would make sense to spread/if/for into those.

Yes, rest params was where I initially thought to support if and for in argument lists and is, I think, still the place where they make the most sense.

ds84182 commented 2 years ago

Have you also considered introducing support to spread record typedefs in parameter lists as well?

Example of how this can greatly improve passing parameters to extended/nested widgets:

typedef CommonButton = ({
  VoidCallback? onPressed,
  VoidCallback? onLongPress,
  ValueChanged<bool>? onHover,
  ValueChanged<bool>? onFocusChange,
  ButtonStyle? style,
  FocusNode? focusNode,
  bool autofocus = false,
  required Widget child,
});

class OutlinedButton extends ButtonStyleButton {
  // Only for OutlinedButton!
  final bool someSpecializedThing;

  OutlineButton(CommonButton... commonButton, {this.someSpecializedThing}) : super(...commonButton);
}

// Not exactly a clean example, but shows what could be possible:
class MyParticleButton extends StatelessWidget {
  final CommonButton commonButton;
  // Also works for function types
  final Widget Function(CommonButton...) builder;
  final Color particleColor;

  MyParticleButton(CommonButton... commonButton, {Key? key, this.builder = OutlinedButton.new, this.particleColor}) : super(key: key);

  Widget build(BuildContext context) {
    return ParticlePainter(
      child: builder(
        ...commonButton,
        // Like map spreads, allows overwriting positional parameters?
        onPressed: ParticlePainter.wrap(context, commonButton.onPressed),
      ),
    );
  }
}

// Usage:
MyParticleButton(child: Text('Tap me!'))
MyParticleButton(builder: ElevatedButton.new, child: Text('I am filled with color (and particles)!'))
Wdestroier commented 2 years ago

Record concat(Record a, Record b) => (...a, ...b); // Not allowed!

Shouldn't it be possible to spread records even if they're represented by a dynamic type? concat(a, b) => (...a, ...b);

lrhn commented 2 years ago

Whether we can allow dynamic record operations depends on how we want to be able to implement records.

There are implementation strategies (that I favor) which requires all record types occurring in the program to be known at compile-time. I guess that we can do arbitrary (and slow and expensive) record operations like the one above, as long as the result is always boxed, but even the boxing would the need to know and record the structure in a different way than optimized (compile-time known) records, which will make boxed-record access polymorphic and slower.

So, that's why I don't want to allow you to do any operation on an untyped record, other than checking that it has a specific known structure.

Wdestroier commented 2 years ago

Can records be created during runtime?

void handleWebSocket(WebSocket webSocket) {
  webSocket
    .map((string) => decodeJson(string))
    .listen((message) {
      var echo = message.echo;
      print("Message to be echoed: $echo");
      var response = encodeJson((response: echo));
      webSocket.add(response);
    }, onError: (error) {
      print('Bad WebSocket request');
    });
}

The spread operator with dynamic types would be more useful if so. It would allow the creation of something more equivalent to a JSON in Dart than a Map, but I'm not sure if it's of interest of this proposal or even the Dart language. I guess it would be useful especially when interacting with APIs.

munificent commented 2 years ago

I'm moving this over to patterns-later. I definitely want it, but I think it would be really hard to get this designed and into the initial release.