dart-lang / language

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

Inferred record types + optional fields for record expressions? #3132

Open jakemac53 opened 1 year ago

jakemac53 commented 1 year ago

Spawned from this discord discussion.

The general use case is that you have a function with many optional parameters. You want to add some sort of "hierarchy" to those parameters by essentially grouping them. Possibly some must always be provided together, or just for organizational purposes you want to nest certain ones together. It might be compelling to use a record for these "groups" of parameters - they can nest recursively and they could potentially be optimized away entirely so they are zero cost.

Consider the following example, which has a set of features bundled under an opts record parameter.

void foo(({bool? zap, int? zip})? opts) { ... }

If I want to actually invoke this function with any opts, I would have to pass explicit null values for each of the fields. I lose the advantage of optional parameters for any of my nested options.

I could create a class to hold those options, or I could expose a helper function to build these records which has optional parameters, but those are both a bit less than ideal.

Would it be possible for us to instead allow just passing a record expression such as (zap: true), omitting any of the non-required fields? We know the expected type of the record so it should be safe to do so?

This does also imply that we might want to add support for required in fields for record types, and figure out how we want to handle optional positional args as well (I would making things as similar to function types as possible).

munificent commented 1 year ago

Would it be possible for us to instead allow just passing a record expression such as (zap: true), omitting any of the non-required fields? We know the expected type of the record so it should be safe to do so?

I think what you propose is: If a record expression e appears in a downwards inference context C where C has named fields that aren't present in e, then e is implicitly desugared to include all of those fields with value null.

I believe that's fairly straightforward semantically. So it's possible, but this would be another kind of implicit compile-time conversion (like int-to-double), and those tend to bring in a lot of complexity. In general, one of the big problems is that it makes code less refactorable. This would work:

({int? a, int? b}) record = (a: 1);

But if you changed it to:

var justA = (a: 1);
({int? a, int? b}) record = justA;

Now you get a compile error. (Int-to-double has the same problem.)

We could make this a runtime implicit conversion (like implicit generic function instantiation and implicit call tear-offs). That would allow the latter example. But now any assignment or parameter binding can potentially create a new object. That could get weird:

typedef AB = ({int? a, int? b});

main() {
  var a = (a: 1);
  AB ab = a;
  print(a == ab); // "false".
}

I think, overall, we consider int-to-double to be a useful feature that carries its weight. So compile-time implicit conversions seems reasonable to me. But we've been talking for years about removing implicit generic function instantiation and implicit call tear-offs because they're weird, confusing, and don't carry their weight. That makes me hesitant to consider runtime implicit conversions for records.

The other option is to model it using actual subtyping. We could say that a record with extra nullable named fields is a supertype of any record that lacks some of those fields. That would let you write the desired code without any conversion at all. (zap: true) would just be a valid instance of ({bool? zap, int? zip}). Sort of like a kind of row polymorphism.

(This would imply that () is a valid instance of all record types containing only nullable named fields.)

Expanding subtyping tends to add a lot of complexity too, including additional runtime complexity and possibly perf implications.

jakemac53 commented 1 year ago

I think what you propose is: If a record expression e appears in a downwards inference context C where C has named fields that aren't present in e, then e is implicitly desugared to include all of those fields with value null.

Exactly :). I was definitely viewing this only as a compile time feature, targeted specifically at this "nested groups of optional parameters" use case.

jakemac53 commented 1 year ago

I am going to go ahead and close this - I think ultimately it would cause more problems than it solves.

In particular I would worry about the use case where people want to add additional "optional" fields to one of these records, thinking it was non-breaking. But if a user was creating the record outside of that context and then passing it as a variable, they would be broken since their record wouldn't have the inferred shape. This could be "fixed" with a runtime component but I don't think that is a good fix.

ds84182 commented 1 year ago

I think this is actually useful to have but needs to be explicit. How about a trailing ... that indicates the rest of the record should be filled with null depending on statically known context type?

munificent commented 1 year ago

I'm going to reopen this because I do think it's a reasonable request. I don't know if it's likely, but I wouldn't mind hanging on to the idea. @ds84182, that's a good suggestion.

domesticmouse commented 1 year ago

I'm going through an exercise attempting to convert a set of imperative function calls into data (the pip patterns for playing cards in a Flame tutorial, natch), and I have hit this issue while trying to model optional arguments. While the optional arguments can be omitted on the function call, they need to be explicitly included as nulls in the record entries in the light weight data representation.

I've added an upvote up on the issue description, but I thought I'd add this colour to describe a usecase.

munificent commented 1 year ago

I thought I'd add this colour to describe a usecase.

This is always very valuable on all language issues. Thank you!

chen56 commented 7 months ago

(This would imply that () is a valid instance of all record types containing only nullable named fields.)

use default value is clear :

typedef CenterStyle = ({double widthFactor=10, double heightFactor=20});

big requirements, very useful:

Usecase flutter:

useCaseFlutter() {
  // want this:
  Style style = const Style(center: (widthFactor: 10, heightFactor: 20));
  // or this: but compile error
  style = const Style(center: (widthFactor: 10));
  // not this:
  Style style2 = const Style(center2: CenterStyle2(widthFactor: 10, heightFactor: 20));
}

class Style {
  const Style({this.center, this.center2});
  final CenterStyle? center;
  final CenterStyle2? center2;
}
typedef CenterStyle = ({double? widthFactor, double? heightFactor});
class CenterStyle2 {
  final double? widthFactor;
  final double? heightFactor;
  const CenterStyle2({this.widthFactor, this.heightFactor});
}

javascript usecase:

$.ajax({
    url: "http://date.jsontest.com/",
    success: function (data) {console.log(data);},
    cache: true,
    timeout: 500
});