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

Allow comma and parentheses for tuple type signatures

Open bisen2 opened this issue 2 years ago • 27 comments

Allow comma and parentheses for tuple type signatures

I propose we allow using a comma and parentheses in declaring tuples in type signatures. A type signature 't * 'u could be rewritten as ('t, 'u). For example:

type Demo =
    abstract member f : int * string            // current tuple declaration
    abstract member g : (int, string)           // would be equivalent to `g` with this suggestion

    abstract member h : seq<int * string>       // current tuple instantiation of type arg
    abstract member i : seq<int, string>        // should still fail - seq only takes one type arg
    abstract member j : seq<(int, string)>      // would be equivalent to `i` with this suggestion
    abstract member k : Map<int, string>        // should continue to work as before - no parens, not a tuple
    abstract member l : Map<int, int * string>  // current tuple instantiation of type arg
    abstract member l : Map<int, (int, string)> // 'TKey = int, 'TValue = int * string aka (int, string)

The existing way of approaching this problem in F# is using the existing 't * 'u syntax.

Pros and Cons

The advantage of making this adjustment to F# is bringing the type signature syntax inline with the implementation syntax. Defining a tuple as 't * 'u but implementing it as x, y is a common stumbling block for first time F#ers.

The disadvantages of making this adjustment to F# are:

  • Having two ways to say the same thing
  • Moving further from ML style
  • Breaking change? I don't think that it is a breaking changes as type signature with this syntax does not compile currently, but I might be missing a corner case in my assumption on that.

Extra information

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

Related suggestions: (put links to related suggestions here)

Affidavit (please submit!)

Please tick this 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] I have searched both open and closed suggestions on this site and believe this is not a duplicate
  • [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.

Please tick all that apply:

  • [x] This is not a breaking change to the F# language design See note in Disadvantages
  • [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.

bisen2 avatar Apr 15 '22 18:04 bisen2

type X<'a> = 
    {
        a: 'a
    }

type X<'a, 'b> = 
    {
        a: 'a
        b: 'b
    }

let x1: X<int> = { a = 123 }
let x2: X<bool, int> = {a = false; b = 123}
let x3: X<bool*int> = { a = (false, 123) }
let ``x??``: X<bool, int> = {a = (false, 123) } // wut?

yatli avatar Apr 15 '22 18:04 yatli

The existing symbols | and * are carefully chosen because they are associated with or types and and types, so they make perfect sense to a lot of developers. I don't think newcomers have any more trouble learning this than anything else unfamiliar in the language. I think the proposed syntax complicates the language rather than simplify it, even more so since it is an alternative. I think it is going to cause a lot of confusion.

BentTranberg avatar Apr 15 '22 19:04 BentTranberg

One problem is that (int, string) Map is already a rarely-used syntax equivalent to Map<int, string>. So with your proposal, if you wanted an option of a tuple, you couldn't write (int, string) option, because that already means a 2-parameter option<int, string>. You would have to write ((int, string)) option, which is quite confusing.

Tarmil avatar Apr 15 '22 21:04 Tarmil

The existing symbols | and * are carefully chosen because they are associated with or types and and types, so they make perfect sense to a lot of developers.

This would have been convincing if we used & for "and", or + for "or". Currently it's a weird pair between +* and &|.

Happypig375 avatar Apr 16 '22 05:04 Happypig375

ASCII has a limited set of symbols. We talk about and types and or types, but we also talk about multiplying types together (tuples) and adding types together (discriminated unions). There's a correspondence between logic operators and arithmetic operators.

edit/add: We also have the terms sum type and product type.

BentTranberg avatar Apr 16 '22 07:04 BentTranberg

There's a correspondence between logic operators and arithmetic operators.

Yes. If we focused on the corresponding logic operators we would have & for "and types" and | for "or types". If we focused on the arithmetic logic operators we would have * for "product types" and + for "sum types".

However, currently we use * and |. It is an inconsistent correspondence where one relates to arithmetic and the other to logic.

Happypig375 avatar Apr 16 '22 09:04 Happypig375

Personally, I appreciate having a "language of definition", which is distinct from a "language of implementation".

But I'm also a kook, who wishes signature files actually drove more of the development process (at least for non-scripting modalities). 🤷‍♂️😉

pblasucci avatar Apr 16 '22 11:04 pblasucci

This suggestion makes a lot of sense, and makes the language more intuitive/beginner-friendly without taking anything away from more experienced users.

FunctionalFirst avatar Apr 16 '22 13:04 FunctionalFirst

The existing symbols | and * are carefully chosen because they are associated with or types and and types, so they make perfect sense to a lot of developers.

I've been doing OCaml/F# for 6 years and it's the first time I've heard of this, and I fail to get the connection - is this something used in math papers? I'm not sure that that's a thing that many developers have experience with. F#'s record type, which is the other "product type" apart from tuples, doesn't use this syntax either, so this doesn't seem to me to be a compelling argument.

pbiggar avatar Apr 16 '22 14:04 pbiggar

@pbiggar well it makes perfect sense to me in that:

  • "| ... of ..." means named disjunction of multiple types.
  • "*" means "Cartesian product", not "and"
  • "and" would be for conjunction of possible values of multiple types. Not really applicable to F#.

Records/anonymous records are not in simple Cartesian product spaces, they have named dimensions.

yatli avatar Apr 16 '22 15:04 yatli

@yatli Then what would a + type look like?

Happypig375 avatar Apr 17 '22 05:04 Happypig375

FWIW I wish this were (more or less) what documenting tuples in signatures looked like in F# 1.0. But as it stands, having an alternative syntax with no hope of deprecating the old one would probably make this a non-starter. Quirky syntax that's unique is at least documentable, and generally less confusing than 2 syntaxes that mean the same thing

cartermp avatar Apr 17 '22 05:04 cartermp

@Happypig375 I imagine + to be for C-style tagless unions :)

@cartermp see my 1st comment, it breaks existing code that use the same type name over different parameter arity.

yatli avatar Apr 17 '22 06:04 yatli

@yatli So the difference between & and + would be that & is typed while + is tagless and interpretable as different types?

Happypig375 avatar Apr 17 '22 07:04 Happypig375

Without going with implementing the suggestion, fsc could be improved to recognise the mistake and prompt the user to correct to the appropriate syntax.

currently:

type Demo =
    abstract member f : int * string            // current tuple declaration
    abstract member g : (int, string)           // would be equivalent to `g` with this suggestion

error FS0010: Incomplete structured construct at or before this point in member definition

better message:

you are using tuple literal syntax rather than tuple type definition syntax: '(int, string)' , try '(int * string)'

smoothdeveloper avatar Apr 17 '22 07:04 smoothdeveloper

Just to note that from time to time I've considered a wholesale revision of signature syntax, of which this would be a part. Fully deprecating (int, int) Map in F# 6 can also be seen as a small step towards this.

This would have to include parameter names too, e.g.

module M =
    /// This is f
    val f: x: int * y: int -> int

presumably becomes

module M =
    /// This is f
    val f: (x: int, y: int) -> int

or perhaps this:

module M =
    /// This is f
    val f(x: int, y: int) : int

But note there are subtleties here, in particular in F# compiled form

val f: int * int -> int

differs from

val f: (int * int) -> int

The first being compiled to a method taking two arguments, the second being compiled to a method taking one single tuple argument. That is, the use of (int, int) requires parentheses - but the absence of presence of parentheses already has meaning in F#.

dsyme avatar Apr 18 '22 03:04 dsyme

@yatli @Happypig375

Example syntax if extrapolated for consistency:

open System

// typed: & |
type MyRecord = Id: Guid & Name: string
type MyDU = Id: Guid | Name: string
// actual
type MyRecord = { Id: Guid; Name: string }
type MyDU = Id of Guid | Name of string

// expressed as type aliases
// generics: * +
type MyTuple = id: Guid * name: string
type MyUnion = id: Guid + name: string
// actual
type MyTuple = Guid * string
type MyUnion = obj // match o with | :? Guid as id -> ...
// issues: boxing perf, compiler often complains, etc.

Observations

  • record uses a container-style syntax (outer curly braces) for its multiple values
  • ; is roughly equivalent to &. Similarly used for list values and code. Tuple values use , instead.
  • record / DU declaration and use are (almost) symmetric, tuple is not (this issue)

Note

(Y'all already know this, but for passers by...) DU of expression is not a tuple. Despite using the * syntax, it represents constructor parameters for the DU case. This is why of expression parameters can be labeled, even though tuples do not support this feature.

type Payment =
    | CreditCard of num: string * exp: string * code: string
    // and more

type MyTupAlias = num: string * exp: string * code: string
//                   ^
// compiler error 10: Unexpected symbol ':' in member definition

kspeakman avatar May 17 '22 18:05 kspeakman

I feel like deviating from ML languages doesn't really add clarity because you'd still have to explain what &/+ is but now people coming from other ML languages would now also need to look it up. I'm not at all a fan of the (x,y) form because it adds a lot of visual noise, and characters that are harder to see such as , vs * .

I understand this is in service of trying to make F# more beginner friendly, which is definitely a goal I agree with but I don't know if this actually works towards that end. If we must sub out * for , I would ask that at the very least we don't show it with parentheses around them unless required. Personally parentheses make it harder to read code and with screen readers it often becomes a sea of "open parentheses open parentheses open parentheses". There's a reason why blind people use python and it's partially because python has less character noise. In this regard F# is mostly "pretty good", and if I lean on inference and type abbreviations it's very good. Adding in a bunch of bonus characters would make using a screen reader more laborious for me. Although I'm fully sighted I do use screen readers sometimes to give my eyes a rest as I have a visual impairment in one eye that makes my eyes tired.

voronoipotato avatar May 31 '22 14:05 voronoipotato

@voronoipotato Thanks for this adding this perspective to my life! Makes me feel less weird about using extra let statements to avoid nested parens. (More understandable to me.) Also gives me a new way to think about Clojure.

I recall being tripped up by a multiple inconsistencies with tuples when I started F#.

  • asterisk in type defs, comma in usage (this issue) As an C# dev, I distinctly remember staring at an FSI prompt and not being able to mentally map the val def to what I typed.
  • labels do not work as expected DU cases have labels and look like tuples. Coming from C#, fn tuple args look just like method parameter defs, rather than destructuring. So I intuitively understood (incorrectly) that labels are an optional part of tuple type def. The first time I tried to use 'em outside of those structures, I spent hours trying to make it compile. Felt like I was taking crazy pills.

Ironically, DU cases are the main reason the asterisk syntax stuck with me. And they aren't really tuples. 🙃

I think this story would be helpful to learners -- making the defn and usage symmetric. In the end, this feature is not high on my wish list, since I rarely typo it. Unlike the (probably totally necessary for some reason) nuance of commas here, semi-colons there. 😁

Related to this issue, consider these valid destructurings:

open System

let f (a: string, b: DateTime) =
    ()
let f ((a, b): string * DateTime) =
    ()

let g (c: string, (a: string, b: DateTime)) =
    ()
let g ((c, (a, b)): (string * (string * DateTime))) =
    ()

kspeakman avatar Jun 01 '22 14:06 kspeakman

One reason commas here semicolons there is you don't have to put parentheses around tupled args in a list :). Also it means that commas always have one representation. I don't think it's easier to make the type representation and the usage the same, as it makes it unclear at a glance whether you're looking at a type or a usage.

I would be unhappy about deviating from all other ML languages, it would mean more ways of doing the same thing in F#. I also believe that deviating from the ecosystem is one of the reasons that ReasonML hasn't caught on. If the people in an ecosystem have to learn new ideograms, they're not going to use it. If they don't use it, who will convince others to try it? When new users are looking for help around ML style type definitions, commas are going to make it fully ungooglable.

    //this is also a valid destructuring
    let a , b = "kspeakman", System.DateTime.Now
    let a , (b , c) = "voronoipotato", ("kspeakman" , System.DateTime.Now)
    // or... 
    let a , b =  "voronoipotato", ("kspeakman" , System.DateTime.Now)
    let c , d = b
    // also...
    let l = [ 1 , 2; 3 , 4]
    //for the semicolon averse...
    let l =
        [
            1 , 2
            3 , 4
        ]
    // It's possible to just say no to parentheses

When dealing in DU, I've learned to stop worrying and love the type inference. When I need constraints, I add more DU. When I need annotations I add type aliases. When I want to know what type something is, I put my mouse over it.

voronoipotato avatar Jun 01 '22 14:06 voronoipotato

Oh I use unparen tuples constantly. Can't use em in fn arguments though.

I avoid semicolons whenever feasible by using newlines. Frequently get caught typing commas like nature intended 🤣 for single-line uses like record destructuring. Ok, maybe like bias from all my previous language experience intended. Where commas are the only answer to "what separator do I use?" And semicolons are the only possible way any compiler can know a statement ended. 🤣 ^ all in good fun.

Conforming to other ML languages is not a goal for me personally. I had never heard of ML when I tried F# the first time. And the only other ML language I've since used is Elm, (which mostly uses commas IIRC, if that that's what we are talking about). F# was "nearby" for me because it was in the .NET ecosystem, not because it's ML. My story is not everyone's. I don't want to minimize ML cross-over as an important consideration, just not one I'm familiar with.

kspeakman avatar Jun 01 '22 18:06 kspeakman

you can use ||> for lambdas :) .

voronoipotato avatar Jun 02 '22 14:06 voronoipotato

you can use ||> for lambdas :) .

I'm trying to imagine that usage.

Further reflection, I see advantages to the ;. (Not that it had any chance of going away at this point, more of a ribbing comment.) Just unfortunate that I need to tear down years of muscle memory. Reckon I had to do that to learn FP anyway.

kspeakman avatar Jun 02 '22 22:06 kspeakman

@dsyme please consider allow regular syntax for abstract members to implement interface. I'm doing OOP dependency injection on F# lots and have to declare interfaces. In our solution C# projects are used with F# projects. By the way this is the way to make C# users closer to F#. So would be nice to be able copy-paste inferface lines to implementation and just replace "abstract" by "member". Here is the real code:

` type IApprovalTaskUpdateService = abstract ApprovalTaskRunning: connectionParameters: TaskAddress * operationContext: OperationContext -> Async abstract ApprovalTaskSucceeded: connectionParameters: TaskAddress * message: string * title: string * operationContext: OperationContext -> Async abstract ApprovalTaskFailure: connectionParameters: TaskAddress * message: string * title: string * operationContext: OperationContext -> Async abstract ApprovalTaskMessage: connectionParameters: TaskAddress * message: string * title: string * operationContext: OperationContext -> Async

type ApprovalTaskUpdateService(vssConnectionFactory: IConnectionFactory) = let taskClient (p: TaskAddress) = let clientFactory = vssConnectionFactory.ClientFactory p.ConnectionParameters let taskParams: TaskServices.TaskParams = { PlanId = p.Task.System_PlanId JobId = p.Task.System_JobId TaskInstanceId = Some p.Task.System_TaskInstanceId TimelineId = p.Task.System_TimelineId }

    clientFactory.GetTaskClient
        {
            ProjectId = p.Project.System_TeamProjectId
            Hub = 
                match p.ReleaseEnvironmentVariables with
                | ClassicVariables classic -> TaskServices.TaskHub.ReleaseHub
                | YamlVariables yaml -> TaskServices.TaskHub.BuildHub
            Task = taskParams
        }
interface IApprovalTaskUpdateService  with
member _.ApprovalTaskRunning (connectionParameters: TaskAddress, operationContext: OperationContext) = async {
    let! taskClient = taskClient connectionParameters
    return! taskClient.NotifyMessageReceived(CancellationToken.None, operationContext) |> Async.AwaitTask
}
member _.ApprovalTaskSucceeded(connectionParameters: TaskAddress, message: string, title: string, operationContext: OperationContext) = async {
    let! taskClient = taskClient connectionParameters
    do! taskClient.NotifyCompletion(message, title, TeamFoundation.DistributedTask.WebApi.TaskResult.Succeeded, operationContext) |> Async.AwaitTask
}
member _.ApprovalTaskFailure(connectionParameters: TaskAddress, message: string, title: string, operationContext: OperationContext) = async {
    let! taskClient = taskClient connectionParameters
    do! taskClient.NotifyCompletion(message, title, TeamFoundation.DistributedTask.WebApi.TaskResult.Failed, operationContext) |> Async.AwaitTask
}
member _.ApprovalTaskMessage(connectionParameters: TaskAddress, message: string, title: string, operationContext: OperationContext) = async {
    let! taskClient = taskClient connectionParameters
    do! taskClient.SendMessageToVSTS(message, title, TeamFoundation.DistributedTask.WebApi.TaskResult.Succeeded, operationContext) |> Async.AwaitTask
}

`

oleksandr-bilyk avatar Jun 18 '22 18:06 oleksandr-bilyk

If you're regularly copying code over I feel like you might benefit from code generation. I'm incredibly weary of making the syntax the same, as it could easily create a false sense of similarity for things that are not similar. If we change it just for this, then we have two ways of doing things everywhere, and it's confusing whether you're looking at an actual tuple or just the type definition of the tuple.

voronoipotato avatar Jul 13 '22 14:07 voronoipotato