In Kotlin every data class is provided with a copy method. It copies the object and optionally it allows for setting new values. Here is an example:
import java.time.LocalDate
data class Todo(
val body: String = "",
val completed: Boolean = false,
val dueDate: LocalDate? = null
);
fun main() {
val todo = Todo(body = "Do this", dueDate = LocalDate.now().plusDays(1));
// user decide to remove the dueDate
print(todo.copy(body = "Investigate this some day", dueDate = null));
}
The copy method can be implemented like this in Kotlin:
fun copy(
body: String = this.body,
completed: Boolean = this.completed,
dueDate: LocalDate? = this.dueDate
): Todo {
return Todo(body, completed, dueDate);
}
However, doing the same in Dart doesn't work, as in Dart default values of parameters must be compile time constant:
class Todo {
final String body;
final bool completed;
final DateTime dueDate;
Todo({this.body = "", this.completed = false, this.dueDate});
Todo copy({
String body = this.body,
bool completed = this.completed,
DateTime dueDate = this.dueDate,
}) {
return Todo(body: body, completed: completed, dueDate: dueDate);
}
}
To allow for "default values" that are non-constant, often the following is recommended:
class Todo {
final String body;
final bool completed;
final DateTime dueDate;
Todo({this.body = "", this.completed = false, this.dueDate});
Todo copy({
String body,
bool completed,
DateTime dueDate,
}) {
body ??= this.body;
completed ??= this.completed;
dueDate ??= this.dueDate;
return Todo(body: body, completed: completed, dueDate: dueDate);
}
}
In many cases this works well. It's different in a subtle way, passing null has a special meaning now. It means, "give me the default value, whatever that is". This works well for "non-nullable" variables, as null can now be used to mean something different. But also for nullable variables if the default value is null (the two meanings of null overlap now).
However, it makes one case impossible, if you have a "true" nullable variable with a non-null default value. In this case, dueDate is truely nullable. null means here that there exists no dueDate for this todo. However, this makes it impossible to use the copy method to remove the due date of the todo:
main() {
final todo = Todo(body: "Do this", dueDate: DateTime.now().add(Duration(days: 1)));
print(todo.copy(body: "Investigate this some day", dueDate: null));
// prints:
// {
// "body": "Investigate this some day",
// "completed": false,
// "dueDate": "2018-12-18 11:11:11.607"
// }
}
https://dartpad.dartlang.org/ccf1d10f1e5279ba93eef24c018b6290
In summary: It is impossible in Dart to give a "nullable" optional parameter a default non-constant value.
I think the most obvious way to solve this would be to allow optional parameters to have a non-constant default value. What is the reason for this restriction? I can not find any language with the same restrictions for the default values of parameters.
I created #140 to express the general request that the default value mechanism should be less rigid than it is today (the default value must be constant). With that, this issue can be considered to be a response to #140, suggesting that we consider ideas from Kotlin as a starting point for a more expressive notion of default values.
We have discussed this topic earlier (allowing default values to be non-constant). Because of this, default values were _not_ included when we defined constant contexts, so we are in a sense preparing for that kind of generalization.
See also https://github.com/dart-lang/sdk/issues/25572 for some earlier discussions on a related topic.
coming from Kotlin this is really cumbersome, i'd also appreciate to implement a simple copy mechanism
What is the reason for this restriction?
I believe the original motivation is that it allows the compiler to check that an overriding method has the same default value as the method it overrides since the default values for both can be computed at compile time. Consider:
class A {
int method([int n = 1]) => n;
}
void test(A a) {
print(a.method());
}
A user who sees that code and looks up the declaration of method() on A probably assumes that since they are omitting the parameter, the value that it gets will be 1. But what they don't know is that their program is actually doing this:
class B extends A {
int method([int n = 2]) => n;
}
main(List<String> args) {
test(B());
}
The override in B has a different default. This is pretty confusing so Dart reports a warning if the default values don't match. It's not clear that this restriction actually carries its weight, though, and we have considered loosening this to allow non-const expressions there.
In parallel, we have a discussion on the same issue in sdk
Specifically, it transpired that
You can only pass arguments which match the type of the parameter. That means that you cannot pass null as an argument to the func written here because it needs an int. You can omit passing an argument, in which case the function will get the default value instead.
So, "null" doesn't have a special meaning "use default" (BTW, why do you think it does?). How to express that meaning is exactly the question under discussion in the cited thread.
See also this comment
We actually can do a proper copyWith by using default values.
There are two tricks available:
a combination of an abstract class + factory constructor, as showcased in a code-generator I released recently – see the generated file: https://github.com/rrousselGit/freezed
using FutureOr:
class _ConstFuture<T> implements Future<T> {
const _ConstFuture();
@override
dynamic noSuchMethod(Invocation invocation) {
return super.noSuchMethod(invocation);
}
}
class Example {
int a;
String b;
Example copyWith({FutureOr<int> a = const _ConstFuture(), FutureOr<String> b = const _ConstFuture()}) {
assert(a is int || a is _ConstFuture);
assert(b is String || b is _ConstFuture);
return Example()
..a = a is _ConstFuture ? this.a : a as int
..b = b is _ConstFuture ? this.b : b as String;
}
}
Very creative, but surreal.
I was trying to put it in the same context where generated constructors reside. The rationale is simple: if we have a justification for generated constructors, the same justification applies to copy constructors like copyWith. For, in a general case, you need to list all the fields (which are many) again in your manually written copyWith method.
Well, I may be biased but I feel that https://github.com/rrousselGit/freezed solves most problems in a very natural syntax.
The FutureOr variant is more of a work-around for all other situations that have a syntax different from freezed.
A FutureOr based copyWith could easily be generated as an extension method for example.
A purist will protest against FutureOr approach by arguing that the signature of the copyWith implies you can pass either a Future or a value, but an attempt to pass a Future will cause a runtime error.
IMO, the very fact that you have to be creative to trigger the use of default values adds extra weight (and urgency) to an argument in favor of a general typesafe solution, before these unconventional methods become popular :-)
There's another, more subtle, problem with the copyWith implementation: the use of the instance of private class as the default value. Suppose you want to create a copy of Example, but parameters "a" and "b" are obtained from some computation, and the values you receive are nullable (null stands for "useDefault"). A simple scenario: you have a map <string, int?>{"a": 1, "b":null} and you'd like to translate it into the call of copyWith:
var y = x.copyWith(map["a"] ?? useDefault, map["b"] ?? useDefault);
But you can't really say useDefault - you don't know what to pass as a token for "useDefault", and even if you knew, you don't have access to the private class _ConstFuture.
We have discussed the ability to access default values explicitly a number of times. One approach is to use null and let that stand for "please pass the default value" (but that clashes with other uses of null, that is, it doesn't work well for a parameter whose type is nullable).
The approach that I usually recommend is to have explicit syntax. With suitable defaults (no pun intended, of course). We could then make SomeClass.copyWith.default[0] denote the default value of the first positional parameter of copyWith as declared in SomeClass; we could omit SomeClass and copyWith when it is used in an invocation of copyWith on a receiver whose static type is SomeClass, and we could omit [0] when it's used in the first positional argument. We would then have this:
var y = x.copyWith(map["a"] ?? default, map["b"] ?? default);
A better solution, in my opinion, would be the if expression:
var value = x.copyWith(x: if (condition) value)
Which would be equivalent to:
var value = condition
? x.copyWith(x: value)
: x.copyWith();
@eernstg :
passing null as a token for "default value" is, as you said, fraught with unintended consequences. Saying that those unintended consequences were accounted for won't be enough to pacify the critics of the language. But it's not only that. There are 2 other arguments against the idea: 1) it doesn't exactly fit into the concept of strong typing, and 2) it's a breaking change. Currently, null is not treated that way, so
foo([int x=0])=>print(x);
foo(null);
prints null, not 0.
The idea of SomeClass.copyWith.default[0], with natural shortcuts, is indeed less error-prone IMO. Still, a purist would point out that in the expression
var y = x.foo(map["a"] ?? default, map["b"] ?? default);
the value substituted for "default" is based on the static type of x, which is not necessarily the same as the dynamic type of x, where the default value can be overridden. This kind of override would probably be quite rare in practice, but still...
Also, it's not exactly clear why the index in SomeClass.copyWith.default[0] should be written in the case of named parameters. Is it ["a"] or [#a] or what? The index can't be numeric in general, b/c named parameters can be re-ordered in the next version of the source, which is not a breaking change otherwise (AFAIK).
@rrousselGit
var value = x.copyWith(x: if (condition) value)
Indeed, I was wondering why this was not done at the time when collection-if was introduced. I vaguely remember something about optional positional parameters - e.g. whether to shift the list or not if some parameter was omitted due to conditional if, but it's difficult to see it as a really serious argument. Or maybe the reason for not doing it was completely different :-)
@tatumizer wrote:
"default" is based on the static type of x
Right, we have made it a warning (though not an error) to override such that a default value of a parameter changes, so that kind of override should indeed be rare. ;-)
Also, it's not exactly clear .. index .. named parameters
We could use the name to access the default value of a named parameter: default.a.
@eernstg : Thanks! Yes, it makes sense now. :-)
Most helpful comment
A purist will protest against FutureOr approach by arguing that the signature of the
copyWithimplies you can pass either a Future or a value, but an attempt to pass a Future will cause a runtime error.IMO, the very fact that you have to be creative to trigger the use of default values adds extra weight (and urgency) to an argument in favor of a general typesafe solution, before these unconventional methods become popular :-)