fslang-suggestions icon indicating copy to clipboard operation
fslang-suggestions copied to clipboard

Names for tuple fields and function parameter types

Open Happypig375 opened this issue 5 months ago • 25 comments

I propose we allow this:

// Function with inferred parameter names and tuple elements - more than one : on the same parameter currently doesn't type check
let transformPoint (scale: float) ((x, y): x: float * y: float) (adjustments: (dx: float * dy: float) list) : resultX: float * resultY: float =
// or require parentheses
// let transformPoint (scale: float) ((x, y): (x: float * y: float)) (adjustments: (dx: float * dy: float) list) : (resultX: float * resultY: float) =
    // Apply scaling
    let scaled = x * scale, y * scale
    // Apply all adjustments
    let final = 
        adjustments 
        |> List.fold (fun (cx, cy) (dx, dy) -> cx + dx, cy + dy) scaled
    // Return named tuple (explicit dot syntax) (structness of tuple is inferrable)
    .resultX = fst final, .resultY = snd final
// val transformPoint : scale: float -> x: float * y: float -> adjustments: (dx: float * dy: float) list -> resultX: float * resultY: float

// Partial application preserving names
let scaleAndAdjust = transformPoint (.scale = 2.0)  // Named parameters for functions, partially applied function preserves parameter names
// val scaleAndAdjust : x: float * y: float -> adjustments: (dx: float * dy: float) list -> resultX: float * resultY: float

// Usage
let initialPoint = .x = 3.0, .y = 4.0  // Named tuple creation (structness of tuple is inferrable)
// val initialPoint : x: float * y: float
type Points = static member Create(dx, dy) = .dx = dx, .dy = dy
let adjustments = [
    .dx = 0.5, .dy = -1.0  // tuple with names
    Points.Create(.dx = -1.0, .dy = 2.0)  // dot-prefixed syntax can also be applied to method arguments
    // note: like in C#, tuple field names only unify if all tuples share the same field names.
]
// val adjustments : (dx: float * dy: float) list

let result = scaleAndAdjust initialPoint adjustments
// val result : resultX: float * resultY: float

match result with resultX = x, resultY = y -> printfn $"{x}, {y}" // Field patterns on tuple patterns
printfn $"{result.resultX}, {result.resultY}" // Accessing tuple fields by name

The existing way of approaching this problem in F# loses all these contextual information regarding function parameter and tuple names.

Pros and Cons

The advantages of making this adjustment to F# are

  1. Preserving important contextual information that helps robust programming
  2. Interop with C# for struct tuple field names (emitting the necessary attributes, and consuming C# defined tuples too)

The disadvantages of making this adjustment to F# is more information to track in the type signatures.

Extra information

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

Related suggestions:

  • Named argument application for functions https://github.com/fsharp/fslang-suggestions/issues/108 https://github.com/fsharp/fslang-suggestions/issues/961#issuecomment-767530376
  • Tuple field names https://github.com/fsharp/fslang-suggestions/issues/616 https://github.com/fsharp/fslang-suggestions/issues/1354
  • Preserving parameter names on function partial application https://github.com/fsharp/fslang-suggestions/issues/1134
  • Dot-prefixed named argument application for methods https://github.com/fsharp/fslang-suggestions/issues/1414
  • Named arguments for generic parameters https://github.com/fsharp/fslang-suggestions/issues/610
  • , separator for DU case fields instead of ; https://github.com/fsharp/fslang-suggestions/issues/957
  • Object/property/field patterns https://github.com/fsharp/fslang-suggestions/issues/968

Affidavit (please submit!)

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

  • [x] This is not a question (e.g. like one you might ask on StackOverflow) and I have searched StackOverflow for discussions of this issue
  • [x] This is a language change and not purely a tooling change (e.g. compiler bug, editor support, warning/error messages, new warning, non-breaking optimisation) belonging to the compiler and tooling repository
  • [x] 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
  • [x] I have searched both open and closed suggestions on this site and believe this is not a duplicate

Please tick all that apply:

  • [x] This is not a breaking change to the F# language design
  • [x] 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 :+1: emoji on this issue. These counts are used to generally order the suggestions by engagement.

Happypig375 avatar Jul 08 '25 20:07 Happypig375

I'm unsure if the leading dot syntax is good, also, there are many places in the language where naming things that are anonymous, should be considered in context of supporting tuple with named fields.

The syntax we would use, ideally, would work for active patterns implementation.

related:

  • #1341 discusses the fact active patterns can't have named fields
  • #1354 is about consuming tuples with named fields
  • #207 and https://github.com/fsharp/fslang-design/blob/main/FSharp-4.6/FS-1030-anonymous-records.md discusses about tuples with named fields

edit: It almost feels that with type directed things, everything with named fields, could use vanilla records syntax, and everything with positional fields, tuple syntax.

smoothdeveloper avatar Jul 08 '25 21:07 smoothdeveloper

Resurrecting := is also possible - (item1 := 1, item2 := 2) if there is no explicitly typed ref box called item1 nor item2 in scope and (:=) is not redefined.

Ideally the same syntax can be reused across named arguments (functions and methods) and tuples - this is why I chose the dot-prefix as suggested in https://github.com/fsharp/fslang-suggestions/issues/1414.

Happypig375 avatar Jul 08 '25 22:07 Happypig375

As for the dot issue[^1], I assume Don meant that we wouldn't want to use OCaml's f ~x:3 for labeled function arguments—i.e., specifically using : instead of =—since we already have C.M (x=3) for methods:

Given the issue with x=y, would it not be too terrible to have OCAML syntax?

We would just put it under a /langversion:5.0 switch.

There's no chance we would use ~ syntax here, given we already support named arguments for member calls.

But using ~ to unambiguously indicate a parameter label in both method and function applications—and maybe tuple construction, active patterns, etc.—while keeping =, might work.

E.g.,

let f x = x
type C = static member M x = x

let x = 3

// Current
f x         // 3
f (x=x)     // true
C.M (x=x)   // 3
C.M ((x=x)) // true

// Proposed: all of the above remain valid, but we can now explicitly label parameters in applications with ~
f ~x=x      // 3
C.M (~x=x)  // 3

(Note that I am not proposing that ~ be used anywhere other than application.)

~ has the advantage over . that it could be used in function applications without requiring extra parenthesization:

let x = 3
let y = 4
f ~x=x ~y=y

. would require parentheses, or else the parameter label would be interpreted as a member lookup on the function:

let x = 3
f .x=x // This would be parsed as `(f.x) = x`.

[^1]: I'm not sure whether this, https://github.com/fsharp/fslang-suggestions/issues/151, or https://github.com/fsharp/fslang-suggestions/issues/1414 is the best place to discuss it... But this is the latest of those, so here we are.

brianrourkeboll avatar Jul 08 '25 22:07 brianrourkeboll

How would one explain that this choice of usages - named tupled fields and function parameters - is in an abstract way the same concept and therefore should use the same syntactical (~) symbol?

Is it because parameters (even when curried, even when of size 1) can be seen as tuples and vice versa?

T-Gro avatar Jul 09 '25 13:07 T-Gro

@T-Gro Whenever the type signature uses * to link function or method parameters or tuple fields together, they correspond to , in the expression syntax (aside from DU field deconstruction but changing it from ; to , is also approved). Moreover, a syntactic tuple at a function or method parameter is represented by C# method parameters, linking them together.

So it makes sense to use ~ wherever , can appear.

This corresponds to C#'s usage of : named parameters across both methods and tuples.

Happypig375 avatar Jul 09 '25 13:07 Happypig375

Not a fan of introducing new syntactic usages for either dots or tildes. Why not indicate element names as part of type annotation rather than tuple construction, the way C# does it?

kerams avatar Jul 09 '25 14:07 kerams

I think the usage remains marginal, I'd like to see more named-field tuples usage in BCL and overall C# codebases, and consuming them be implemented first, and agree with @kerams that we could limit the scope of "constructing" such tuples to the strict minimum (explicit signature), for the time being.

F# may evolve to use some syntax that covers more named field tuples literals convenience, but the first step is minimum conservative adjustments for interop support.

smoothdeveloper avatar Jul 09 '25 14:07 smoothdeveloper

@kerams

Not a fan of introducing new syntactic usages for either dots or tildes

See https://github.com/fsharp/fslang-suggestions/issues/1414#issuecomment-3052989822 - this is about disambiguation of initialization from equality.

I am happy with that solution (definitely "both") as well. Anything that distinguishes an assignment to parameter/property/field from the equality operator (and only from the equality operator) is fine by me.

Why not indicate element names as part of type annotation rather than tuple construction, the way C# does it?

C# allows both type annotation and tuple construction for tuple field names.

(int a, string) tuple = (1, b: "");

Happypig375 avatar Jul 09 '25 17:07 Happypig375

https://github.com/fsharp/fslang-suggestions/issues/1434#issuecomment-3052910332

Why not indicate element names as part of type annotation rather than tuple construction, the way C# does it?

https://github.com/fsharp/fslang-suggestions/issues/1434#issuecomment-3053489828

C# allows both type annotation and tuple construction for tuple field names.

(int a, string) tuple = (1, b: "");

Yes, I've literally been writing C# today using named tuple items, although the primary reason is because in C# you cannot deconstruct tuple parameters. Here's a contrived example:

var d =
    someCollection
        .SelectMany(x => x.Ys, (x, y) => (Q: x.A, R: y.B + y.C))
        .ToDictionary(tup => tup.Q, tup => tup.R);
        // Otherwise requires tup.Item1, etc., or { var (a, _) = tup; return a; }

brianrourkeboll avatar Jul 09 '25 18:07 brianrourkeboll

Aside from ./~ prefix, we can also just use ~ as an infix operator as a single ~ is a syntactic error today.

let transformPoint (scale: float) ((x, y): x: float * y: float) (adjustments: (dx: float * dy: float) list) : resultX: float * resultY: float =
    let scaled = x * scale, y * scale
    let final = 
        adjustments 
        |> List.fold (fun (cx, cy) (dx, dy) -> cx + dx, cy + dy) scaled
    resultX ~ fst final, resultY ~ snd final

let scaleAndAdjust = transformPoint (scale ~ 2.0) 
// or we can also allow ~ without parentheses:
// let scaleAndAdjust = transformPoint scale ~ 2.0
// let scaleAndAdjust = transformPoint scale~2.0
let initialPoint = x ~ 3.0, y ~ 4.0
type Points = static member Create(dx, dy) = ~~dx, ~~dy
 // if (~~) resolves to the operator in FSharp.Core, then above expands to: (nameof dx) ~ dx, (nameof dy) ~ dy
let adjustments = [
    dx ~ 0.5, dy ~ -1.0 
    Points.Create(dx ~ -1.0, dy ~ 2.0) 
]

let result = scaleAndAdjust initialPoint adjustments
match result with resultX = x, resultY = y -> printfn $"{x}, {y}"
printfn $"{result.resultX}, {result.resultY}"

Happypig375 avatar Jul 10 '25 19:07 Happypig375

I am still none the wiser as to why we'd want special expression syntax for this when SynExpr.Typed exists, meaning you could just tack : (dx: float * dy: float) onto any tuple construction (the utility of which I do not understand either).

let initialPoint: (dx: float * dy: float) = 3.0, 4.0
let initialPoint = (3.0, 4.0): (dx: float * dy: float)

kerams avatar Jul 10 '25 20:07 kerams

Aside from ./~ prefix, we can also just use ~ as an infix operator as a single ~ is a syntactic error today.

I don't think we'd want that.

I think OCaml's usage of : in ~x:3 instead of F#'s existing = in x=3 (in methods, constructors, union pattern matching, etc.) was probably what Don was referring to in https://github.com/fsharp/fslang-suggestions/issues/151#issuecomment-474111193. So I'm pretty sure we'd want to keep the =.

brianrourkeboll avatar Jul 10 '25 20:07 brianrourkeboll

@kerams

I am still none the wiser as to why we'd want special expression syntax for this when SynExpr.Typed exists, meaning you could just tack : (dx: float * dy: float) onto any tuple construction.

it's verbose and doesn't play well with struct tuple inference - you'd likely need to write things like 1.0, 2.0 : struct(dx:_ * dy:_) and 1.0, 2.0 : dx:_ * dy:_

Happypig375 avatar Jul 10 '25 20:07 Happypig375

No to "inferred names" because the name of a thing should not affect its type. If x is a tuple and let (a, b) = x and let (c, d) are bindings then clearly a,b and c,d are eternal to x and the letters a,b,c,d are not part of the definition of x. This principle then applies to binding within method arguments.

It would be good to support C# ValueTuple names and would be ideal if we could merge this into anonymous records, so that named dotnet ValueTuples are the implementation of F# anonymous records. "To align with changes in dotnet, we changed the anonymous record implementation from reference to value types" would be a perfectly good justification for a small behavioral change here.

charlesroddie avatar Jul 14 '25 08:07 charlesroddie

... and would be ideal if we could merge this into anonymous records, so that named dotnet ValueTuples are the implementation of F# anonymous records.

Actually anonymous records can be value types

> struct {|A=1|};;
val it: struct {| A: int |} = { A = 1 }

ijklam avatar Jul 14 '25 09:07 ijklam

@charlesroddie The original design document of struct anonymous records explicitly chose concrete struct types instead of ValueTuple to enable reflection scenarios (highly relevant in boilerplate-free serialization). C# ValueTuple and F# struct anonymous records are mutually exclusive and switching to a ValueTuple representation for F# struct tuples is a breaking change for these scenarios.

Happypig375 avatar Jul 14 '25 12:07 Happypig375

Are the C# named tuples a common thing to be exposed across assembly boundaries? If yes, what are the usage examples?

If working with named tuples from C# is a common pain point, I think it is better to design it as a separate language suggestion targeting just that interop concern. Translating to/from attribute metadata about the names that C# compiler puts on, without affecting the inner working of F#.

T-Gro avatar Jul 24 '25 07:07 T-Gro

@T-Gro https://github.com/fsharp/fslang-suggestions/issues/1354 lists one API member in the BCL (System.Numerics.BigInteger.DivRem), I had queried the BCL using reflection but don't find the script I used to do so.

We should actually scan nuget.org, but that's a whole ordeal.

smoothdeveloper avatar Jul 24 '25 07:07 smoothdeveloper

Even if not for C#, named tuples come naturally from partial application of constructors, methods, and DU cases. They preserve important contextual information just like how partial application of functions should preserve parameter names.

Happypig375 avatar Jul 24 '25 08:07 Happypig375

There are 59 methods (230 method overrides in total) returning ValueTuples as result in the BCL.

Index Method name Overrides count
1 System.TupleExtensions.ToValueTuple 21
2 System.Runtime.Intrinsics.Arm.AdvSimd.LoadAndInsertScalar 21
3 System.Math.DivRem 10
4 System.Runtime.Intrinsics.Arm.Sve.Load4xVectorAndUnzip 10
5 System.Runtime.Intrinsics.Arm.Sve.Load3xVectorAndUnzip 10
6 System.Runtime.Intrinsics.Arm.Sve.Load2xVectorAndUnzip 10
7 System.ValueTuple.Create 9
8 System.Runtime.Intrinsics.Arm.AdvSimd.LoadAndReplicateToVector64x3 7
9 System.Runtime.Intrinsics.Vector512.Widen 7
10 System.Runtime.Intrinsics.Vector64.Widen 7
11 System.Runtime.Intrinsics.Arm.AdvSimd.LoadAndReplicateToVector64x2 7
12 System.Runtime.Intrinsics.Arm.AdvSimd.LoadAndReplicateToVector64x4 7
13 System.Runtime.Intrinsics.Arm.AdvSimd.Load2xVector64AndUnzip 7
14 System.Runtime.Intrinsics.Arm.AdvSimd.Load4xVector64AndUnzip 7
15 System.Runtime.Intrinsics.Arm.AdvSimd.Load2xVector64 7
16 System.Runtime.Intrinsics.Arm.AdvSimd.Load3xVector64 7
17 System.Runtime.Intrinsics.Arm.AdvSimd.Load4xVector64 7
18 System.Runtime.Intrinsics.Vector128.Widen 7
19 System.Runtime.Intrinsics.Arm.AdvSimd.Load3xVector64AndUnzip 7
20 System.Runtime.Intrinsics.Vector256.Widen 7
21 System.Runtime.Intrinsics.X86.X86Base.DivRem 4
22 System.Runtime.Intrinsics.Vector256.SinCos 2
23 System.Numerics.Vector.SinCos 2
24 System.Runtime.Intrinsics.Vector128.SinCos 2
25 System.Numerics.BigInteger.DivRem 2
26 System.Runtime.Intrinsics.Vector64.SinCos 2
27 System.Runtime.Intrinsics.Vector512.SinCos 2
28 System.Runtime.DependentHandle.get_TargetAndDependent 1
29 System.Runtime.Intrinsics.X86.X86Base.CpuId 1
30 System.Runtime.InteropServices.NFloat.SinCos 1
31 System.Numerics.ITrigonometricFunctions`1.SinCosPi 1
32 System.Numerics.IBinaryInteger`1.DivRem 1
33 System.Runtime.InteropServices.NFloat.SinCosPi 1
34 System.Math.SinCos 1
35 System.MathF.SinCos 1
36 System.Byte.DivRem 1
37 System.Double.SinCos 1
38 System.Double.SinCosPi 1
39 System.Half.SinCos 1
40 System.Half.SinCosPi 1
41 System.Int16.DivRem 1
42 System.Int32.DivRem 1
43 System.Int64.DivRem 1
44 System.Int128.DivRem 1
45 System.Numerics.ITrigonometricFunctions`1.SinCos 1
46 System.IntPtr.DivRem 1
47 System.SByte.DivRem 1
48 System.Single.SinCos 1
49 System.Single.SinCosPi 1
50 System.UInt16.DivRem 1
51 System.UInt32.DivRem 1
52 System.UInt64.DivRem 1
53 System.UInt128.DivRem 1
54 System.UIntPtr.DivRem 1
55 System.Numerics.Vector2.SinCos 1
56 System.Numerics.Vector3.SinCos 1
57 System.Numerics.Vector4.SinCos 1
58 System.Range.GetOffsetAndLength 1
59 System.Console.GetCursorPosition 1

ijklam avatar Jul 24 '25 13:07 ijklam

hot from HN: https://news.ycombinator.com/item?id=44843571

I do not understand how they could develop a language inspired by OCaml but not bring over labeled function arguments. A real L when it comes to ergonomics. And they just have no plans to ever fix this??

smoothdeveloper avatar Aug 09 '25 05:08 smoothdeveloper

@Happypig375 reflection scenarios (highly relevant in boilerplate-free serialization)

Serialization via reflection is an unfortunate thing that used to happen in dotnet. We shouldn't let it stick around in F#.

@smoothdeveloper hot from HN: https://news.ycombinator.com/item?id=44843571

That issue is https://github.com/fsharp/fslang-suggestions/issues/961#issuecomment-767530376 I think someone should open a new issue copying and pasting that comment. I could do it: it's not something that I would use, but it makes sense to me to have more consistency between methods and functions so I would support it.

charlesroddie avatar Aug 09 '25 21:08 charlesroddie

@charlesroddie But people do use it and reflection was an explicit design goal of anonymous types.

Also, with named tuple fields, there is no requirement to give all fields names - some can have names, some can have no name. Records, anonymous or otherwise, don't allow that.

The important distinction between tuples and records is that tuple fields have a natural order and record fields don't. This semantic difference shouldn't be conflated by having anonymous records be exposed as tuples.

Happypig375 avatar Aug 10 '25 07:08 Happypig375

That issue is #961 (comment) I think someone should open a new issue copying and pasting that comment. I could do it: it's not something that I would use, but it makes sense to me to have more consistency between methods and functions so I would support it.

Done in https://github.com/fsharp/fslang-suggestions/issues/1436

charlesroddie avatar Aug 10 '25 15:08 charlesroddie

As I am revisiting the suggestion, I decided to apply the "core-functional" label to signal this having a big impact on most of today's processing of function type inference (currying, partial application, passing in lambdas with arguments and related IDE services). I want that to signal this as a bigger change in how things work and not just mere syntax sugar.

That made me realize that the existing way for signaling importance of meaning for certain elements/arguments is missing in this suggestion - using types to signal meaning. In the specific example here, one could as well represent a dx argument by a delta type wrapper, and use e.g. units of measure to separate the x and y dimensions.

T-Gro avatar Sep 04 '25 12:09 T-Gro