The current tuple proposal makes tuples immutable. I think that's great.
However, there will be situations where, say, you have (int, int, {Color: color}) cpoint; and you want to change just the color.
You'd then have to do cpoint = (cpoint[0], cpoint[1], color: Color.red); (using my own notation for projections 馃榿 ).
What if you had a way to say "like this tuple, with color replaced by ..."?
Idea: Allow []= or name= to be used on expressions of tuple type, but only in cascades. The result of such a cascade is a new tuple which has the same structure and values as the original, except where those values were "overwritten".
Example: cpoint = cpoint..color = Color.red; aka. cpoint = cpoint..[#color] = Color.red;.
Tuples are still immutable, so you can't change a tuple, but you can create a new one, and then assign it to the original variable (or to something else).
The problem with this syntax is that cpoint..color = ... looks like it does modify cpoint, and users will forget to do the assignment cpoint = cpoint..[...]=.... We can obviosuly tell them if the result of such a modification isn't used, but it's still sligtly off compared to actually mutable values.
Any other ideas?
Why not follow established convention and add a method copyWith() to the tuple?
cpoint = cpoint.copyWith(color: Color.red);
The method would be a bit magical (not expressible in normal Dart) because normal methods can't exactly check which parameters were passed. For positional components you can then use the same names as destructuring interfaces use.
Nice! We could just emit a hint about ignoring the value of such expressions.
Of course, this is the same thing as copyWith or other constructs used to create a composite entity which is the same except for a specified set of differences.
We could also take a bit of inspiration from the mixin construct: A syntax like class B = A with M could be generalized to allow class B = A with {...}, and this makes A with {...} denote a class which is obtained from A by "copying with some modifications".
It is possible that regular parentheses are more natural for a record/tuple, and we would probably not want to support function invocation for a record type, so we could use this:
void main() {
(int, int, {Color color}) x = (0, 1, color: Color.red);
var x2 = x(7); // Just changes x[0].
var x3 = x(_, 3); // Just changes x[1].
var x4 = x(color: Color.Blue); // Just changes x.color.
}
We could allow the modifier part ((...)) to specify a prefix of the positional fields as shown above, or we could require an ellipsis at the end in order to make it visible that not all positional fields are mentioned:
void main() {
(int, int, {Color color}) x = (0, 1, color: Color.red);
var x2 = x(7 ...); // Just changes x[0].
var x3 = x(_, 3 ...); // Just changes x[1].
var x4 = x(... color: Color.Blue); // Just changes x.color.
}
This would be inconvenient if the number of positional fields is large, but that may be inconvenient in several other ways as well, so maybe that doesn't matter much. Otherwise we could of course have a way to address a specific field as well, e.g., by allowing an int to serve as a "name":
void main() {
(int, int, int, int, int, int, int, int) x = (0, 1, 2, 3, 4, 5, 6, 7);
var x2 = x(6: 100); // Yields (0, 1, 2, 3, 4, 5, 100, 7).
}
Idea: Allow
[]=orname=to be used on expressions of tuple type, but _only_ in cascades. The result of such a cascade is a new tuple which has the same structure and values as the original, except where those values were "overwritten".Example:
cpoint = cpoint..color = Color.red;aka.cpoint = cpoint..[#color] = Color.red;.
The last thing I want to do is jam more syntax into cascades. They are already semantic mystery meat. Saying now there is a setter syntax in cascades that cannot be used outside of a cascade is even stranger.
void main() { (int, int, {Color color}) x = (0, 1, color: Color.red); var x2 = x(7); // Just changes x[0]. var x3 = x(_, 3); // Just changes x[1]. var x4 = x(color: Color.Blue); // Just changes x.color. }
Directly calling a tuple to do record update feels a little too subtle and implicit to me. And it doesn't generalize to user-defined classes since those may already support call() and thus the call syntax means something else. Perhaps:
void main() {
(int, int, {Color color}) x = (0, 1, color: Color.red);
var x2 = x with (7); // Just changes x[0].
var x3 = x with (_, 3); // Just changes x[1].
var x4 = x with (color: Color.Blue); // Just changes x.color.
}
We could generalize this to say that a with expression on an instance of a user-defined class is sugar for a call to copyWith() where each argument is either taken from the corresponding record field if present or by calling an appropriately-named getter on the LHS operand otherwise. (If we also provide a way to auto-generate these methods, that would be particularly powerful.)
I like that. There is always a strong push toward concise syntax (favoring x(7) over x with (7)), but I like the idea that we'd use special syntax to indicate explicitly that there is some magic involved.
Syntactically, we could make with (...) a selector. This would make it bind tightly, and that's probably what we want. We could also use .with(...) in order to visually suggest that this is a selector (and it participates in null shorting just like other selectors, etc.). It would still be unambiguous because with is a reserved word.
void main() {
(int, int, {Color color}) x = (0, 1, color: Color.red);
var x2 = x.with(7); // Just changes x[0].
var x3 = x.with(_, 3); // Just changes x[1].
var x4 = x.with(1: 3); // Also just changes x[1].
var x5 = x.with(color: Color.Blue); // Just changes x.color.
}
The extension to give .with(...) a semantics for receivers whose type isn't a concrete record type (rather than making it an error) is non-breaking, so we could add that whenever we want. For instance, this could be added together with a notion of value classes with generated copyWith methods.
Ah, I like .with(). I think we would want this syntax to be high precedence, and using . sends that signal clearly.
I just discovered C# 9.0 adds a similar with expression syntax for their "record" types (which are more like Kotlin data classes than the record types here).
This is, in my opinion, a must have. Everyone who ever programmed extensively in ML ended up writing their own tuple update library, and it was the feature that was always brought up as a major missing piece of the language.
Having to do with (_,_,_,_,5) to update the last positional value is not convenient.
The C# syntax could be useful, but we'd need to allow integer labels, tuple with {5: value} to update the fifth positional value.
Maybe tuple with{[5]: value} to make it look more like an l-value, but the [ and ] are redundant.
I am wondering if long purely positional ("nameless") tuples are antipattern to begin with - I don't think neither with (_, _, _, _, 5) nor tuple with {4: value} _reads_ great. Maybe we should not be optimising for this at all. Also both are rather magic syntaxes - it is not entirely clear to me why it should not be a simple method call with named parameters. tuple.with(...) - and we can reuse naming from destructuring interfaces (tuple.with(field5: 10)) to make things consistent.
Btw, if we make it a method call - then IDE would complete it without any additional work on IDE side which seems like a win-win situation. User does not need to lookup how to update the tuple. They just type tuple. and get with in completion and they see name of parameters there.
This is, in my opinion, a must have. Everyone who ever programmed extensively in ML ended up writing their own tuple update library, and it was the feature that was always brought up as a major missing piece of the language.
I suspect this is less of an issue in Dart where the language already gives you another way to relatively easily define incrementally-updatable aggregate types: classes.
I am wondering if long purely positional ("nameless") tuples are antipattern to begin with - I don't think neither
with (_, _, _, _, 5)nortuple with {4: value}_reads_ great. Maybe we should not be optimising for this at all.
I agree that if you find yourself frequently updating giant tuples you are already hurting yourself. At some point you should accept that your code is working with a real entity and give the thing a name and a real class declaration.
it is not entirely clear to me why it should not be a simple method call with named parameters.
It would need some minor magic to deal with the omitted parameters and non-nullability. Consider:
{x int, y int} pair = (1, 2);
var tearOff = pair.with;
tearOff(y: 2);
The type of tearOff needs to be Function({int x, int y}) since those parameters are optional. But the parameters are also non-nullable and there is no known constant default value we could use for each one鈥攖he default values would presumably be the current values of the record's elements, which are only known at runtime. So the body of this function is not something a user could hand-write.
But I don't think this magic would leak out elsewhere, so it doesn't seem like an overall bad idea to me.
I suspect this is less of an issue in Dart where the language already gives you another way to relatively easily define incrementally-updatable aggregate types: classes.
I don't think I believe this. I think all of the requests for adding data class copyWith style updates is pretty strong evidence that in fact incrementally updating classes is not well supported in Dart as it exists.
I think all of the requests for adding data class
copyWithstyle updates is pretty strong evidence that in fact incrementally updating classes is not well supported in Dart as it exists.
Fair point. That definitely implies we need an update feature that works for user-defined classes too and not just records.
I don't think it makes sense to have an update feature on class types in general.
Tuples are structural and defined by their content. If you replace one of their component values and retain the rest, the result is a new tuple. You can always create a new tuple from the values of an existing tuple.
Class types, aka. interfaces, are not defined in terms of their components. They are created using constructors and deconstructed (if at all) by their getters. There does not have to be any one-to-one correspondence between getter properties and constructor arguments. There can be zero, one or and arbitrary amount of differently typed public constructors. There can be any number of derived or correlated getters. There may or may not be setters.
A general update functionality makes sense on abstract data-types. If we introduce "data classes" or similar, it would make sense there too.
Or just make them mutable and have setters, that's how you normally update class instances. Immutable classes is the exception :)
Most helpful comment
The last thing I want to do is jam more syntax into cascades. They are already semantic mystery meat. Saying now there is a setter syntax in cascades that cannot be used outside of a cascade is even stranger.
Directly calling a tuple to do record update feels a little too subtle and implicit to me. And it doesn't generalize to user-defined classes since those may already support
call()and thus the call syntax means something else. Perhaps:We could generalize this to say that a
withexpression on an instance of a user-defined class is sugar for a call tocopyWith()where each argument is either taken from the corresponding record field if present or by calling an appropriately-named getter on the LHS operand otherwise. (If we also provide a way to auto-generate these methods, that would be particularly powerful.)