-
Notifications
You must be signed in to change notification settings - Fork 209
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
With expressions #2009
Comments
So it seems the problem is the general idea of passing There are other issues, like #577, #140, and #137. Specifically #577 (credits to @jodinathan) and these two comments from @tatumizer and @lrhn introduce the idea of using the already-reserved
It would look something like this: class User {
final String username;
final String? email;
const User({required this.username, this.email});
// assuming non-const default parameters are allowed, this gives us what we want
// now, passing in null makes the value null, and omitting the value entirely gives the default.
User copyWith({String? username = this.username, String? email = this.email}) => User(
username: username,
email: email,
);
}
void main() {
// create a reference user
final alice = User(username: "alice", email: "alice@gmail.com");
// regular copyWith material, nothing novel here
final bob = alice.copyWith(username: "bob", email: "bob@gmail.com");
// we're able to remove the email by explicitly passing null
final aliceUnverified = alice.copyWith(email: null);
// we're able to choose whether to keep the email or not
bool isVerified = getAuthStatus();
final aliceSynced = alice.copyWith(email: isVerified ? default : null);
} |
The problem of default values comes when all type-valid arguments are also valid and reasonable values, so there is no value left over to be the sentinel default value. An approach to that could be to allow parameters to have types different from the local variable they introduce. Example: void foo(int x as num) { ... } would declare a void foo(int? x as num? = double.nan) { ... } and accept any (I still want to move to a parameter passing strategy where you cannot distinguish passing |
Can you elaborate on this? How then will the |
(It won't be solved if you can't distinguish passing |
I am concerned about this being based on the static type. I may have a function that wants to change the
I also share the general concern @tatumizer was I think trying to express about this part. In Dart constructor parameters don't have a 1:1 relationship with fields. For lots of classes these with expressions will just be totally useless (or even dangerous because they will not do what is expected). I would somehow want this feature to be restricted to classes that meet the definition of a "data class" - basically classes where every field is initialized with an initializing formal in the unnamed constructor, and there are no additional parameters. |
As a more meta comment, I think I would rather see us solve this problem by solving the more general problem around |
@jakemac53 instead of |
This is interesting, however, it seems that omitting a parameter is something that the compiler can do at compile time. For this to work the default value of a parameter must be known at runtime because you may need it or not depending on the condition result. I like the foo(if (cond) x: 42);
// desugars to
foo(x: cond ? 42 : default);
// then you can easily use it with multiple parameters
foo(if (cond) x: 42, if (otherCond) y: 123);
// desugars to
foo(x: cond ? 42 : default, y: otherCond ? 123 : default); The point is that the |
I don't think that solves the |
I can't decide if I'm nit-picking here, but I don't think this reasoning is correct. Consider this (admittedly bizarre) API evolution: Before: class C {
int x;
C({this.x});
} After: class C {
int x;
int? y;
C({this.x, String? y}) : y = y == null ? null : int.parse(y);
} If no callers use f(C c) => c.with(x: 0); then that suddenly becomes a compile error because I realize that this is a totally bizarre example, so maybe we don't care. I just wanted to raise awareness of it. *Okay, actually I lied here. Technically, any change to a constructor signature is a breaking change because of constructor tearoffs. So maybe my whole argument is moot? I dunno. |
I spent a while thinking about whether I could have used this feature in the implementation of flow analysis. It uses a lot of immutable data structures and updates them in a very "with"-like way, so it seems like a good candidate. For example, check out this code from the class VariableModel<Variable extends Object, Type extends Object> {
final List<Type>? promotedTypes;
final List<Type> tested;
final bool assigned;
final bool unassigned;
final SsaNode<Variable, Type>? ssaNode;
final NonPromotionHistory<Type>? nonPromotionHistory;
final Map<String, VariableModel<Variable, Type>> properties;
VariableModel(
{required this.promotedTypes,
required this.tested,
required this.assigned,
required this.unassigned,
required this.ssaNode,
this.nonPromotionHistory,
this.properties = const {}}) { ... }
VariableModel<Variable, Type> write(...) {
...
return new VariableModel<Variable, Type>( // (1)
promotedTypes: promotedTypes,
tested: tested,
assigned: assigned,
unassigned: unassigned,
ssaNode: newSsaNode);
...
}
...
} This proposal would let me replace the statement at (1) with: return this.with(
ssaNode: newSsaNode,
nonPromotionHistory: null,
properties: const {}); Which is actually a lot better than what it looked like before. Note that not only did the use of |
To distinguish between "omitted value" and "copying over from the old instance", simply set the default in the signature to
From my comment above, combining |
True, it would. I don't know what the reasons are for making default values be const so I have no idea as to the feasibility of that part. |
There are two main things we get from it, that I know of:
However, non-const default values would be really useful, so I'd be happy to sacrifice those get them. |
What about reusing class Name {
final String first;
final String? last;
Name(this.first, this.last);
Name with({String first, String? last}) => Name(
(first == void) ? this.first : first,
(last == void) ? this.last : last
);
}
main() {
var elvis = Name("Elvis", "Costello"); // Elivs Costello
var theKing = elvis.with(last: null) // Elvis null
var evlis2 = elvis.with() // Elvis Costello
var evlis3 = elvis.with(last: void) // Elvis Costello
} And class Foo {
int? x;
Foo({required this.x});
}
main() {
Foo(x: 1); // OK
Foo(x: null); // OK
Foo(); // Error
Foo(x: void); // Error
} Could also be the basis for conditional arguments: Baz(p: if(condition) value);
Baz(p: condition ? value : void); Not sure how hard/impossible a migration for this would be. |
I see now that this would also clash with default values, so never mind... |
This one we could also avoid with a different feature that allows you to not have to copy the actual default value (and instead be able to refer to it with a |
Could this proposal be extended to include classes that have an unnamed factory (including an unnamed redirecting factory)? My usecase here would be as a heavy user of |
Yes, this strawman is deliberately worded such that it encompasses both generative and factory constructors. |
I would love to have this feature. I can understand questions about whether providing some sort of undefined or default keyword would be better, but I've only ever felt the need for that in trying to implement these copyWith methods. Perhaps I'd find more use-cases for something like undefined if we could also spread maps into functions to populate their parameters. Or maybe in pattern matching contexts? I'm not sure. But I can say for sure that this feature would be very valuable to me today and it seems like it wouldn't preclude a future language feature for something like undefined. In any case, I'm happy you're thinking about this and kicking around ideas! Here's to hoping something like this lands in a not so distant future version of the language 🤞🏻 |
I understand that it would have a lot of ramification but those are two different ideas, one has not been defined, the other is nothing. There is also the third idea of undeclared. One possibility would be to only allow to check if something is undefined. That is only allow this operation:
There is a general hatred for this, maybe for good reasons, but it has its places. |
The "check if potentially unassigned variable is assigned or not" operator can definitely work. It's something I'm not particularly fond of, because it might remove optimization opportunities. Allowing you to check whether a late variable is initialized means that the initialization state becomes overt. Compilers need to make sure that it has the correct state any time it's checked. Code like late int x = 2;
if (something) {
foo(x);
}
if (x is! undefined) { ... } Here the compiler can't just eagerly initialize I'm also worried that allowing you to detect that an argument was not passed, as different from passing the default value, will make parameter forwarding harder, unless we also have a way to provide no argument value in an argument position. |
For reference, this is how the Java team wants to ship this feature: https://openjdk.org/jeps/468 |
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, andcopy()
. (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:On the marked line, we create a new
Name
object that has the same state asName("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:
This works fine. But note that the
last
field is nullable. Perhaps you want to create a newName
by removing a last name. This works fine in Kotlin:But the corresponding Dart version does not:
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 "passednull
" 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 acopyWith()
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
:The grammar is simply:
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: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:
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:
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:
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:
The resulting
past
value does not have the same date, hour, minutes, etc. astoday
. 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:
That looks fairly strange to me, but could work.
Alternatives
No
.
beforewith
We could eliminate the leading
.
if we want the syntax to be more visually distinct:I think this looks nice. Extending it to support named constructors looks more natural to me:
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.The text was updated successfully, but these errors were encountered: