Skip to content

Late parameters, late-init-query operator, parameter element #3680

Open
@lrhn

Description

@lrhn

(Because I'm sure I've written this before, but it's probably in a comment of another issue, here is a stand-alone version.)

The copyWith problem is that it's impossible to distinguish an omitted argument from an explicit passing of the default value.
That's generally considered a good thing, but it makes forwarding arguments to another function awkward.

Dart once had a "was argument passed" operator, ?x which was true if x had an argument passed, and false if it got its default value implicitly. It was intended to make it easier to forward arguments to other functions, but since those other functions could now also behave differently depending on whether an argument was passed or not, it actually made it harder. It was quickly removed again.

I propose that we add two things, both a way to see if an argument was passed, and a way to easily pass or not pass an argument in constant code size.

Proposal

Allow optional parameters to be declared late. A late parameter need not be initialized, so it can be optional and not have a default value. (It's not allowed to have a default value, because if it has one, it doesn't need to be late.)

Add a prefix operator ?? (strawman syntax) which applies to a late local variable, and which evaluates to a bool value that is true if the variable is initialized, and false if it's not. (This value affects "definite assignment" analysis, so it's possible to write if (??x) { return x; } else { x = 42; } no matter what the assignment analysis said about the late variable x before.)
(See also #1028. Maybe ?? should only apply to late parameters, but it can be extended to any other late variable.)

Finally, allow argument elements, which are arguments which can evaluate to no value.

<argumentList> ::= '(' <arguments> ')'
<arguments> ::= (<argument>? ',')* <argument>?
<argument> ::= (<identifier> ':')? <argumentElement>
<argumentElement> ::= 
      'if' '(' <expression> '>') <argumentElement> ('else' <argumentElement>)?
      'switch' '(' <expression> ')' '{' <elementSwitchCases> '}'
    | '?' <expression>
    | <expression>
<elementSwitchCases> ::= (<elementSwitchCase> ',')* (<elementSwitchDefault> | <elementSwitchCase>) ','?
<elementSwitchCase> ::= 'case' <pattern> ('when' <expression>) ':' <argumentElement>
<elementSwitchDefault> ::= 'default' ':' <argumentElement>

Anywhere we currently allow an <expression> as an argument, we now use <argument>, which allows omitting a value for that argument.

  • The if can have no else branch.
  • The switch needs not be exhaustive (unless it's an always-exhaust type, as usual).
  • The ? <expression> is a non-value if the expression is null.
  • The <argument> itself can be omitted (otherwise ?null would be the shortest "no value" syntax).

(The element-switch and null-aware element ?e should both be added as collection elements as well.)

That allows a call of foo(, if (false) 0, switch (0) { case 1: 0 }, ?null, 42) to call foo with the first four arguments being omitted.
It's the same as foo(,,,, 42). It's an error if a parameter is not optional, and its argument element can evaluate to no value (more precisely: an <argumentElement>? may evaluate to no value if it's empty, if it's an if element that has no else branch, or where the argument element of either branch may evaluate to no value It's empty, if it's a switch element which is not exhaustive, or where any case element may evaluate to no value, or if it's an ?-element).

It's new that non-trailing optional positional arguments can be omitted, and later parameters can be passed.

There is no good way to avoid that property while allowing conditionally passing an argument, not without completely disallowing that for positional arguments. Then we'd disallow late positional parameters too. That's an annoying asymmetry with named parameters, so it's better to just allow non-trailing positional parameters to be omitted. The majority of functions can't tell the difference (yet, without the ?? feature), they'll just get their default values.
(A few functions may deliberately use private sentinel default values to check whether an argument was passed. That's not generally possible, which is why it doesn't solve the callWith problem, but if the parameter type is Object? anyway, any sentinel value can be used. Calling those functions incorrectly may give you a different result than you expected (well, what did you expect?). So don't do that. The functions will probably be rewritten to use ??parameter instead of identical(parameter, _sentinel) soon enough.)

Semantics

The semantics are straight-forward.

  • A late parameter with no argument value passed is uninitialized after "binding actuals to formals", behaving just like any other uninitialized local late variable. If an argument was passed, it's initialized to that value. The variable starts as potentially assigned, which means it's not a static error to read or write it, but may be a runtime error. Reading it will throw, if unassigned, writing will succeed if unassigned or non-final. Static type is the declared type. (For a covariant parameter, the parameter's local variable's type is the declared type, even if the function parameter's runtime type is Object?. Same as always.)
  • The ??parameterVariable has static type bool, the operator checks whether the late parameter is initialized or not. If used in a conditional position, the operator promotes the variable to definitle assigned on the true branch and definitely unassigned on the false branch. It's a compile-time error to use it on anything other than a late variable name.
  • Argument elements are evaluated as would be expected. If they end up with no value (if with false value and no else branch, non-exhaustive switch with no matching case, ?e with a null value, or just an omitted <argument>), then the argument list has no value at that position (or at that name, if named, an omitted <argument> is always counted as a positional argument). This is new, argument lists now need a way to represent "no value" at any position or name, in a way that can be acted on when binding actuals to formals. See first item.

Consequences

It's now possible to write a copyWith:

class Point 
  final int x, int y;
  final Color? color;
  Point(this.x, this.y, {this.color});
  Point copyWith({late int x, late int y, late Color? color}) =>
    Point(??x ? x : this.x, ??y ? y : this.y, color: ??color ? color : this.color);
}

(I'm quite certain that the first request we'll get is to allow ?? as infix, treating an unassigned late variable the same as a null-valued nullable non-late variable. Then copyWith would be:

  Point copyWith({late int x, late int y, late Color? color}) =>
      Point(x ?? this.x, y ?? this.y, color: color ?? this.color);

That's ambiguous when a variable is both nullable and late, since reading the variable is allowed (it only might throw).
More likely the ?? will be saved for special cases, and you can still use null where it's not a valid value:

  Point copyWith({int? x, int? y, late Color? color}) =>
      Point(x ?? this.x, y ?? this.y, color: ??color ? color : this.color);

Still, expect this request.)

It's also now possible to forward any known argument list, without knowing the default values:

Result foo(int x, {late Banana banana, late SecretSauce sauce}) {
  log("foo(${["$x", if (??b) "banana: $banana", if (??sauce) "sauce: $sauce"].join(", ")})");
  return super.foo(x, banana: if (??banana) banana, sauce: if (??sauce) sauce);
}

or from noSuchMethod without an exponential blowup in the number of optional named parameters:

  noSuchMethod(i) {
    if (i.memberName ==  #foo) {
      return target.foo(i.positionalArguments[0] as int,
           banana: if (i.namedArguments case {#banana: Banana banana}) banana,
           sauce: if (i.namedArguments case {#sauce: SecretSauce sauce}) sauce,
      );
   } else {
     return super.noSuchMethod(i);
   }
 }

The forwarding is more signficant than just copyWith. If copyWith was the only problem to solve, allowing non-constant default values is enough:

  Point copyWith({int x = this.x, int y = this.y, Color? color = this.color}) =>
      Point(x, y, color: color);

(Which we should just do. #140! But that does allow seeing whether an argument was passed using side-effects through external variables, so we might still want argument elements to counteract that ability.)

Metadata

Metadata

Assignees

No one assigned

    Labels

    featureProposed language feature that solves one or more problems

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions