Description
This strawman addresses #961 and partially addresses #314. It's just a strawman right now, so I'm putting it right in the issue here. If it gets traction, I'll move this text to a real proposal document.
Motivation
The #1 open feature request for Dart is data classes. This is a Kotlin feature that gives you a lightweight way to define a class with instance fields and value semantics. You give the class a name and some fields and it implicitly a constructor, equals()
, hashCode()
, destructuring support, and copy()
. (Scala's case classes are similar.)
This proposal relates to the last method, copy()
. Several languages that lean towards immutability have language or library features to support "functional update". This means creating a new object that has all of the same state as an existing object except with a few user-specified field values replaced. In Kotlin, it looks like:
data class Name(val first: String, val last: String?)
fun main() {
var tom = Name("Tom", "Petty")
var jones = tom.copy(last = "Jones") // <--
println(jones)
}
On the marked line, we create a new Name
object that has the same state as Name("Tom", "Petty")
except that the last name has been replaced with "Jones". (You might think code like this is odd, but it's not unusual.)
You could imagine a similar API in Dart, here implemented manually:
class Name {
final String first;
final String? last;
Name(this.first, this.last);
Name copy({String? first, String? last}) =>
Name(first ?? this.first, last ?? this.last);
}
main() {
var tom = Name("Tom", "Petty");
var jones = tom.copy(last: "Jones"); // <--
print(jones);
}
This works fine. But note that the last
field is nullable. Perhaps you want to create a new Name
by removing a last name. This works fine in Kotlin:
var elvis = Name("Elvis", "Costello")
var theKing = elvis.copy(last = null)
println(theKing) // "Name(first=Elvis, last=null)"
But the corresponding Dart version does not:
var elvis = Name("Elvis", "Costello");
var theKing = elvis.copy(last: null);
print(theKing);
Here, the resulting object still has last name "Costello". The problem is that in Dart, there is no way to tell if a parameter was passed or not. All you can determine was whether the default value was used. In cases where the parameter is nullable and the default is also null
, there's no way to distinguish "passed null
" from "didn't pass a parameter".
We are working on adding macros to Dart to let packages automate generating methods like ==()
, hashCode
, etc., but macros don't help here. Macros can still only express the semantics that Dart gives you. Even with a very powerful macro language, you can't metaprogram a copyWith()
method that handles update of nullable fields correctly.
This proposal sidesteps the issue by adding an expression syntax to directly express functional update. It is modeled after the with
expression in C# 9.0, but mapped to Dart syntax and generalized to work with any class.
With expressions
A with expression looks exactly like a regular method call to a method named with
:
class Name {
final String first;
final String? last;
Name({this.first, this.last});
}
main() {
var tom = Name(first: "Tom", last: "Petty");
var jones = tom.with(last: "Jones"); // <--
print(jones);
}
The grammar is simply:
selector ::= '!'
| assignableSelector
| argumentPart
| (`.` `?.`) `with` arguments // <-- Added.
The semantics are a straightforward static desugaring to a constructor call:
-
Determine the static type
T
of the left-hand receiver expression. -
Look for an unnamed constructor on that class. It is a compile-time error if
T
is not a class type with an unnamed constructor. -
Evaluate the receiver expression to a fresh variable
r
. -
Generate a call to
T
's unnamed constructor. Any arguments in the parenthesized argument list afterwith
are passed to directly to it. -
Then, for each named parameters in the constructor's parameter list:
-
If an explicit argument with that name is passed, pass that.
-
Else, if
T
defines a getter with the same name, invoke the getter onr
and pass the result to that parameter. It is a compile-time error if the type of the getter is not assignable to the parameter's type.
-
-
It is a compile-time error if any required positional or named parameters do not end up with an argument.
-
If the token before
with
is?.
, then wrap the entire invocation in:(r == null ? null : <generated constructor call>)
The result is an invocation of the receiver type's unnamed constructor. That produces a new instance of the same type. Any explicit parameters are passed to the constructor. Any absent named parameters that can be filled in by accessing matching fields on the original object are.
The problem with distinguishing "passed null
" from "didn't pass a parameter" is addressed by the "If an explicit argument..." clause. We use the static presence of the argument to determine whether to inherit the existing object's value, and not the argument's value at runtime.
This desugaring only relies on the getters and constructor parameter list, so it works with any class whose shape matches. Most classes initialize their fields using initializing formals (this.
parameters), so any class that does so using named constructor parameters can use this syntax.
The desugaring does not rely on any properties of the class that are not already part of its static API, so using with()
on a class you don't control does not affect how the class's API can be evolved over time. (For example, the behavior does not depend on whether a particular constructor parameter happens to be an initializing formal or not.)
If records are added to Dart, we can easily extend with
to support them, since they already support named fields and named getters.
The main limitation is that this syntax does not work well with classes whose constructor takes positional parameters. From my investigation, that's about half of the existing Dart classes in the wild. But my suspicion is that this pattern is most useful for classes that:
- Are immutable.
- Have a fairly large number of fields.
- Where it's meaningful to create an instance from various subsets of those fields.
Classes that fit those constraints are conveniently the classes most likely to use named parameters in their constructor. Also, much of the benefit of this feature is not having to write the argument names when constructing a new instance. Constructors with only positional arguments are already fairly terse.
Null-aware with
The proposal also supports a null-aware form:
Person? findPerson(String name) => ...
var jones = find("Tom")?.with(last: "Jones");
print(jones);
This is particularly handy, because the code you have to write in the absence of that is more cumbersome since you may need to cache the intermediate computation of the receiver:
Person? findPerson(String name) => ...
var maybeTom = find("Tom");
Person? jones;
if (maybeTom != null) {
jones = Person(first: maybeTom.first, last: maybeTom.last);
}
print(jones);
Limitations
Positional parameters
This syntax can be used with positional parameters. It just means that the arguments must also be positional. This of somewhat limited use, but it does let you more easily copy objects whose constructor takes positional and named parameters.
Note that it does not fill in missing positional parameters:
var today = DateTime.now();
var past = today.with(1940, 5);
The resulting past
value does not have the same date, hour, minutes, etc. as today
. Those optional positional parameters are simply omitted. This is unfortunate, and probably a footgun for the proposal. But filling those parameters in implicitly based on their name would require making the name of a positional parameter meaningful in an API, which is currently not the case in Dart.
Named constructors
The proposed syntax does not allow calling a named constructor to copy the object. We could possibly extend it to allow:
var elvis = Name("Elvis", "Costello");
var theKing = elvis.with.mononym(last: null);
print(jones);
That looks fairly strange to me, but could work.
Alternatives
No .
before with
We could eliminate the leading .
if we want the syntax to be more visually distinct:
var jones = tom with(last: "Jones");
I think this looks nice. Extending it to support named constructors looks more natural to me:
var theKing = elvis with.mononym(last: null);
However, it makes it harder to come up with a null-aware form that looks reasonable. Also the precedence of with
relative to the receiver and any surrounding expression is less clear.