Skip to content
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

Should there be a way to update a tuple? #1292

Open
lrhn opened this issue Nov 6, 2020 · 23 comments
Open

Should there be a way to update a tuple? #1292

lrhn opened this issue Nov 6, 2020 · 23 comments
Labels
question Further information is requested records Issues related to records.

Comments

@lrhn
Copy link
Member

lrhn commented Nov 6, 2020

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?

@lrhn lrhn added question Further information is requested patterns Issues related to pattern matching. labels Nov 6, 2020
@mraleph
Copy link
Member

mraleph commented Nov 6, 2020

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.

@eernstg
Copy link
Member

eernstg commented Nov 6, 2020

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).
}

@munificent
Copy link
Member

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;.

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.)

@eernstg
Copy link
Member

eernstg commented Nov 9, 2020

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.

@munificent
Copy link
Member

Ah, I like .with(). I think we would want this syntax to be high precedence, and using . sends that signal clearly.

@munificent
Copy link
Member

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).

@leafpetersen
Copy link
Member

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.

@lrhn
Copy link
Member Author

lrhn commented Nov 13, 2020

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.

@mraleph
Copy link
Member

mraleph commented Nov 13, 2020

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.

@munificent
Copy link
Member

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) nor tuple 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—the 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.

@leafpetersen
Copy link
Member

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.

@munificent
Copy link
Member

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.

Fair point. That definitely implies we need an update feature that works for user-defined classes too and not just records.

@lrhn
Copy link
Member Author

lrhn commented Nov 17, 2020

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 :)

@kasperpeulen
Copy link

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 would say, it is very easy to update mutable classes, but if you want immutable structures, you will get frustrated very soon with Dart. Record seems like exactly what Dart needs if you are interested in applying immutable patterns in Dart.

Or just make them mutable and have setters, that's how you normally update class instances. Immutable classes is the exception :)

That is maybe what you normally do, but many developers are moving to immutable data structures and consider mutable classes as the exception :)

@munificent munificent added records Issues related to records. and removed patterns Issues related to pattern matching. labels Aug 19, 2022
@TimWhiting
Copy link

It looks like javascript is using the spread syntax for their version of tuples/records, it seems rather clean and would work well with spreading into argument lists as mentioned here #2128

Additionally for deep updates they have a proposal that is basically a mirror of @lhrn's proposal for extractor selector chains here #2433. The syntax being a mirror image seems like it would make it a very clean feature that works as you would expect in both update contexts as well as extraction contexts.

@lrhn
Copy link
Member Author

lrhn commented Sep 6, 2022

The idea seems to be that a record spread in a record literal will spread all the fields that are not otherwise defined for the same record literal. That allows var pair = (x: 4, y: 5); var pair2 = (x: 0, ...pair); to only spread the y value into the new pair, since x is explicitly declared.
I guess if you do (...rec1, ...rec2), the first to spread the field wins.

You can then update a record by doing r = (...r, foo: 42);, and the static type of r will prevent you from mistyping the name to something not already in the record. (JavaScript won't have that advantage.)
You can't easily update just the third positional field, not unless we allow eliding values, like:

  fiveTuple = (,,42, ...fiveTuple);

It's possible, but probably a little speculative.

I like the idea. We should have spreads anyway, and this is a useful way to handle conflicts.

@ds84182
Copy link

ds84182 commented Sep 6, 2022

I still like the idea of with to handle positional fields. (1, 2) with (_, 5) returns (1, 5). This isn't something spread can cover, because it semantically feels it should concatenate positional fields. e.g. (1, 2, 3, ...(4, 5)) returns (1, 2, 3, 4, 5).

A more interesting question though: Could we also apply spreads to record types?

typedef Vec2<T> = (x: T, y: T);
typedef Vec3<T> = (...Vec2<T>, z: T); // Alternatively, `Vec2<T> with (z: T)

@lrhn
Copy link
Member Author

lrhn commented Sep 7, 2022

Ack. And doh. Obviously a spread should concatenate positional fields, so the spread idea only works for named fields.

Doing (,,42,...(1,2,3)) should, if anything, give (1, 2, 42, 3). And it's still going to be weird.

@eernstg
Copy link
Member

eernstg commented Oct 19, 2022

@lrhn, @munificent, move to records-later?

@munificent munificent added records-later and removed records Issues related to records. labels Oct 20, 2022
@jodinathan
Copy link

will we have a way to update records in Dart 3?

@eernstg
Copy link
Member

eernstg commented Mar 16, 2023

There are no plans to add support for any of these "functional update" members in Dart 3.0. They remain equally interesting, of course, and it may well happen later on. For now:

void main() {
  (int, int, {Color: color}) cpoint = ...;

  // Change `cpoint` to a new record that differs only by being red.
  cpoint = (cpoint.$1, cpoint.$2, color: Color.red);
}

@AlexVegner
Copy link

AlexVegner commented May 12, 2023

Thanks for Dart 3 functionality. It's awesome.
It will be nice to have spread operator support for records

void main() {
  ({String? name,  int? id}) category = (name: 'A', id: 1);
  category = (...category, name: 'B');
}

@renggli
Copy link

renggli commented Jun 24, 2023

+1 for spread operator support. To update positional fields static extension methods on generic record types work quite well in the interim: dart-more/lib/src/tuple/tuple_3.dart

@munificent munificent added records Issues related to records. and removed records-later labels Aug 28, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
question Further information is requested records Issues related to records.
Projects
None yet
Development

No branches or pull requests