Open eernstg opened 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.
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.
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 default
s 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);
}
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)
.)
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.
}
See https://github.com/dart-lang/language/issues/3305, this feature would definitely help for macros
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".
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'.
}
That is.... wow 🤣
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.
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
.
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
-argument with no else
.If we allow both, it should make forwarding no argument almost as easy as forwarding an argument value.
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:
@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?
@yanok ^
@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.
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.
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.
Correct me if i'm wrong: would this proposal solve following issues? Are this related?
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;
}
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)
);
}
}
This seems like something that can be fixed with macros, add a default prop with a macro and access it like: CLASS.prop1.default
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:
This does work: In order to get the same behavior for an invocation of
forwardToF1
as the one we get from an invocation off1
, 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:This is true in all four cases, but we need to copy the default value expression from
f1
toforwardToF1
(and maintain the consistency whenever there's a reason to change the default value off1
), 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 typeOptimizer
thenfunction.optimize(optimizer)
could mean "optimizefunction
using the givenoptimizer
", with the special case "don't optimizefunction
at all" whenoptimizer
evaluates to null, andfunction.optimize()
could mean "optimizefunction
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:
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 callf2
with an argument whose type isint?
and the parameter type isint?
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:
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]
andj ??= 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:
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:
Proposal
Syntax
The Dart grammar is changed as follows:
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
meansf[0].default
if it occurs in the first positional argument in an invocation off
, and so on. This means that we will automatically get the most relevant default value, based on the location wheredefault
is used. But we can specify any default value we want, in any location, if needed.A
postfixExpression
of the formC.m[p].default
is a compile-time error unlessC
is a type literal denoting a class, mixin, or extension (coming: or view) that declares a method namedm
where (1)p
is a constant expression of typeint
andm
declares an optional positional parameter at the positionp
, or (2)p
is a constant expression of typeSymbol
andm
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 formf[p].default
is a compile-time error unlessf
denotes a function declaration where (1)p
is a constant expression of typeint
andf
declares an optional positional parameter at the positionp
, or (2)p
is a constant expression of typeSymbol
andf
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 formC.m.default
is treated asC.m[k].default
when it occurs in a default value in a positional parameter declaration at the positionk
(anint
), and asC.m[n].default
when it occurs in a default value of a named optional parameter with the name corresponding ton
(aSymbol
). Similarly if it occurs in an actual argument at the positionk
respectively as an argument to a named parameter with the namen
. In all other cases a compile-time error occurs.A
postfixExpression
of the formf.default
is treated asf[k].default
when it occurs in a default value in a positional parameter declaration at the positionk
(anint
), and asf[n].default
when it occurs in a default value of a named optional parameter with the name corresponding ton
(aSymbol
). Similarly if it occurs in an actual argument at the positionk
respectively as an argument to a named parameter with the namen
. In all other cases a compile-time error occurs.A
primary
of the formsuper.default
is treated asC.m.default
if it occurs in the default value of an instance method namedm
such thatC
is the most specific superclass that declares a member namedm
.A
primary
of the formdefault
is treated assuper.default
if it occurs in the default value of of a formal parameter declaration of an instance method. It is treated asC.m.default
if it is passed as an actual argument to a method invocation ofm
with receiver typeC
orC<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:
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 forj
).Alternatively, when
null
is never a meaningful value ofj
, it implies that we're basically turning off null safety for that parameter at every call site: We may think thatf(e)
is an appropriate invocation off
, and the type checker will complain if the actual argument isn't guaranteed to be anint
; however, it is actually just checked thate
is guaranteed to be anint
ornull
, and we may then silently use the default value becausee
is unexpectedly null.If we had used
void f([int j = 42]) {...}
then we would have explicitly committed to use the default withf()
and not to use the default withf(e)
, and with the feature proposed here we could usef(x ?? default)
wherex
has typeint?
to specify the behavior where the default value is used whenx
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.
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 superclassC
, and we can't get the default from any other superinterface unless we specifically ask for it using something likeOtherSuperInterface.default
. Similarly,C.m[0].default
can only be used ifC
actually declares anm
which is a method with optional positional parameters. It might be possible to allow this syntax to be used also with declarations inherited byC
, 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:
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.