It is inconvenient that the default value of a formal parameter is constant
Currently, default values are constant expressions, which implies that it is impossible for a function declaration to use the default value mechanism to specify a non-trivial computation which fills in an actual argument value when it is omitted at the call site.
One starting point for addressing this request is the reference to Kotlin given in #137.
I am curious if this issue has been reconsidered/revisited due to the incoming non-nullable types, default values of parameters that are non-nullable would require that any user created classes/types would have to have a const value just to be able to default it. which may not always be possible.
We have made some changes to the rules about default values for parameters.
In particular, named parameters can be required, in which case there is no need and no permission to specify a default value. This mechanism is a language mechanism, it's taken into account when determining whether one function is a subtype of another one, and hence it is enforced (so it's quite different from the @required metadata which has been around for several years).
Moreover, abstract methods do not have to have a default value for an optional parameter, even when its type is non-nullable, because the run-time semantics will never rely on such default values anyway.
However, this actually doesn't make much difference here: This issue is essentially about allowing default values to be normal expressions in some scope (maybe: the instance or static scope of the enclosing class, or the library scope, depending on where the parameter is declared). Those expressions would then be evaluated at each invocation where the corresponding parameter is omitted.
The requirement that a default value must be assignable to the type of the corresponding parameter remains unchanged, and the requirement that a default value must be specified iff the dynamic semantics may use it also remains unchanged. So the changes that we're introducing along with non-nullable types will just carry over directly to the kind of defaults that this issue is targeting.
Note that https://github.com/dart-lang/language/issues/951 makes a similar request.
And https://github.com/dart-lang/language/issues/429. My comments there also apply to #951.
You can tell whether a parameter was passed or not by having side effects
But assuming we care a lot about allowing a copyWith method that does actually allow setting a null-able variable to null without the extensible overhead of fairly obscure code generation, how would we ever resolve this?
I personally think it is incredibly desirable to create such copyWith methods and the benefits would be felt throughout the entire language because writing models will become much, much easier.
This is especially important in libraries which do not wish to use other code generating libraries or places where its currently simply impossible to set a null-able variable to null with copyWith like in the Uri class.
So given A: "we wish to default to non-constants" B: "we do not wish to expose information on whether a prameter is passed"
Is there generally a solution that would satisfy both A and B or is the current conclusion that we are simply trading B for A? I believe giving up B for A if we cannot have both would be the better outcome.
No, there is no design which satisfies both A and B. Trivially, if the default value expression is not constant, so A, it can have side effects, which means it's possible to detect whether it has run, which precludes B.
I don't have a problem with allowing you to programmatically detect whether an argument was given or not, if you also have a way to programmatically decide whether to pass an argument or not, without requiring a combinatorial explosion of individual call expressions.
Having the former without the latter is what makes forwarding optional parameters nigh impossible to do precisely. With both, it can (and should) be a one-liner.
Let's give a concrete design for non-constant default values.
Proposal
-
Allow non-constant expressions as default value expressions. (That is, remove the current requirement that they are constant expressions.)
-
The lexical scope of the expression is the parameter scope.
-
It's a compile-time error if the default value expression of a parameter refers to a parameter which is not declared earlier in the parameter list. Cannot refer to later variables, cannot refer to itself.
-
It's a compile-time error if the default value expression contains an assignement to any parameter of the function.
-
The static type of a default expression must be assignable to the declared type of the parameter.
-
It's a compile-time error if a default value expression of a
constconstructor is not a potentially constant expression, or if its assignable only using a non potentially constant coercion. -
During function invocation, while binding actuals to formals, parameters are processed in source order, each parameter variable being bound to its value in that order, until all parameter variables are bound.
-
If an optional parameter has no corresponding argument, its default value expression is evaluated to a value in the parameter scope (where all prior parameters have now been bound to a value), and then the parameter variable is bound to the value of that evaluation, or coerced if it its static type was assignable to, but not a subtype of, the parameter's type. If it throws, the invocation throws.
Async
We can allow an asynchronous function to have await in the default-value expressions.
We probably should allow that, otherwise it'll feel non-orthogonal.
That does mean that an invocation of an asynchronous function can introduce an asynchronous gap before it even reaches the function body.
That's not particularly new or worrisome. If the first line of the body had been arg ??= await something(), it would behave exactly the same.
Since constructors cannot be async, at least we won't have asynchronous interruptions in the middle of creating and initializing an object.
Consequences
That will trivially allow a copyWith function like:
class Point
final int x, int y;
final Color? color;
Point(this.x, this.y, {this.color});
Point copyWith({int x = this.x, int y = this.y, Color? color = this.color}) =>
Point(x, y, color: color);
}
Because of the "no assignment" rule, the compiler doesn't need to have the incoming values stored in any particular place. It can probably be loosened, but it makes it much easier to read when one initializer cannot change a previously initialized variable.
However, because expressions can have side effects, this feature allows determining whether a parameter was passed, even if it has the same value as the eventual default value.
bool __wasPassed = true;
// Can be read only once, then it resets.
// Should be read to reset it, before the next call to `foo`.
bool get _wasPassed {
var result = __wasPassed;
__wasPassed = true;
return result;
}
Object? _notPassed() {
_wasPassed = false;
return null;
}
void foo([Object? arg = _notPassed()]) {
if (_wasPassed) {
print("Passed: $arg");
} else {
assert(arg == null);
print("Not passed");
}
}
void main() {
foo(); // Not passed
foo(null); // Passed: null
foo(); // Not passed
foo(null); // Passed: null
}
That's so cumbersome it's probably not going to be used much, which is why I don't necessarily think we need to allow computationally deciding whether to pass an argument. It'll be an anti-pattern to rely on distinguishing a passed argument from a default value, because it makes it harder for callers to choose the behavior they want.
if you also have a way to programmatically decide whether to pass an argument or not, without requiring a combinatorial explosion of individual call expressions.
Control flow in argument lists would do that:
forward({
String? a,
String? b,
String? c,
String? d,
String? e,
String? f}) {
original(
if (?a) a: a,
if (?b) b: b,
if (?c) c: c,
if (?d) d: d,
if (?e) e: e,
if (?f) f: f,
);
}
Control flow in argument lists would do that
"Parameter elements" was absolutely what I was hinting at when writing that sentence.
(But ?x may not be the best syntax for checking if a parameter is set when we also use ?e as a null-aware element.
It makes ? x ? v1 : v2 ambiguous. At least one of the meanings will need parentheses.)
Oh, yes, agreed that ? is probably not the right syntax.
i'm against that particular example
I would prefer, as stated here: https://github.com/dart-lang/language/issues/3680#issuecomment-2586206825
to instead use late for checking if something was passed. (possibly with if (late? lateValue) lateValue = Value())
beyond that, I do want non-const arguments, and late can be used to avoid executing expensive defaults at runtime, as otherwise it would have a weird mismatch with normal variables.
I would expect that foo(Bar bar = Bar()) and Bar bar = Bar() to behave identically and construct a Bar(). maybe that's not a problem, and having non-late parameters not call their initializer is an inconsistency we're okay with, but I thought i'd bring it up.
i mean, think about it. parameter lists just look like normal variables. (and can even be final!) why shouldn't they just be treated the same? (required notwithstanding)
possibly; we keep const defaults, and non-const late defaults
I would expect that
foo(Bar bar = Bar())andBar bar = Bar()to behave identically and construct aBar().
Parameter default values are not the same as variable initializers. They are not executed unconditionally, but only if a value isn't given by an argument.
A more (but not totally) precise syntax would be Bar bar ??= Bar() since the variable is only given that value of it doesn't have one already... but not if it was explicitly given the value null.
Sure. Like I said, we might be okay with that - especially since the const values can't really have unexpected behavior due to the arguably inconsistent syntax.
That's why I say, leave the const defaults, but enable the use of late for non-const defaults, cleanly sidestepping any side-effect-related confusion that might occur otherwise. (One might expect that = to invoke the function directly, but everyone knows it's only involved on access for late values)
Any particular reason why this didn't make it in the language? Compile time constants are very restrictive.
The reason is likely that a nullable parameter, ({Foo? foo}), and starting the function with foo ??= createFoo(); is good enough, so the effort spent on allowing a non-constant default value in the parameter list (with all the little details to get right), has been better spent on something that doesn't have a similar good enough alternative.
The reason is likely that a nullable parameter,
({Foo? foo}), and starting the function withfoo ??= createFoo();is good enough, so the effort spent on allowing a non-constant default value in the parameter list (with all the little details to get right), has been better spent on something that doesn't have a similar good enough alternative.
its not though, because null has its own meaning.
The cases where a null argument needs to be treated differently from no argument are few and far between. Luckily, because null is the canonical way to represent "no value".
So in most cases, using a nullable parameter with ??= is good enough.
Not perfect, but to be perfect, we might also need a way to distinguish whether a parameter was passed or not.
So in most cases, using a nullable parameter with ??= is good enough.
I dont agree with this. Many Models have nullable fields, so the expression of "copy this model, with this field nulled out" is extremely common.
Right now, this problem is unsolved. The ecosystem has instead adapted workarounds. One such work around is freezed which employes essentially inheritance constructor magic to make this feature of distinguishing between default value and null available.
This leads to everyone depending on freezed (or an equivalent package), because a manual implementation is so tedious. But package authors cannot do that. So they must do the other magic way of implementing this feature. In the latest version of a package I maintain, I introduced:
typedef Defaulted<T> = FutureOr<T>;
final class Omit<T> implements Future<T> {
const Omit();
// coverage:ignore-start
@override
noSuchMethod(Invocation invocation) => throw UnsupportedError(
'It is an error to attempt to use a Omit as a Future.',
);
// coverage:ignore-end
}
To facilitate:
PagingState<PageKeyType, ItemType> copyWith({
Defaulted<List<List<ItemType>>?>? pages = const Omit(),
Defaulted<List<PageKeyType>?>? keys = const Omit(),
Defaulted<Object?>? error = const Omit(),
Defaulted<bool>? hasNextPage = const Omit(),
Defaulted<bool>? isLoading = const Omit(),
});
This is necessary because there is just no other good way to express "copy the PagingState with Error being null" except to recreacte the whole object through its constructor manually.
This is obviously not a good solution; It abuses how FutureOr is the only union type and it does not scale. Every developer who wants a solution like this must implement it from scratch.
Because of this, I actually believe this issue is extremely important. It is not about being perfect, it is about not locking everyone into using a package that uses build runner and obscure language features under the hood or having to abuse unique type structures in unintended ways.
Like I shared in another issue, there's a reasonable simplified syntax for copyWith:
Model Function({int? value} get copyWith {
return ({Object? value = const _Sentinel()}) {
return Model(
value: value == const _Sentinel() ? this.value : value as int?,
);
};
}
Cf https://github.com/dart-lang/language/issues/314#issuecomment-3058739700.
This can be used in all classes, without a dependency on any third-party package, and is not too hard to write by hard.
This trick is fairly common. You'll see it in the Dart SDK. For example, Object.hash uses it.
Thats very impressive! But I would still consider this a workaround, just like using freezed or abusing FutureOr, and it requires considerable boilerplate compared to having non-constant default parameters (Like creating a _Sentinel class).
Nothing about the code above is obvious to a new developer coming to dart and looking to write a copyWith method. Every package I come across seems to solve this issue differently. Would making copyWiths easy and readable not still be juistification to implement this feature? Looking at these work arounds, it just does not quite seem to me that this issue is "sovled" as you have put it in 314.
I use ValueGetter to solve this, but I agree that it is not the ideal solution.
AppState copyWith({
final ValueGetter<String?>? value,
}) {
return AppState(
value: value != null ? value() : this.value,
);
}
state.copyWith(value () => 'new_value');
state.copyWith(value () => null);
@clragon The fact is that there's very little use for this outside of copyWith and a few specific niche cases.
And solving this at the language language, while better, wouldn't be game-changer.
The solution I shared is IMO 80% of the way there in terms of usability. And a few lint rules could bring it to 90% (such as detecting incorrect casts).
And the discoverability issue is a non-issue IMO. If folks don't know that this trick exists, we can just write a blogpost about it.
@rrousselGit
And the discoverability issue is a non-issue IMO
I have been here for almost 5 years and I have never seen this technique, so purely anecdotally I would wager the average user is probably entirely unaware of it. It's also not very obvious for anyone why they would do it this way. The culture around this issue is doing it either in an inferior way or with a package. Indeed the implementation you have shown is quite simple, compared to other workarounds, but i would argue its quite bad developer experience. If this was a language feature, the implementation would not only be trivial but also just feel natural. new developers could write correct and useful code with almost zero effort here. Correct-by-default.
The fact is that there's very little use for this outside of copyWith and a few specific niche cases
I would say there is probably a reasonable amount of constructors that currently use ternaries, akward syntax and the constructor list to express non-constant default values. For all of those, which do not require any further logic, this feature could provide both brevity and clarity. Such a change could synergize with the primary constructor on classes and the private named parameters proposals, making it possible to write concise class defintions like
class MyConfig({
var List<String> hosts = List.unmodifiable(['github.com', 'flutter.dev']),
});
Alone being able to default to non-constant List objects is probably a very welcome addition. Again I believe that this is still worth pursuing for a considerable benefit in quality of life.