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

Field update syntax for Records #1049

Closed
5 tasks done
thinkbeforecoding opened this issue Jul 16, 2021 · 31 comments
Closed
5 tasks done

Field update syntax for Records #1049

thinkbeforecoding opened this issue Jul 16, 2021 · 31 comments

Comments

@thinkbeforecoding
Copy link

thinkbeforecoding commented Jul 16, 2021

Field update syntax for record

I propose we implement an update syntax for records. We can update records using the { x with .. } syntax, but it is quickly quite verbose.
I propose the following syntax:

type Average =
    { Total: int
      Count: int }

let add value avg =
   { avg with
        Total t = t + value
        Count c = c + 1 }

This is especially convenient when dealing with nested records and the RFC https://github.com/fsharp/fslang-design/blob/main/RFCs/FS-1049-nested-record-field--copy-and-update-expression.md :

{ x with FirstLevel.SecondLevel.ThridLevel value = value + 1 }
vs
{ x with FirstLevel.SecondLevel.ThridLevel = x.FirstLevel.SecondLevel.ThridLevel + 1 }

The existing way of approaching this problem in F# is to access the field from the input object:

type Average =
    { Total: int
      Count: int }

let add value avg =
   { avg with
        Total = avg.Total + value
        Count = avg.Count + 1 }

Pros and Cons

The advantages of making this adjustment to F# are simpler code for immutable constructs.. reduce the temptation to use lenses 😁

The disadvantages of making this adjustment to F# are a new syntax, added complexity in the compiler

Extra information

Estimated cost (XS, S, M, L, XL, XXL): M

Related suggestions:
https://github.com/fsharp/fslang-design/blob/main/RFCs/FS-1049-nested-record-field--copy-and-update-expression.md

Affidavit (please submit!)

Please tick this by placing a cross in the box:

  • This is not a question (e.g. like one you might ask on stackoverflow) and I have searched stackoverflow for discussions of this issue
  • I have searched both open and closed suggestions on this site and believe this is not a duplicate
  • This is not something which has obviously "already been decided" in previous versions of F#. If you're questioning a fundamental design decision that has obviously already been taken (e.g. "Make F# untyped") then please don't submit it.

Please tick all that apply:

  • This is not a breaking change to the F# language design
  • I or my company would be willing to help implement and/or test this

For Readers

If you would like to see this issue implemented, please click the 👍 emoji on this issue. These counts are used to generally order the suggestions by engagement.

@thinkbeforecoding
Copy link
Author

An alternative could be:

{ x with Field as f = f+1 }

Of course for short fields name, the advantage is small, but for long Domain related field names, its quickly a win.

@pblasucci
Copy link

I actually like the as binding version a bit better... but in either case, would you imagine all the regular decomposition goodness being allowed as part of the binding?

@thinkbeforecoding
Copy link
Author

That would be neat. Like if it's a tuple:

{ x with Field (t, u) = t+1,u-1}

And it could even be partial (to make it simpler than suggestion with yields) :

{ x with OptionalField (Some v) = Some (v+1)} 

Property keeps same value if it doesn't match.

{ x with List (_ :: tail) = tail}

Just keeps empty list as is.

@Happypig375
Copy link
Contributor

Treating it as a nested pattern, interesting

@thinkbeforecoding
Copy link
Author

The general pattern would be:

{ x with Field matchExpr = expr}

Translated to:

{ x with Field =
        match x.Field with
        | matchExpr -> expr
        | _ -> x.Field}

When matchExpr is total, the _ clause is dead code and can be discarded.

And, in a way, the classic syntax would be equivalent to:

{ x with Field _ = expr}

@pblasucci
Copy link

The longer I look at this, the more I think it's a very worthwhile bit of sugar. 💯

@Alkasai
Copy link

Alkasai commented Jul 16, 2021

It can also play nicely with my suggestion on conditional updates #1016

@thinkbeforecoding
Copy link
Author

thinkbeforecoding commented Jul 16, 2021

Yes, I think the syntax here is close to what already exists.
If we implement it using a true match clause we could even use guards:

{ x with Field v when v > 0 = v-1 }

{ x with Field v when someCondition = v+1 }

@thinkbeforecoding
Copy link
Author

I actually like the as binding version a bit better... but in either case, would you imagine all the regular decomposition goodness being allowed as part of the binding?

The version with 'as' is nice also but a bit longer.
However, it would fit with the #1025 suggestion in a way...

@Happypig375
Copy link
Contributor

@thinkbeforecoding The as is only needed for type tests. Here we match on a property, akin to DUs or active patterns.

@thinkbeforecoding
Copy link
Author

Yes, and an extra keyword is not necessary since there is no ambiguity.

@krauthaufen
Copy link

Thinking about beginners I think the as version is way less alienating and overall clearer, since it sort of tells you what's happening without the need to look it up somewhere...
Generally speaking I like the idea and I've been annoyed by this countless times but nonetheless I think things like this (implicit yields, auto-nullable conversions, implicit conversions for literals, etc.) make an otherwise super-simple language really hard to explain.
I guess I'm just saying simplicity (to me) is/was one of F#'s core benefits over languages like C# where feature-creep nowadays makes it really hard to read programs due to tons of special-cases and custom syntax for lots of things.

@thinkbeforecoding
Copy link
Author

Yes the idea is to just bind a name to the value. So that it is not a special syntax, in f#, you can already write things like

let (Some v) = x
let (a, b) = y

And

let a = x

is just a special case of it, catch-all matching with a name.

So there is no irregularity here, just the possibility to bind the value of the field to use it in the expression.

@krauthaufen
Copy link

I see your point but consider someone in the future being annoyed by { Field = fun a b -> ... } who would likely come up with something very similar. I understand that this is sound and will totally be a cool feature for F# nerds that follow discussions like this one.
I'm just saying this syntax (without as at least) does not make it explicit that this is a pattern-context and it raises F#'s entry barrier another bit.

@charlesroddie
Copy link

charlesroddie commented Jul 18, 2021

The stated advantage is "simpler code for immutable constructs". Actually "shorter, but more complex and obfuscated" is more accurate. Take the snippets posted:

let add value avg =
   { avg with
        Total = avg.Total + value
        Count = avg.Count + 1 }

let add value avg =
   { avg with
        Total t = t + value
        Count c = c + 1 }

In both cases you have avg so you need to understand that. The first snippet defines the new total Total = as avg.Total + value. This is extremely simple.

The second snippet has syntax Total t = where Total is describing the new total and t is the binding for the old total. This is extremely confusing and irregular. = f(t) again refers to the new total.

If you try to regularize this then it becomes Total newTotal = oldTotal + value which then when you define oldTotal becomes Total newTotal = avg.Total + value which then simplifies to Total = avg.Total + value which is exactly what we have at the moment!

In general the there is no need for syntactic sugar for expressions of the type x.Prop <- f(x.Prop) since "saving writing Prop twice" is not a significant benefit. And syntax like x.Prop oldValue <- f(oldValue) would be a poor alternative.

@krauthaufen
Copy link

krauthaufen commented Jul 19, 2021

Another idea could be generating lenses for record types in fsc with get/set/update functionality.

We would even have a syntax for these, since MyRecord.Field is not valid outside of record constructions at the moment we could just use it for accessing the lenses like

old
|> MyRecord.Field .= newValue
|> MyRecord.Other.Update(fun a -> a + 1)

What do you think? (Details to be worked out)

@dsyme
Copy link
Collaborator

dsyme commented Jun 14, 2022

For context, lenses suggestion was here #379

@charlesroddie How do you feel about the as version? It seems more reasonable?

let add value avg =
   { avg with
        Total = avg.Total + value
        Count = avg.Count + 1 }

let add value avg =
   { avg with
        Total as t = t + value
        Count as c = c + 1 }

@thinkbeforecoding
Copy link
Author

As proposed before, using 'as' this could be a pattern on the right, like already possible in match since last version.

@charlesroddie
Copy link

For context, lenses suggestion was here #379

@charlesroddie How do you feel about the as version? It seems more reasonable?

let add value avg =
   { avg with
        Total = avg.Total + value
        Count = avg.Count + 1 }

let add value avg =
   { avg with
        Total as t = t + value
        Count as c = c + 1 }

Addition of as doesn't make a substantive difference. In each case t binds to Total. In each case there is an illogical equation t=t+value. I suspect a reason some people like this request is the failure to disambiguate old and new. If you write oldTotal instead of t as a clarification it's clear that this update syntax doesn't make sense.

A more logical syntax (which I still don't support as it's not needed) would be with t+value replacing Total t.

@krauthaufen
Copy link

krauthaufen commented Jun 15, 2022

Overall wouldn't it just be cleaner to have some syntax for applying functions to record fields?

{ avg with Total °= fun v -> v+1 }

Of course one would need to come up with a more suitable operator and I'm still not convinced that this would really improve things, however it would make use of existing constructs (functions, update-syntax, operators)

@Tarmil
Copy link

Tarmil commented Jun 15, 2022

Or maybe something like this would make it clearer that it's the old value being transformed, without introducing a new operator.

let add value avg =
    { avg with
        Total as t -> t + value
        Count as c -> c + 1 }

(with or without as, I have no strong opinions on this point)

@zanaptak
Copy link

zanaptak commented Jun 15, 2022

Possible to consider new keyword to emphasize the binding is not representing the actual updated field?

Total was t = t + value
Total previously t = t + value

@Happypig375
Copy link
Contributor

    { avg with
        Total t <- t + value
        Count c <- c + 1 }

If we use the assignment operator <-, everything becomes clear.

@kerams
Copy link

kerams commented Jun 16, 2022

In general the there is no need for syntactic sugar for expressions of the type x.Prop <- f(x.Prop) since "saving writing Prop twice" is not a significant benefit.

The main benefit I see here is that you wouldn't get a chance to reference a different field or an unrelated value (of the same type) on the right hand side by accident. Copying update lines and then manually changing field names is especially prone to this, but you could make the same mistake while typing out the update as usual - Count = avg.Total + 1.

@Happypig375, nice idea, but someone could then go 'Why can't I also write Total <- 30'.

@Tarmil, I quite liked your suggestion too, because it's reminiscent of a match clause. Therein also lies the problem the more I look at it.

@thinkbeforecoding
Copy link
Author

thinkbeforecoding commented Jun 16, 2022

@kerams , the idea is not only to avoid typos, but to enable deconstruction:

{ p with Point (x,y) -> (x+1, y+1) }  

for instance using @Tarmil suggestion.

@Happypig375
Copy link
Contributor

@kerams We could also allow that, but creates two ways of doing to same thing.

@Tarmil
Copy link

Tarmil commented Jun 16, 2022

    { avg with
        Total t <- t + value
        Count c <- c + 1 }

If we use the assignment operator <-, everything becomes clear.

I disagree, now it looks like you're mutating the original record.

@Happypig375
Copy link
Contributor

with indicates immutability.

@Tarmil
Copy link

Tarmil commented Jun 16, 2022

But that's the point, this syntax sends mixed signals. with suggests immutability, and <- suggests mutation. And beyond that, what we're doing here is matching patterns and binding variables on the left side and using them in an expression on the right side. That's exactly what happens with the -> operator everywhere else in the language.

@Happypig375
Copy link
Contributor

Good point, -> makes sense if thought that way.

@dsyme
Copy link
Collaborator

dsyme commented Mar 1, 2023

Thank you for this suggestion.

I've taken a look at this and discussed with @vzarytovskii and will close this as something we're not going to do.

The feature doesn't occur in other languages with record syntax, and the specific choice to allow naming of the x.F field of the record is just one of many things you might want to name. Also it will interact badly with other suggestions like allowing nested update.

@dsyme dsyme closed this as not planned Won't fix, can't repro, duplicate, stale Mar 1, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

10 participants