Open munificent opened 2 years ago
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.
So it seems the problem is the general idea of passing null
vs not passing anything.
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 default
keyword to denote "no parameter". Combine that with non-const default parameters, you have a solution that has several benefits over with
.
copyWith
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 Function(int)
function with a local variable of type num
(always intiially assigned an int
, possibly even promoted to int
).
The default value would then only need to be assignable to the local variable, so you could do:
void foo(int? x as num? = double.nan) { ... }
and accept any int
or null
, and still detect that no parameter was passed.
(The feature is also useful in the other direction: void bar(Object? x as int) { ... }
which will throw at run-time if the argument isn't actually an int
, like we do for covariant
instance method parameters).
(I still want to move to a parameter passing strategy where you cannot distinguish passing null
from not passing an argument, and then there will be no way to write a with
that accepts overriding with null
.)
(I still want to move to a parameter passing strategy where you cannot distinguish passing
null
from not passing an argument, and then there will be no way to write awith
that accepts overriding withnull
.)
Can you elaborate on this? How then will the copyWith
problem, where the difference between not passing an argument and passing in null
is crucial, be solved?
(It won't be solved if you can't distinguish passing null
from passing nothing. It'll be impossible. That's something I'm personally willing to sacrifice for a much nice parameter passing protocol, one where you can always forward parameters to another function without knowing its default values, because you can always pass null
to mean "no argument". I think that's a bigger problem than copyWith
. But that's a separate problem, and different from the with
proposal, which could behave differently than a function call.)
- Determine the static type
T
of the left-hand receiver expression.
I am concerned about this being based on the static type. I may have a function that wants to change the name
of a Person
, and if somebody passes me a Manager
I don't want to end up converting that to a Person
and dropping all the extra information contained in Manager
. The macro/codegen approaches all avoid this problem by just creating copyWith
methods specialized to each class (the subclasses are only adding non-optional params).
2. 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.
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 null
versus undefined
(in particular for parameters). I don't think we want to go the route of adding undefined
as an actual value, but some other solution to the problem might exist?
@jakemac53 instead of undefined
, what about the default
keyword in https://github.com/dart-lang/language/issues/577?
I find the
foo(x: if (cond) 42)
idea less hacky and more consistent (rhymes with "if" in collections). Though these two things are not equivalent in general, in the context of "with" they are.
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 default
keyword and foo(x: if (cond) 42)
being a syntax sugar for its use, ie:
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 default
keyword is important for you to state to the compiler that you need to know the default value of the parameter.
@jakemac53 instead of
undefined
, what about thedefault
keyword in #577?
I don't think that solves the copyWith
use case, unfortunately. Or at least it isn't clear to me how.
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.
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 with
, this is a non-breaking change*, because any calls to C
's constructor that worked before the change will also work after it. But if there's a call site that does this:
f(C c) => c.with(x: 0);
then that suddenly becomes a compile error because int?
is not assignable to String?
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 VariableModel
class:
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 with
allow me to drop the promotedTypes
, tested
, assigned
, and unassigned
arguments, but it also forced me to explicitly state that I want to set nonPromotionHistory
to null
and properties
to const {}
. It's that last part that gets me really excited, because previously, the fact that those two fields were changing was really non-obvious, and now it's totally clear.
@tatumizer:
WIth "with", default value is supposed to be copied over from the old instance. In a constructor call, "default" would mean a constant value defined in the initializer.
To distinguish between "omitted value" and "copying over from the old instance", simply set the default in the signature to this.X
, so that omitting a value defaults to the instance's current value (depends on the ability to set non-const default parameter values).
@jakemac53:
I don't think that solves the
copyWith
use case, unfortunately. Or at least it isn't clear to me how.
From my comment above, combining default
with non-const default parameters does solve this isssue. See my example there how to implement a User.copyWith
.
From my comment above, combining
deafult
with non-const default parameters does solve this isssue. See my example there how to implement aUser.copyWith
.
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.
I don't know what the reasons are for making default values be const
There are two main things we get from it, that I know of:
It sidesteps tricky questions about when and how often the default value expression gets evaluated. Since it's const and thus can't have side effects or be mutated, it doesn't really matter. This avoids a particularly nasty pitfall in Python where the default value object is mutable but only gets evaluated once at function declaration time. (Or course, the easy answer is to just... not do that. If we supported non-const default values, we'd probably evaluate them once per invocation.)
It makes it possible to statically check that an overridden method doesn't change the default value. The language gives you a warning about this since it's really confusing as to what default value you actually get in this situation. We can do the check statically since both default values are const objects known at compile time.
However, non-const default values would be really useful, so I'd be happy to sacrifice those get them.
It won't be solved if you can't distinguish passing
null
from passing nothing
What about reusing void
instead of exposing default
?
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 required
void
treated the same as not passing anything:
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...
2. It makes it possible to statically check that an overridden method doesn't change the default value. The language gives you a warning about this since it's really confusing as to what default value you actually get in this situation. We can do the check statically since both default values are const objects known at compile time.
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 default
keyword or some other feature like that).
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.
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 package:freezed
. This proposal appears to obsolete (and improve upon) package:freezed
's generated copyWith
methods. Code using package:freezed
is based on unnamed redirecting factories.
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.
Could this proposal be extended to include classes that have an unnamed factory (including an unnamed redirecting factory)?
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 don't think we want to go the route of adding
undefined
as an actual value
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:
- if someParam is undefined
- if lateInitializationMember is undefined .. // but is declared
- someClass.someUndeclaredMember = x // still throws, is undeclared
There is a general hatred for this, maybe for good reasons, but it has its places.
In most cases I use a wrapper though when needing some kind of differenciation. like null == undefined, DataSet == data is set but the data itself could be null. It's just an happy accident that all cases where a copyWith methods is needed does not require this and to be totally correct they would but that'd be asinine
The "check if potentially unassigned variable is assigned or not" operator can definitely work. It might be possible to extend it to parameters, treating optional non-nullable parameters with no default value as potentially unassigned, and allowing the check-if-undefined operation to promote them.
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 x
to 2
, because you are able to ask whether initialization has happened.
Without that operation, the compiler could eagerly initialize the variable, and nobody can tell the difference.
(Admittedly, the example is stupid code, unless you actually want the side channel information that the intializer was evaluated, and quite possibly, the compiler can be smart enough to recognize when you are not asking about the initialization state.)
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. Those features will have to go together, otherwise it's a "no" from me.
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.