dart-lang / language

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

Provide explicit access to the default values of parameters #2269

Open eernstg opened 2 years ago

eernstg commented 2 years ago

This issue proposes that Dart should have explicit syntax denoting the default values of parameters, such that it is possible to write forwarding functions and similar constructs with good generality and maintainability properties. The core idea is that the most relevant default value in a given context is denoted by the reserved word default, and more general forms are available to denote the default value of an arbitrary formal parameter in scope. The proposal is a non-breaking change.

Motivation

It is inconvenient to write a forwarding function in Dart when the forwardee accepts one or more optional parameters. Here is a safe way to do it:

// Copying the default value.
void f1([int? j = 42]) {}
void forwardToF1([int? j = 42]) => f1(j);

This does work: In order to get the same behavior for an invocation of forwardToF1 as the one we get from an invocation of f1, we should be able to call them in at least the following ways, and it should not matter which function we're calling, and that is indeed true:

void main() {
  f1();     forwardToF1();     // 1. Passing 42.
  f1(1);    forwardToF1(1);    // 2. Passing 1.
  f1(42);   forwardToF1(42);   // 3. Passing 42.
  f1(null); forwardToF1(null); // 4. Passing null.
}

This is true in all four cases, but we need to copy the default value expression from f1 to forwardToF1 (and maintain the consistency whenever there's a reason to change the default value of f1), which is bad.

Note that null may have a separate meaning. Say, if we're considering a method optimize that accepts an optional argument of type Optimizer then function.optimize(optimizer) could mean "optimize function using the given optimizer", with the special case "don't optimize function at all" when optimizer evaluates to null, and function.optimize() could mean "optimize function using a default optimizer.

Alternatively, we could consider maintaining the rule that "passing null is exactly the same thing as not passing anything", cf. https://github.com/dart-lang/language/issues/2232. That proposal has a beautiful simplicity to it, but it is a breaking change and it does have some other costs as well. So that's the reason why I'm proposing to keep the semantics of optionals unchanged.

Consider the following example, which uses the "passing null is the same as not passing" semantics, expressed in current Dart:

// Insist that passing `null` is the same thing as not passing anything.
void f2([int? j]) {
  j ??= 42;
}
void forwardToF2([int? j]) => f2(j);

About the costs:

This approach captures the meaning of null as an actual argument, because it is taken to mean "use the default value". In the optimizer example we would no longer be able to request that there should not be any optimizations at all.

Also, it captures the type of the optional parameter because it must be nullable, and this means that the invocation is less type safe than it would have been if it had been declared as void f2([int j = 42]) {} (that is, using a non-nullable parameter type): If, by a mistake, we call f2 with an argument whose type is int? and the parameter type is int? just because it is optional and we want to use "passing null is the same as not passing anything" semantics, then we won't be notified that the parameter should be non-nullable. In short, "you can never have call-site null safety for optional arguments".

Consider the invocations:

void main() {
  f2();     forwardToF2();     // 1. The value of `j` is 42 in `f2`.
  f2(1);    forwardToF2(1);    // 2. Passing 1.
  f2(42);   forwardToF2(42);   // 3. Passing 42.
  f2(null); forwardToF2(null); // 4. The value of `j` is 42 in `f2`.
}

So we do get the consistency in all four cases, but there were also some costs, including the fact that it is a breaking change to change the forwardee from using the parameter [int? j = 42] to using [int? j] and j ??= 42 in the body (case 4).

With this proposal, we could get the same semantics as in the first example, but without the code duplication:

void f3([int? j = 42]) {}
void forwardToF3([int? j = f3.default]) => f3(j);

Another example is the case where we wish to pass a given argument if it is non-null, but we we wish to use the default value when it is null:

void f4([int j = 42, String s = 'some default']) {}

void main() {
  int? x = ...;

  // To invoke `f4` as described today, we need to have two distinct invocations,
  // because we don't want to textually copy the default value into the call site.
  if (x != null) {
    f4(x);
  } else {
    f4();
  }

  // With this feature, we can use the default value without copying it,
  // and get the same behavior.
  f4(x ?? default);

  // We might want to use the default value indirectly.
  f4(default + 1);

  // We may also want to use multiple default values, which could not
  // be expressed today using multiple separate invocations (unless
  // we copy the default value manually to the call site).
  String? y = ...;
  f4(x ?? default, y ?? default);
}

Proposal

Syntax

The Dart grammar is changed as follows:

selector
    :    '!'
    |    assignableSelector
    |    argumentPart
    |    typeArguments
    |    '.' 'default' // New alternative.
    ;

primary
    :    thisExpression
    |    'super' unconditionalAssignableSelector
    |    constObjectExpression
    |    newExpression
    |    constructorInvocation
    |    functionPrimary
    |    '(' expression ')'
    |    literal
    |    identifier
    |    constructorTearoff
    |    'super' '.' 'default'
    |    'default' // New alternative.
    ;

Static analysis

This section is basically the specification of how to obtain a full expression containing default from any of the abbreviations. For instance, default means f[0].default if it occurs in the first positional argument in an invocation of f, and so on. This means that we will automatically get the most relevant default value, based on the location where default is used. But we can specify any default value we want, in any location, if needed.

A postfixExpression of the form C.m[p].default is a compile-time error unless C is a type literal denoting a class, mixin, or extension (coming: or view) that declares a method named m where (1) p is a constant expression of type int and m declares an optional positional parameter at the position p, or (2) p is a constant expression of type Symbol and m declares an optional named parameter with the name given by the symbol.

If no error occurred then C.m[p].default is a constant expression whose value is the default value of said parameter.

A postfixExpression of the form f[p].default is a compile-time error unless f denotes a function declaration where (1) p is a constant expression of type int and f declares an optional positional parameter at the position p, or (2) p is a constant expression of type Symbol and f declares an optional named parameter with the name given by the symbol.

If no error occurred then f[p].default is a constant expression whose value is the default value of said parameter.

A postfixExpression of the form C.m.default is treated as C.m[k].default when it occurs in a default value in a positional parameter declaration at the position k (an int), and as C.m[n].default when it occurs in a default value of a named optional parameter with the name corresponding to n (a Symbol). Similarly if it occurs in an actual argument at the position k respectively as an argument to a named parameter with the name n. In all other cases a compile-time error occurs.

A postfixExpression of the form f.default is treated as f[k].default when it occurs in a default value in a positional parameter declaration at the position k (an int), and as f[n].default when it occurs in a default value of a named optional parameter with the name corresponding to n (a Symbol). Similarly if it occurs in an actual argument at the position k respectively as an argument to a named parameter with the name n. In all other cases a compile-time error occurs.

A primary of the form super.default is treated as C.m.default if it occurs in the default value of an instance method named m such that C is the most specific superclass that declares a member named m.

A primary of the form default is treated as super.default if it occurs in the default value of of a formal parameter declaration of an instance method. It is treated as C.m.default if it is passed as an actual argument to a method invocation of m with receiver type C or C<T1 .. Tk>.

In all other cases, the occurrence of .default as a selector is a compile-time error.

The static type of an expression containing the selector .default is determined by the constant expressions that they denote.

Dynamic semantics

The dynamic semantics of any postfixExpression containing the selector .default is determined by the constant expression that they denote.

Discussion

Specify the default value in the function body

The main purpose of this proposal is to support a more flexible and maintainable way to deal with the default values of optional parameters at call sites. The frequently mentioned alternatives would include at least the following:

// Avoid using the built-in support for default values. Provide the default value
// dynamically in the body of the function.
void f([int? j]) {
  j ??= 42;
  ...
}

This approach does support a programmatic choice between passing and not passing the given actual argument: There is no difference between passing null and not passing anything. However, this means that we can never actually pass null to the body of the function (which would be a problem if null is a meaningful value for j).

Alternatively, when null is never a meaningful value of j, it implies that we're basically turning off null safety for that parameter at every call site: We may think that f(e) is an appropriate invocation of f, and the type checker will complain if the actual argument isn't guaranteed to be an int; however, it is actually just checked that e is guaranteed to be an int or null, and we may then silently use the default value because e is unexpectedly null.

If we had used void f([int j = 42]) {...} then we would have explicitly committed to use the default with f() and not to use the default with f(e), and with the feature proposed here we could use f(x ?? default) where x has type int? to specify the behavior where the default value is used when x is null.

First class functions

An interesting case to consider is first class functions. The proposal makes default values available based on static information, and this means that there is no support (unless we extend the proposal) for denoting default values of optional parameters of function objects. This may be particularly inconvenient in the case where the optionals are positional and we wish to pass a later one, but we only pass an earlier one because we have no other choice.

void g1([int i = 1, int j = 2]) {...}

void g2([int? i, int? j]) {
  i ??= 1;
  j ??= 2;
  ...
}

void main() {
  g1(default, 3); // Pass the actual default value followed by a non-default value.
  var g1var = g1; // But we can't do `g1var(default, 3)`.

  // Using the "Null is the same as not passing anything" style, that is, `g2`.
  g2(null, 3); // OK.
  var g2var = g2;
  g2var(null, 3); // Works!
}

We could of course consider having support for default value lookups on function objects (e.g., g1var[0].default). However, that is a significant extension of the proposal, and we would need to consider the implications (for instance, would this be possible to implement without incurring space/time costs for function objects that don't use this feature).

About the context sensitive denotation of default values

The abbreviated forms require that the declaration that provides the default value from an instance member must be located precisely. For example, super.default refers to a specific superclass C, and we can't get the default from any other superinterface unless we specifically ask for it using something like OtherSuperInterface.default. Similarly, C.m[0].default can only be used if C actually declares an m which is a method with optional positional parameters. It might be possible to allow this syntax to be used also with declarations inherited by C, if this is not ambiguous.

In general, we could rather easily allow the default value to be obtained from any other source (e.g., searching all superinterfaces and finding a unique value would be enough to make the mechanism work), but if we do that then the semantics gets harder to understand, and there's a greater danger that changes will occur by accident.

Another dynamic extension of this mechanism which would be very interesting to pursue is to use the actual default value even in the case where it is not statically known:

abstract class A<X extends num> {
  void foo([X x, String s]);
}

class B implements A<int> {
  void foo([int x = 2, String s = 'B-default']) {}
}

class C implements A<double> {
  void foo([double x = 2.5, String s = 'C-default']) {}
}

void main () {
  A<num> a = ... ? B() : C();
  a.foo(default, 'Not default');
}

This would be a compile-time error with the current proposal, but it could be supported in the case where the runtime supports a dynamic lookup of the actual default values of parameters (a bit like a vtable for each parameter).

Why are default values not dynamic?

For a discussion about this topic, please check out https://github.com/dart-lang/language/issues/2269#issuecomment-1703211435.

gaaclarke commented 2 years ago

Alternatively, you could do something similar by making default values const values that are named and can be used in any context.

For example:

void f3([int? j = 42]) {}
void forwardToF3([int? j = f3.j.default]) => f3(j);
print(f3.j.default);

You can do everything that the proposal says with the added benefits: 1) There is more context when it is used f3.j.default versus f3.default 1) It can be used in novel ways beyond forwarding messages. Maybe this is never used but it doesn't seem to hurt to allow the possibility.

eernstg commented 2 years ago

making default values const values that are named and can be used in any context

Yes, I agree that this would be useful, but that is actually what the proposal already does.

As an aside, it does not denote the default value of an optional positional parameter by name, it uses the position: it's f3[0].default rather than f3.j.default. The reason for this is mainly that it should not be a breaking change to change the name of a positional formal parameter.

So we can do this:

void f3([int? j = 42, String s = 'Hello!']) {}
void forwardToF3([int? j = f3.default, String s = f3.default]) => f3(j, s);
print(f3[0].default);

Note that it is possible, but not required, to specify the default values of the positional parameters by using f3[0].default respectively f3[1].default, because the position is taken to be the position where the ....default expression occurs if it is not specified.

AlexV525 commented 2 years ago

I would like to raise a concern about using default in the above examples. Methods like forwardToF3 do return f3 directly so it's reasonable since they have a reference connection (AFAIK). But print(f3[0].default) seems a little bit overhead. Maybe we can narrow the scope of using defaults only when they have connections. Directly return is a simple case, others like static/field methods can use them too. But no other usages can occur, or we'll have .default flying everywhere.

Also, asking with snippets:

/// We usually define a dialog in `StatelessWidget`.
class SomeDialog extends StatelessWidget {
  /// However, we'll hide its default constructor and use [SomeDialog.show] instead.
  const SomeDialog._({
    super.key,
    this.someField = 'test',
  });

  final String someField;

  static void show({
    required BuildContext context,
    String someField = default, // <-- Can use `default`.
  }) {
    //......
  }

  void test({
    String someField = default, // <-- `super` or `default`?
  }) {}

  void forwardShow({
    required BuildContext context,
    String someField = default, // <-- `super` or `default`?
  }) => show(context: context, someField: someField);
}
lrhn commented 1 year ago

This proposal has suffered from the passing of time. There is no longer a requirement that a subclass method has the same default value as a superclass method. That means that:

class C {
  void foo([int x = 0]) {}
}
class D extends C {
  void foo([int? x = null]) {}
}
void main() {
  C c = D();
  c.foo(C.foo[0].default);
}

will pass the wrong default value to the method.

(I think it's also missing the definition of the first example's g1(default, 3).)

eernstg commented 1 year ago

There is no longer a requirement that a subclass method has the same default value as a superclass method

This was never a requirement, violations were just warnings. However, if a designer wishes to maintain a discipline where no such discrepancies exist (and I certainly think that's a meaningful discipline to adopt), they can now do it in a maintainable manner:

class C {
  void foo([int x = 0]) {}
}

class D extends C {
  void foo([int x = super.default]) {}
}

What's the justification for using default parameter values to model the following?:

class C {
  void foo([int x = 0]) {}
}

class D extends C {
  void foo([int? x = null]) {}
}

C myFunctionReturningC() => D();

void main() {
  C c = myFunctionReturningC();
  c.foo(); // How does this work, check declaration ... ah, yes, it works just like `c.foo(0)` .. NOT.
}
jakemac53 commented 1 year ago

See https://github.com/dart-lang/language/issues/3305, this feature would definitely help for macros

yanok commented 1 year ago

This would help Mockito to get rid of the need of reviving default values too. Right now we have a problem, if a default value contains a private name.

OTOH, I could see people using private default values to send a signal: "There is a default value, but you are not supposed to inspect it".

eernstg commented 1 year ago

Not new. ;-)

class A {}

class _PrivateDefault implements A {
  noSuchMethod(_) => throw "Not implemented: Just a default receiver";
  const _PrivateDefault();
}

const _privateDefault = _PrivateDefault();

class B {
  void m([A a = _privateDefault]) {}
}

// In some other library.

class CheatingB implements B {
  Object? defaultValue;
  noSuchMethod(Invocation invocation) {
    defaultValue = invocation.positionalArguments[0];
    return null;
  }
}

void main() {
  var b = CheatingB();
  b.m();
  print(b.defaultValue); // Instance of '_PrivateDefault'.
}
jakemac53 commented 1 year ago

That is.... wow 🤣

yanok commented 1 year ago

Yeah, I know about this trick (and even considered applying it in mockito), that's why I wrote "not supposed" instead of "not allowed" :) It is still behind having to use nSM, which excludes non-test code hopefully. While explicit access will expose defaults to everybody.

eernstg commented 1 year ago

One consideration which has been raised several times about this proposal is that the expressions containing default as proposed in this issue are constant expressions. In particular, there is no way these expressions could yield the dynamically determined default value of a formal parameter of a given non-constant receiver. For example:

class A { void m([int i = 0]) {}}
class B1 extends A { void m([int i = 1]) {}} 
class B2 extends A { void m([int i = 2]) {}}

A a = DateTime.now().millisecondsSinceEpoch.isEven ? B1() : B2();

void main() => print(a.m.default[0]);

This is a compile-time error, there is no such thing as a.m.default[0] in this proposal.

Surely it could be added to the proposal. It may or may not be a costly operation to evaluate such expressions, but it is definitely not likely to be accepted into the language if it causes programs to be slower or bigger in ways that aren't directly associated with the feature itself (so it must definitely be "pay as you go").

If it is added then the static type would have to be a top type (such as Object?):

class A {
  void m([int x = 42]) {}
}

class B extends A {
  // Contravariant overriding is always allowed.
  void m([dynamic x = true]) {}
}

A a; // Assume that `a = B()` is executed at some point in time.

void main() {
  a.m.default[0]; // This expression must have static type `Object?`.
}

Moreover, it would not be straightforward to establish that any such value is a type correct argument to an invocation of any method. In particular, even if we do a.m(a.m.default[0]), it could still fail if a is a getter that evaluates to two distinct objects of type A-or-a-subtype at the two call sites in this expression. We'd need some kind of path dependent typing to make this type safe, which means that it is not relevant to Dart (that's a huge change).

In other words, the management of dynamically determined default values is inherently an unsafe enterprise. It is not obvious that it fits in with the current proposal, and I'm not convinced that it would be justified at all.

Arguably, it's not a good idea in the first place to have one statically known default value for any formal parameter, and then a different default value in the method implementation which is actually executed at run time. It does not make sense to announce in the signature of a method that the effect of calling that method and omitting that parameter is that the specified default value is provided, but at run time it's actually a completely different value (which may not even be found in any of the directly or indirectly imported libraries as seen from the call site).

In other words: Just say no! Don't "override" a default value by a different default value.

If we don't do that then there is no need to use any other default values than the statically known ones. This is already true for every function which is not a class instance method (those other functions never override anything), and I'd prefer if we make no attempt to support dynamic lookups of default values.

In the case where there is a perceived need to use different default values for different implementations of a given method, I'd recommend that the method claims the right to interpret a sentinel value (typically that would be null) and uses that value as the default value in the API. The method body could then replace that default value by "the real default value", which could be a constant or a dynamically evaluated expression as needed:

class A {
  void m([int? arg]) { arg ??= 42; ... }
}

class B implements A {
  void m([dynamic arg]) { arg ??= true; ... }
}

This means that the API will just reveal that null is used as the default value, and it is up to a DartDoc comment or similar sources of documentation to specify the effect of passing null (including the case where it is done by omitting the parameter in the invocation).

This means that A.m.default[0] is a constant expression with the value null, and that's indeed also what you need to pass in order to get the same effect as not passing the argument. In other words, it is now also possible to write a forwarder like this:

A theA;

void mOnTheA([int? arg = A.m.default[0]]) => theA.m(arg);

This means that mOnTheA() and mOnTheA(someValue) will faithfully forward the call to theA.m. We could have used the parameter declaration int? arg because A.m.default[0] is a constant expression whose value is null, but we might actually want to use the longer form shown above because it makes the forwarding semantics explicit. Also, if class A is modified such that A.m.default[0] can not be the default value of arg then we will get a compile-time error and we can fix the situation. Otherwise we might not notice that A is changed in a way that breaks mOnTheA.

lrhn commented 1 year ago

We could probably fix the noSuchMethod forwarders to forward the actual arguments, including whether no argument was passed at all, instead of copying inaccessible default values out of a library. It was definitely not intended as a way to snoop the default value.

The reason we didn't is that that would allow you to tell whether an argument was passed or not, which is the other side of this issue, and something we don't want to allow by itself, because it then makes forwarding arguments much harder.

The good solution would be to:

If we allow both, it should make forwarding no argument almost as easy as forwarding an argument value.

eernstg commented 1 year ago

We could probably fix the noSuchMethod forwarders to forward the actual arguments

Interesting! Some implementations would already create separate methods for every possible actual argument list shape (with something like foo$2 and foo$3 if foo takes 3 positional arguments, one of which is optional). Sounds like they would just need to call noSuchMethod directly, rather than calling "the version taking all arguments" passing the default value for every missing actual argument.

[but] that would allow you to tell whether an argument was passed or not, which is the other side of this issue, and something we don't want to allow by itself,

We usually also don't want to push developers in the direction of using noSuchMethod forwarders "because that's the only way you can do it", for almost any value of "it". ;-)

There is a third option (which is somewhat radical): Generate throwing rather than forwarding stubs for such methods.

However, I'm not totally convinced that snooping default values is so bad.

What could a "secret" default value allow a client to do (that they shouldn't do)? Mainly, they could call the given method, passing that default value rather than not passing anything, and the implementer of the method wouldn't be able to see the difference.

But we noted just a few lines up from here that the ability to see this difference is not a capability that we wish to support in the first place!! :grin:

jakemac53 commented 1 year ago

@iinozemtsev what does mockito actually use the default value for? Does it forward it on to some other method or is it actually capturing the value?

iinozemtsev commented 1 year ago

@yanok ^

yanok commented 1 year ago

@iinozemtsev what does mockito actually use the default value for? Does it forward it on to some other method or is it actually capturing the value?

We want to be able to set expectations with omitted optional arguments, like if we have

class C {
  int m(int x, [int y = 1]) => x + y;
}

We want when(mockC.x(any)) act the same as when(mockC.x(any, 1)), or to put it the other way, if a user sets expectation with when(mockC.x(any)) it should trigger both when mockC.x(val) and when mockC.x(val, 1) is called from the code under test. For that, we do actually capture the value, so we could use it while matching provided expectations with the actual calls.

At least that's the API mockito provides now. Of course, we could always say that from now on it's the users' job to always set expectations even for optional arguments explicitly, but I don't think it's very nice.

lrhn commented 1 year ago

Not new. ;-)

Waitaminute! If a no-such-method-forwarder uses the default value of the function it forwards for ... that assumes there is a default value, but since it's an nSM-forwarder, the method is definitely abstract, and doesn't have to have a default value.

So, what happens if it doesn't? Currently we use null as default value. (Because it's the default default-value?)

class C {
  void foo([int x]);
  Object? noSuchMethod(i) {
    print(i.positionalArguments[0]);
  }
}
void main() {
  C().foo(); // Prints `null` on VM
}

This prints null on the VM, gives an error in DartPad:

TypeError: null: type 'JSNull' is not a subtype of type 'int'

Probably from when it tries to assign the default value null to the int parameter of the forwarder.

That's not unsound, because the null only goes into an untyped List<Object?>, but it suggests that there is a concrete function with a declaration of void foo([int x = null]) { ... }, which at least risks breaking some invariants.

I don't know of any way to get an concrete method to inherit the invalid default value. So again not a soundness issue, not one I can find.

eernstg commented 1 year ago

Waitaminute! ... doesn't have a default value ... Currently we use null

Good catch! ;-)

Conceptually, I'd prefer to catch that situation rather than silently use null. I created https://github.com/dart-lang/language/issues/3331 to propose a couple of ways we could do that.

lucaesposto commented 10 months ago

Correct me if i'm wrong: would this proposal solve following issues? Are this related?

1. Default values duplication

Extending a class which constructor parameters are non-nullable and have default values:

class A {
  const A({this.prop1 = 'v1'}); 
  final String prop1;
}

class B extends A {
  const B({this.prop1 = 'v1'});  // we desire parent default as default, but we have to redefine it
  final String prop1;
}

We are forced to duplicate default values definition. This adds boilerplate and/or sources of bugs if a default value is modified. In a chain of extensions the problem increases even more.

What is desired is something like:

[ class A ... ] 

class B extends A {
  const B({this.prop1 = super.prop1.default}); // or any other syntax that defines the concept of "use parent default"
  final String prop1;
}

2. Ignore passed argument value if null

Another related issue, that needs a different solution, arises when you have to pass a nullable value to a non-nullable parameter with defined default and you want to use default value if null:

class CustomButton extends StatelessWidget {
  final void Function()? onPressed;
  final Widget? child;
  final Clip? clipBehavior;

  const CustomButton({
    required this.onPressed,
    required this.child,
    this.clipBehavior,
  });

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: onPressed,
      child: child,
      clipBehavior: clipBehavior ?? Clip.none, // clipBehavior is non-nullable, Clip.none is its default value
    );
  }
}

In this case, desired solution is something like:

class CustomButton extends StatelessWidget {
  final void Function()? onPressed;
  final Widget? child;
  final Clip? clipBehavior;

  const CustomButton({
    required this.onPressed,
    required this.child,
    this.clipBehavior,
  });

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: onPressed,
      child: child,
      clipBehavior?: clipBehavior, // this should behave like if clipBehavior was not provided in the function call (if it's null)
    );
  }
}
stan-at-work commented 1 month ago

This seems like something that can be fixed with macros, add a default prop with a macro and access it like: CLASS.prop1.default