dart-lang / dart-lang/language

Provide explicit access to the default values of parameters

Open
#2,269 21 comments 22 reactions 0 assignees View on GitHub
small-feature
Dominant language
TeX
Stars
2.9k
Forks
239
Avg merge
2d 18h
Merged PRs (30d)
14

Description

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:

```dart
// 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:

```dart
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:

```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:

```dart
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:

```dart
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:

```dart
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`.

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:

```dart
// 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.

```dart
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:

```dart
abstract class A {
void foo([X x, String s]);
}

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

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

void main () {
A 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.

Contributor guide

Open the contributing guide

Research direction

Start with the proposal's Syntax, Static analysis, and Dynamic semantics sections, then review the surrounding Dart language specification process. There are no files or tests named in the issue. Done would require an agreed language-design decision and corresponding specification work, rather than a self-contained code change.

Written by the indexing model from the issue text.

Assessment

Tech stack
dart
Domain
compilers
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Stale
Clarity
Mostly clear
Newbie friendliness
20/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.