fsharp / fslang-suggestions

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

Spread operator for F# #1253

Open dsyme opened 1 year ago

dsyme commented 1 year ago

I propose we consider the merits of a spread operator for F#

...expr

It would be used for

Records
type A = {
    X: int
    Y: int
}
type B = {
    Z: int
}
type C = {
    X: int
    Y: int
    Z: int
    Extra: string
}
let a = { X = 1; Y = 2 }
let b = { Z = 3 }
let c = { ...a; ...b Extra = "four" }
Anonymous records
let a = {| X = 1; Y = 2 |}
let b = {| Z = 3 |}
let c = {| ...a; ...b; Extra = "four" |}
Object expressions
type IA =
    abstract A: int -> int
type IAB =
    abstract A: int -> int
    abstract B: int -> int

let a =
    { new IA with
        member _.A x = x + 1 }
let b =
    { new IB with
        ...a
        member _.B x = x - 1 }
Interface implementations
type IA =
    abstract A: int -> int
type IAB =
    abstract A: int -> int
    abstract B: int -> int

type A() =
    interface IA with
        member _.A x = x + 1
type B(a: IA) =
    interface IB with
        ...a
        member _.B x = x - 1 

The existing way of approaching this problem in F# is explicit re-delegation.

Technical details?

Fundamental technical questions are

For example

Patterns?

Should a spread operator be allowed in patterns? e.g.

match r with 
| {| A = a; ...rest |} -> ...

Initially there would I think be no need for this.

Types?

How about type syntax? e.g.

type A = {
    X: int
    Y: int
}
type B = {
    Z: int
}
type C = {
    ...A;
    ...B;
    Extra: string
}
let a = { X = 1; Y = 2 }
let b = { Z = 3 }
let c = { ...a; ...b Extra = "four" }

Spreading record types into other record types seems tempting, but note this would not give rise to subtyping. But then can you spread object types into record types? Seems like a step too far?

Collections?

Spread operators in other languages usually work with

Bat all of this is necessarily desirable for a spread operator in F#, though each should be considered and if they aren't supported good diagnostics should be given directing people to the right way to do things.

Pros and Cons

The advantages of making this adjustment to F# are conceptual efficiency, introduction of a littler structural matching

The disadvantages of making this adjustment to F# are

Extra information

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

Affidavit (please submit!)

Please tick this 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.

brianrourkeboll commented 1 year ago

I suppose it could technically subsume yield! in computation expressions as well.

let xs = [3..10]
let ys = [1; 2; ...xs]
dsyme commented 1 year ago

I suppose it could technically subsume yield! in computation expressions as well.

Yes, it could. Which then raises the question of return! too.

baronfel commented 1 year ago

A 'rest' operator for list-like patterns would be very helpful - right now list is privileged in terms of syntax because it can easily be deconstructed into parts of a certain length and 'the rest' - other list-like types (those that are indexable) would be more usable if they has a way to achieve the same result.

dsyme commented 1 year ago

A 'rest' operator for list-like patterns would be very helpful - right now list is privileged in terms of syntax because it can easily be deconstructed into parts of a certain length and 'the rest' - other list-like types (those that are indexable) would be more usable if they has a way to achieve the same result.

Yes also allows a "match at end" syntax for arrays and lists.

cartermp commented 1 year ago

RIP tooling logic for handling dots 😄

Lanayx commented 1 year ago

I'd like to see DUs added to suggestion as well on type level, so one can use

type One = 
    | X of int 
    | Y of string

type Two =
    | ...One
    | Z of string

The benefits are less prominent on instance level, but also usable

type One = 
    | X of int 
    | Y of string

type Two =
    | X of int 
    | Y of string
    | Z of string

let x: One = X 1
let y: Two = ...x
smoothdeveloper commented 1 year ago

@dsyme, would you mind expanding a bit on the compile time and possibly runtime semantics you envision for the concrete implementation of this on CLR?

Also, would you mind sharing about the background / prior art, that is inspiring this feature?

Spreading record types into other record types seems tempting, but note this would not give rise to subtyping. But then can you spread object types into record types? Seems like a step too far?

Not having support for spreading at type level seems it would force redundant work on type definition, when the author desires to always map all the properties; design-wise, it would not be encouraged, but for the use cases of full mapping being ever desired, rather than selective / explicit one. I think it improves semantics of the code to support it on type definition.

@Lanayx example for DU is also good to consider, if records is going to support this.

Spreading object types into records or even a DU case, seems appealing to me, but not needed for first iteration in landing this feature.

This feature would be useful for https://github.com/isaksky/FsSqlDom which maps MSSQL Dom to DU, where bulk of declarations is done in code generation, it could be done with spreadability of object into DU cases.

I'm also seeing use case where it would be possible to explicitly omit some of the things, #762; I think it should prompt question about ... being the best notation; I've considered += { a without PropName1; PropName2 }

it may lead to a cascading set or requirements for using spread operators and related structural manipulations in generic code.

Are you considering some of the SRTP constructs would ideally, need to support a notation describing "spreadability semantics" between any two types or, type to set of types?

Are values of a SRTP-constrained type considered spreadable?

It seems to me, this would be powerful to make selective spread operators, which monomorphizes at compile time.

Are values of a class-constrained generic type considered spreadable?

Given the runtime cost, assuming this would be runtime reflection based, I'd see this as least priority than the SRTP approach (now that SRTP is embraced better, due to IWSAM giving "run for the money" :D), people can use automapper stuff, it seems it can be library based, specific to semantics of CLR, not specific to F#, less added value than SRTP semantics.

some subtle corner cases

Sensing inheritance is going to be headache inducing, I believe for a first approach (in order to get the feature out, for the already known effective use cases), it would be fine to bail out, if there is any kind of clash / ambiguity surfacing from multiple or conflicting inheritance scenarios.

The main issue is that it is still work to verify those cases, and report errors that make sense, but this would allow to defer decisions in this area, while seeing the real world usages and deriving feedback from it for, later, opening those use cases that would be guarded.

xp44mm commented 1 year ago

With this feature, the clone record syntax { a with } will be improved by spread record syntax id<'targetType> { ...a }.

The record spread syntax is similar to javascript, so "record field value shorthands" syntax may be a good feature as well.

type A = {
    X: int
    Y: int
}
type B = {
    Z: int
}
type C = {
    X: int
    Y: int
    Z: int
    Extra: string
}
let a = { X = 1; Y = 2 }
let b = { Z = 3 }
let Extra = "four" //*
let c = id<C>{ ...a; ...b; Extra  } // <- Extra

@brianrourkeboll I agree with you.+1 https://github.com/fsharp/fslang-suggestions/issues/973#issue-796786555

aaronmu commented 1 year ago

Interesting, I think it might improve developer experience a lot.

Would it be possible to spread anon records into a normal record?

type A = { X: int; Y: int; Z: int }

let x = {| X = 1; Y = 2 |}
let y = {| Z = 3 |}

let a = {...x; ...y }

How about combining anon records?

let x = {| X = 1; Y = 2 |}
let y = {| Z = 3 |}
let z = {| ...x; ...y |}

What would happen in the below scenario?

type A = { X: int; Y: int }
type B = { X: int }

let a : A = { X = 1; Y = 1 }
let b : B = { ...a }

Ideally all of these examples just work :-)

dsyme commented 1 year ago

Would it be possible to spread anon records into a normal record?

Yes, that's the intention of the design.

What would happen in the below scenario?

Yes, there could be excess properties. The new object "b" would not have property Y. My understanding is this is different to TypeScript, where all properties are copied across as the JS semantics is used at runtime (TS typing doesn't affect runtime behaviour).

How about combining anon records?

Yes, that would be in scope

dsyme commented 1 year ago

@smoothdeveloper

Not having support for spreading at type level seems it would force redundant work on type definition, when the author desires to always map all the properties; design-wise, it would not be encouraged, but for the use cases of full mapping being ever desired, rather than selective / explicit one. I think it improves semantics of the code to support it on type definition.

I'm a bit ambivalent about support in type definitions. The more "programmatic" type definitions become, the harder they are to understand - to work out what properties are in a type you have to "run the type level program in your head". This is sort of OK for expressions - which are "real" programs and have debugging, printing etc. support - but for type level programs it's all compile-time execution. It's basically similar to many of my concerns about type-level programming I've expressed in the past, e.g. #243

Still we could do expressions first and leave types to later.

dsyme commented 1 year ago

@Lanayx Regarding https://github.com/fsharp/fslang-suggestions/issues/1253#issuecomment-1464312424 - interesting. I can see there is some kind of potential here, perhaps as a third wave.

octaviordz commented 1 year ago

The more "programmatic" type definitions become, the harder they are to understand

For what is worth. I agree. Maybe tooling could help here. Implement the expansion of ...A at design time in a tooling action.


type A = {
    X: int
    Y: int
}
type B = {
    ...A;
    Extra: string
}
Lanayx commented 1 year ago

I've tried chatgpt to validate this proposal and it totally supports it! I has also found new cases! Attaching the responses

First part:

Title: Spread Operator in F#
Author: ChatGPT
Status: Draft
Date: 2023-03-18

Abstract:
This document proposes the addition of a spread operator in the F# programming language. The spread operator is designed to simplify the syntax and improve readability when working with lists, arrays, and other collection types. It allows for the unpacking of elements in a collection to be used as individual arguments in a function call or to merge collections.

    Introduction

The F# programming language, as a functional programming language, emphasizes immutability and the use of collections for data manipulation. However, working with collections can sometimes involve verbose syntax, which can reduce readability and maintainability. This document proposes the addition of a spread operator, similar to the one found in languages like JavaScript and Python, to simplify the handling of collections in F#.

    Specification

The spread operator is proposed to be implemented using the '...' syntax. The operator should work with the following collection types:

    Lists
    Arrays
    Sequences

2.1. Spreading in function calls

The spread operator can be used to unpack elements from a collection and use them as individual arguments in a function call. This simplifies function calls that require multiple arguments from a single collection.

Example:

fsharp

let addThreeNumbers a b c = a + b + c
let numbers = [1; 2; 3]

let result = addThreeNumbers ...numbers

2.2. Merging collections

The spread operator can be used to merge multiple collections into a single one.

Example:

fsharp

let list1 = [1; 2; 3]
let list2 = [4; 5; 6]

let mergedList = [...list1; ...list2] // [1; 2; 3; 4; 5; 6]

    Backward Compatibility

The proposed spread operator does not affect any existing F# features or syntax, and therefore should not introduce backward compatibility issues.

    Implementation

The spread operator should be implemented as part of the F# compiler, modifying the existing syntax parsing and type checking to support the new feature.

    Acknowledgments

The spread operator is inspired by similar features found in languages such as JavaScript, Python, and Ruby, as well as the numerous proposals and discussions in the F# community.

    References

    F# Language Specification: https://fsharp.org/specs/language-spec/
    ECMAScript Spread Operator: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax
    Python PEP 448: https://www.python.org/dev/peps/pep-0448/

Second part:

Title: Spread Operator in F# for Records and Discriminated Unions
Author: ChatGPT
Status: Draft
Date: 2023-03-18
Extension to: Spread Operator in F#

Abstract:
This document extends the previous RFC on the spread operator in F# to include support for records and discriminated unions. The spread operator simplifies the syntax and improves readability when working with F# data structures, allowing for the unpacking and merging of fields or cases.

    Introduction

Building on the previous RFC on the spread operator in F#, this extension proposes the addition of spread operator support for F# records and discriminated unions. The spread operator can simplify the creation and manipulation of these data structures, improving readability and maintainability.

    Specification

The spread operator should be extended to work with the following F# data structures:

    Records
    Discriminated Unions

2.1. Spreading in records

The spread operator can be used to unpack fields from an existing record and create a new record using the values of those fields. This simplifies the creation of new records based on existing ones.

Example:

fsharp

type Person = { FirstName: string; LastName: string; Age: int }

let john = { FirstName = "John"; LastName = "Doe"; Age = 30 }
let jane = { ...john; FirstName = "Jane" } // { FirstName = "Jane"; LastName = "Doe"; Age = 30 }

2.2. Spreading in discriminated unions

The spread operator can be used to unpack cases from a discriminated union and create a new instance of the union using the values of those cases. This simplifies the creation of new instances based on existing ones.

Example:

fsharp

type Shape =
    | Circle of radius: float
    | Rectangle of width: float * height: float

let circle = Circle 5.0
let biggerCircle = Circle ...(radius + 2.0) // Circle 7.0

let rectangle = Rectangle (3.0, 4.0)
let tallerRectangle = Rectangle ...(width, height + 2.0) // Rectangle (3.0, 6.0)

    Backward Compatibility

The proposed spread operator extension does not affect any existing F# features or syntax and should not introduce backward compatibility issues.

    Implementation

The spread operator extension should be implemented as part of the F# compiler, modifying the existing syntax parsing and type checking to support the new feature for records and discriminated unions.

    Acknowledgments

The spread operator is inspired by similar features found in languages such as JavaScript, Python, and Ruby, as well as the numerous proposals and discussions in the F# community.

    References

    F# Language Specification: https://fsharp.org/specs/language-spec/
    ECMAScript Spread Operator: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax
    Python PEP 448: https://www.python.org/dev/peps/pep-0448/

Third part

Title: Type-level Spread Operator in F# for Records and Discriminated Unions
Author: ChatGPT
Status: Draft
Date: 2023-03-18
Extension to: Spread Operator in F#

Abstract:
This document extends the previous RFCs on the spread operator in F# to include type-level support for records and discriminated unions. The type-level spread operator simplifies the process of composing and extending F# data structures by merging the types of existing records or discriminated unions.

    Introduction

The previous RFCs introduced the spread operator in F# for lists, arrays, sequences, records, and discriminated unions at the value level. This extension proposes the addition of a type-level spread operator, which simplifies the composition and extension of F# data structures by merging the types of existing records or discriminated unions.

    Specification

The type-level spread operator should be extended to work with the following F# data structures:

    Records
    Discriminated Unions

2.1. Type-level spreading in records

The type-level spread operator can be used to create a new record type by merging the fields from existing record types. This simplifies the composition of complex record types.

Example:

fsharp

type Name = { FirstName: string; LastName: string }
type Age = { Age: int }

type Person = { ...Name; ...Age }

let john: Person = { FirstName = "John"; LastName = "Doe"; Age = 30 }

2.2. Type-level spreading in discriminated unions

The type-level spread operator can be used to create a new discriminated union type by merging the cases from existing discriminated union types. This simplifies the composition of complex discriminated union types.

Example:

fsharp

type Shape2D =
    | Circle of radius: float
    | Rectangle of width: float * height: float

type Shape3D =
    | Sphere of radius: float
    | Cube of side: float

type Shape = ...Shape2D | ...Shape3D

let circle: Shape = Circle 5.0
let cube: Shape = Cube 3.0

    Backward Compatibility

The proposed type-level spread operator extension does not affect any existing F# features or syntax and should not introduce backward compatibility issues.

    Implementation

The type-level spread operator extension should be implemented as part of the F# compiler, modifying the existing syntax parsing and type checking to support the new feature for records and discriminated unions at the type level.

    Acknowledgments

The type-level spread operator is inspired by similar features found in languages such as TypeScript, as well as the numerous proposals and discussions in the F# community.

    References

    F# Language Specification: https://fsharp.org/specs/language-spec/
    TypeScript Spread Operator: https://www.typescriptlang.org/docs/handbook/2/objects.html#spread-types

Fourth part

Title: Spread Operator in F# for Combining Modules
Author: ChatGPT
Status: Draft
Date: 2023-03-18
Extension to: Spread Operator in F#

Abstract:
This document extends the previous RFCs on the spread operator in F# to include combining modules without drilling down to the underlying functions. The proposal introduces the spread operator to merge modules into a single module, simplifying the process of combining and organizing functions from different modules.

    Introduction

The previous RFCs introduced the spread operator in F# for lists, arrays, sequences, records, discriminated unions, and their respective types. This extension proposes the addition of a spread operator for F# modules, which simplifies the process of combining modules without the need to specify individual functions.

    Specification

The spread operator should be extended to work with the following F# language feature:

    Modules

2.1. Spread operator for combining modules

The spread operator can be used to merge modules into a single module. This simplifies the process of combining functions from different modules and improves code organization.

Example:

fsharp

module MathOperations =
    let add x y = x + y
    let subtract x y = x - y
    let multiply x y = x * y

module StringOperations =
    let concat s1 s2 = s1 + s2
    let length s = s.Length

module Util =
    ...MathOperations
    ...StringOperations

let result1 = Util.add 5 3
let result2 = Util.subtract 7 2
let result3 = Util.concat "Hello" ", F#"

In this example, the Util module merges the MathOperations and StringOperations modules using the spread operator. This makes it easy to combine modules without having to import specific functions from each module.

    Backward Compatibility

The proposed spread operator extension for combining modules does not affect any existing F# features or syntax and should not introduce backward compatibility issues.

    Implementation

The spread operator extension for combining modules should be implemented as part of the F# compiler, modifying the existing syntax parsing and type checking to support the new feature for merging modules.

    Acknowledgments

The spread operator for combining modules is inspired by similar features found in languages such as JavaScript (ES6) and TypeScript, as well as the numerous proposals and discussions in the F# community.

    References

    F# Language Specification: https://fsharp.org/specs/language-spec/
    JavaScript (ES6) Spread Operator: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax
    TypeScript Spread Operator: https://www.typescriptlang.org/docs/handbook/2/objects.html#spread-types

Fifth

Title: Extending Spread Operator to Tuples in F#
Summary

This RFC proposes extending the spread operator to work with tuples in F#. This addition will allow users to merge tuples more easily and create new tuples by combining existing ones.
Motivation

Currently, combining tuples or extracting and merging their elements requires manual handling, which can be cumbersome and error-prone. By extending the spread operator to work with tuples, F# developers will be able to merge tuples more efficiently and write cleaner, more concise code.
Detailed Design

The spread operator will be extended to support tuples, allowing users to merge two or more tuples into a new tuple. The syntax for using the spread operator with tuples will be similar to the one used with records and discriminated unions.
Syntax

The spread operator can be used in tuple construction:

fsharp

let tuple1 = (1, "a")
let tuple2 = (2, "b")
let combinedTuple = (..tuple1, ..tuple2) // combinedTuple = (1, "a", 2, "b")

Nested Tuples

The spread operator can be used with nested tuples as well:

fsharp

let tuple1 = (1, (2, 3))
let tuple2 = (4, (5, 6))
let combinedTuple = (..tuple1, ..tuple2) // combinedTuple = (1, (2, 3), 4, (5, 6))

Restrictions

Using the spread operator with tuples of different types is allowed. However, the type of the resulting tuple will be determined by the combined types of the input tuples.
Implementation

The spread operator for tuples can be implemented as a compiler feature, expanding the spread syntax into tuple construction code.
Drawbacks

    Additional complexity: Extending the spread operator to support tuples increases the complexity of the language and the compiler implementation.
    Potential confusion: Users might get confused about the syntax and behavior of the spread operator for tuples, especially if they are not familiar with the spread operator in other contexts (e.g., records, discriminated unions).

Alternatives

One alternative is to continue using manual tuple handling and merging, without the spread operator. However, this approach is less ergonomic and can lead to more cumbersome and error-prone code.
Unresolved Questions

    Should the spread operator handle optional elements in tuples?
    Are there any performance implications or concerns when using the spread operator with large tuples?

Conclusion

By extending the spread operator to work with tuples, F# developers will benefit from more concise and expressive code when working with tuples. This addition will improve the ergonomics and simplicity of tuple manipulation in F#.
xp44mm commented 1 year ago

2.+. Spreading in tuples

The spread operator can be used to unpack elements from an existing tuple and create a new tuple using the values of those elements. This simplifies the creation of new tuples based on existing ones.

Example:

let john =  "John","Doe",30
let jane =  "Doctor",...john // "Doctor","John","Doe",30
T-Gro commented 1 year ago

Would a single let binding (= 1 name + 1 value) be considered spreadable as well? It would increase the overlap with other languages, i.e. the expected behavior. At the same, the chance of errors would probably increase.

let x = {| X = 1; Y = 2 |}
let y = "value for y"

let a = {...x; ...y }
//  {| X = 1; Y = 2; y = "value for y" |}
octaviordz commented 1 year ago
let x = {| X = 1; Y = 2 |}
let y = "value for y"

let a = {...x; ...y }
//  {| X = 1; Y = 2; y = "value for y" |}

What language does that?, not Javascript is it?.

const a = {x, y};  // expands to:  const a = {x: x, y: y};

Shorthand property names/shorthandProperty, example. https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Object_initializer

dsyme commented 1 year ago

@Lanayx A fascinating use of ChatGPT! However I'm not particularly inclined to include any of those above examples. They make sense in JS and Python but the F# and .NET type/argument/module system just doesn't unify in quite the same way to make these particularly feasible I think.

dsyme commented 1 year ago

I'm going to mark this as approved in principle, though there would be many details to consider.

I suspect the biggest problematic area will be about any spreading involving providing of fulfilling object members (methods, properties, events etc.) as this is quite a large space, especially once extension members are considered. It could be that a phased approach would be needed, taking care not to rule out application in these areas.

Happypig375 commented 1 year ago

We should also consider a negated spread since this suggestion subsumes with, while we also have a suggestion for without: https://github.com/fsharp/fslang-suggestions/issues/762.

type A = {
    X: int
    Y: int
}
type B = {
    Y: int
    Z: int
}
type C = { ...A; not ...B }
// type C = { X: int }
vzarytovskii commented 1 year ago

We should also consider a negated spread since this suggestion subsumes with, while we also have a suggestion for without: https://github.com/fsharp/fslang-suggestions/issues/762.

type A = {
    X: int
    Y: int
}
type B = {
    Y: int
    Z: int
}
type C = { ...A; not ...B }
// type C = { X: int }

Yeah, without should be part of it. Wondering what's the solution in JS/TS

Lanayx commented 1 year ago

TS provides Omit construct for type-level without

vzarytovskii commented 1 year ago

TS provides Omit construct for type-level without

It feels very inconsistent - have a language construct for extending types/records and have type-level construct for narrowing them.

Lanayx commented 1 year ago

For TS I think it is ok, since spread is only for instance level there and constructs are for type level. For F# we are planning to use spread everywhere, so we need to find another way

BrianVallelunga commented 1 year ago

Correct me if I'm wrong, but using this along with anonymous records I still can't make a function that restricts parameters to records (of any type), and then returns an anonymous record, right?

let joinAndAddX (a: 'a) (b: 'b) =
   {| ...a; ...b with X = "Hello" |}
realvictorprm commented 1 year ago

RIP tooling logic for handling dots 😄

I feel that lol

izackhub commented 7 months ago

This feature would be incredibly practical and will greatly reduce code duplication in may situation, especially when copuled with the also proposed without (which may also be called omit to better align with FP terms) feature.

I have always missed this feature since I lernt of the ballerina languages 'type inclusion' concept.

Lanayx commented 5 months ago

While someone will be creating an RFC for this, specifically for spreading on record types, there will be a choice - to copy attributes together with fields definitions or not. I personally think that attributes should be copied as well.