fsharp / fslang-suggestions

The place to make suggestions, discuss and vote on F# language and core library features
345 stars 21 forks source link

Allow recursive record assignment #1315

Open reinux opened 1 year ago

reinux commented 1 year ago

I propose we allow fields assigned to a record to be referenced down the line, perhaps indicated by a rec keyword a la let rec:

rec {
  firstName = ...
  lastName = ...
  fullName = lastName + ", " + firstName
}

The existing way of approaching this problem in F# is to bind the value upfront, and then reference that, e.g.

let firstName, lastName = ..., ...
{ firstName = firstName
  lastName = lastName
  fullName = lastName + ", " + firstName
}

Pros and Cons

The advantages of making this adjustment to F# are quality of life, helps with legibility when working with large amounts of declarative code/configuration/etc.

The disadvantages of making this adjustment to F# are one more thing to learn, though its meaning can be intuited quite easily from let rec or with a simple code example as in here.

Extra information

Estimated cost (XS, S, M, L, XL, XXL): S (or M if parsing records piecemeal is currently non-trivial)

Related suggestions: (put links to related suggestions here)

This syntax is available in the Nix language, a DSL for a functional Linux package manager and accompanying distro.

Affidavit (please submit!)

Please tick these items by placing a cross in the box:

Please tick all that apply:

For Readers

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

cartermp commented 1 year ago

I would say that if we did this, I wouldn't want a keyword. I'd much prefer it to be a scoping rule that "works" in top-down order:

let doot = {
    FirstName = "phillip"
    LastName = "carter"
    FullName = $"{FirstName} {LastName}"
}
smoothdeveloper commented 1 year ago

isn't it going to introduce breaking change?

type Rec = { FirstName: string; LastName: string; FullName: string }
let FirstName = "gauthier"
let LastName = "segay"
let doot = {
    FirstName = "phillip"
    LastName = "carter"
    FullName = $"{FirstName} {LastName}"
}
> val FirstName: string = "gauthier"
> val LastName: string = "segay"
> val doot: Rec = { FirstName = "phillip"
>                  LastName = "carter"
>                  FullName = "gauthier segay" }

I'm more keen if we introduce #653 / #24, I think it boils to almost the same as the suggestion:

let FirstName = "phil"
let LastName = "carter"
let FullName = $"{FirstName} {LastName}"
let phil = { FirstName; LastName; FullName }
cartermp commented 1 year ago

Yes, it'd technically be a breaking change, but in ordering it'd be consistent with shadowing (most "recent" scope wins), so it's something I'd actually be in favor of introducing

BentTranberg commented 1 year ago

@cartermp, I always thought not introducing breaking changes was a super high priority. Without some mechanism, e.g. a keyword, it would introduce massive breaking changes in my source.

How about just borrowing an idea I believe was recently used in another suggestion?

{
    firstName = firstName
    lastName = lastName
    fullName = _.firstName + _.lastName
}

I know that some have argued the underscore should mean only "not used" or something like that, but the way I see it it's more like "no need to spell it out".

smoothdeveloper commented 1 year ago

@BentTranberg I also considered _. could be a good fit here, but I'm also wondering if this wouldn't be ambiguous in some contexts. I'd get a better sense of it when I'll start using that feature.

I also wonder about the ordering semantics, if the breaking change would be given a pass:

let FirstName = "gauthier"
let LastName = "segay"
let doot = {
    FullName = $"{FirstName} {LastName}"
    FirstName = "phillip"
    LastName = "carter" }
Tarmil commented 1 year ago

@BentTranberg The underscore syntax has already been implemented for F#8 as meaning fun x -> x.firstName, so unfortunately this would be ambiguous.

BentTranberg commented 1 year ago

How about this?

type Person =
    {
        FirstName: string
        LastName: string
        FullName: string
    }

let person1: Person =
    { as x
        FirstName = "James"
        LastName = "Bond"
        FullName = $"{x.FirstName} {x.LastName}" // James Bond
    }

let person2: Person =
    { person1 as x with
        FirstName = "Lena"
        FullName = $"{x.FirstName} {x.LastName}" // Lena Bond
    }

edit: The last one - person2 - illustrates how as x is used together with the existing syntax person1 with.

smoothdeveloper commented 1 year ago

@BentTranberg you are onto something, onto being one more fsharp language designer assistant :)

maybe this would be more natural:

let rec person1 =
  { FirstName = "James"
    LastName = "Bond"
    FullName = "{x.LastName}, {x.FirstName} {x.LastName}" } as x

as of now:

let rec person1 = {
  FirstName = "James"
  LastName = "Bond"
  FullName = $"{person1.LastName}, {person1.FirstName} {person1.LastName}" 
}

it currently gives:

  let rec person1 = { FirstName = "James"; LastName = "Bond"; FullName = $"{person1.LastName}, {person1.FirstName} {person1.LastName}" };;
  --------------------------------------------------------------------------^^^^^^^

warning FS0040: This and other recursive references to the object(s) being defined will be checked for initialization-soundness at runtime through the use of a delayed reference. This is because you are defining one or more recursive objects, rather than recursive functions. This warning may be suppressed by using '#nowarn "40"' or '--nowarn:40'.

  let rec person1 = { FirstName = "James"; LastName = "Bond"; FullName = $"{person1.LastName}, {person1.FirstName} {person1.LastName}" };;
  --------^^^^^^^
error FS0031: The value 'person1' will be evaluated as part of its own definition
BentTranberg commented 1 year ago

With the last syntax I suggested, it should not be allowed to reference a field before it is assigned explicitly, if it is assigned explicitly. Without having given it much thought, I think that simple rule will be enough. I also think it feels quite F#'ish that fields are assigned before they can be used.

reinux commented 1 year ago

I think the learning overhead is a bit higher with that syntax, because as is normally used after a pattern as opposed to within brackets. Maybe let (person1 as x): Person = ... might be easier to make sense of?

Being able to copy-and-update from another record using a variable bound using as is interesting, but I couldn't quite figure out what that means (or why you would do that) until I read the comment. Maybe that's just me, though, and I'm admittedly a little sleep deprived.

That said, I also take @cartermp 's point that not having too much additional syntax for this would be nice, which is kind of why I think rec is a good compromise, even if it doesn't really add as much value as as would.

smoothdeveloper commented 1 year ago

@reinux you are right about as location, just in case you miss the feature, which is supported:

type Foo() as this =
// ...

and #501 also uses the same for binding an interface.

I overall feel the suggestion isn't bringing enough, with record punning and using locals with the right name, it would become terse enough to not really bother for this edge case.

The feature value increases when the members are more complex expressions, but initializing the locals is very explicit and isn't much more code, no feature needed beyond punning, it doesn't even have to pollute the scope due to the flexible scoping for bindings.

let person =
  let FirstName, LastName = "James", "Bond"
  let FullName = $"{LastName}, {FirstName} {LastName}"
  { FirstName; LastName; FullName }

note: the code gen for the bindings of tuple above isn't great, it somehow initializes an array, but is a separate issue.

BentTranberg commented 1 year ago

I'm not that happy either with the syntax I suggested. Especially the second one - person2 - I believe is bound to be misinterpreted by the reader all the time as meaning x is an alias for the original person1. It's easy enough to deduce the reasonable explanation if you stop and think for a while, but you shouldn't be required to stop and think for a while ... "oh, it must mean this, and not that, because why else is it there". No, no, no. It should be plainly understood immediately when you look at it, and I feel that doesn't happen.

charlesroddie commented 12 months ago