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

Consider incorporating FSharp.Core.Fluent into FSharp.Core #1073

Open
4 tasks done
dsyme opened this issue Sep 9, 2021 · 42 comments
Open
4 tasks done

Consider incorporating FSharp.Core.Fluent into FSharp.Core #1073

dsyme opened this issue Sep 9, 2021 · 42 comments

Comments

@dsyme
Copy link
Collaborator

dsyme commented Sep 9, 2021

I'd like to open a discussion about incorporating FSharp.Core.Fluent into FSharp.Core. Or we could consider adding it as a second F# DLL referenced by default, with the option of turning that off.

https://fsprojects.github.io/FSharp.Core.Fluent/

image

Pros and Cons

The advantages of making this adjustment to F# are

  • reduced barrier to entry for beginners - xs.sum() relies only on dot notation, c.f. xs |> Seq.sum requires knowlesdge of |> and Seq, and knoweledge that input collections support Seq/IEnumerable programming
  • more discoverable code for simple things like summing a list
  • it's often more succinct

The disadvantages of making this adjustment to F# are

  • fluent collection programming has some downsides for clarity, performance
  • loss of pipeline debugging
  • multiple ways to do things
  • complicates library authorship
  • introduces camelCase naming to methods and dot notation
  • more type annotations are required

Extra information

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

Related suggestions: (put links to related suggestions here)

Affidavit (please submit!)

Please tick this by placing a cross in the box:

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.

@cartermp
Copy link
Member

cartermp commented Sep 9, 2021

I'm generally in favor of doing this, especially if debugging improvements come in with this motivating it. I would prefer that if this is done, it's just a part of FSharp.Core. Not a separate assembly.

This is clearly in violation of this principle:

whether this gives multiple ways to achieve the same thing

But, ehh, we've violated that one before. Yay for guidelines and not rules, eh?

I think it's worth going through who the intended audience is and why they would benefit from this.

@cannorin
Copy link

cannorin commented Sep 9, 2021

I generally love the idea (adding popular module functions as extension methods) but not a fan of camelCase method names, especially in the standard library. It may encourage people to write their methods in camelCase, which I think is not very good.

@sudqijawabreh
Copy link

I think this well help with autocomplete since you write dot and then you can discover what operations you can apply.

@rosalogia
Copy link

I'm generally in favor of doing this, especially if debugging improvements come in with this motivating it. I would prefer that if this is done, it's just a part of FSharp.Core. Not a separate assembly.

I'm curious what kind of debugging improvements you foresee being motivated by this change? If you're right about it motivating debugging improvements, I suddenly have a strong reason to agree with this suggestion.

As it stands, the reasoning seems somewhat weak to me. I worry about the similarity/simplicity of the syntax taking attention away from the loss of pipeline debugging and performance that might result in overuse of this syntax. It would be a bit of a shame if newcomers to the language/paradigm make a habit of using a syntax that necessarily carries with it these cons when the alternative pipelining syntax is not so much more complicated.

Have others generally found that beginners struggle with adapting to pipelining? Or that developers in general struggle with discovery of module functions because of pipelining? Perhaps my perspective is skewed 😋

@7sharp9
Copy link
Member

7sharp9 commented Sep 9, 2021

Another con of fluent is more annotations are normally required, which sort of spoils the flow having to go back and add an annotation.

@vzarytovskii
Copy link

vzarytovskii commented Sep 9, 2021

It would be nice but as @cartermp said:

This is clearly in violation of this principle:

whether this gives multiple ways to achieve the same thing

Which may be confusing for users.

Also, question about official (and existing) docs and guides, will they favour one over another (fluent vs classic)? Shall we have an official "suggestion" what to use specifically?

@cartermp
Copy link
Member

cartermp commented Sep 9, 2021

@rosalogia

I'm curious what kind of debugging improvements you foresee being motivated by this change?

Since we now have pipeline debugging/stepping, it's a clear advantage to use them when diagnosing code. So imagine code like this:

m1(asdf).m2(fun x -> (* some code *)).m3(asdf2)

Where you can step into each piece of the call chain and subsequent statements/expressions within them. @dsyme sounds motivated to make that possible/better compared to today. It's an orthogonal improvement, but with this suggestion, a lot more impactful.

@rosalogia
Copy link

@cartermp makes sense; thanks for elaborating!

@pbiggar
Copy link

pbiggar commented Sep 9, 2021

I don't like this at all! :)

multiple ways to do things

One of the things I've learned about programming languages (and software in general) is that when you have multiple ways of doing things, to only do that if the new ways can be done linearly.

The Fluent namespace is a linear change - people can choose to import it, and nobody expects every library and API to support it.

Moving it into FSharp.Core elevates it, saying "Fluent is a 2nd way of doing this thing". Tutorials and docs now need to cover it. People adding new top-level functions now need to make Fluent methods for them or else they're not as "first class" as the builtins (that is, they'll need to break up their tidy Fluent chains with an "ugly" function application or pipe). People now ask why there aren't Fluent versions of APIs, and packages get PRs adding Fluent versions of every function.

I personally like functions and using piping to compose them. But, if you/the community thinks Fluent is better, I would suggest changing the language to have Fluent be the one true way of doing things. One way is better than two ways.

If switching to fluent doesn't sound good, I would suggest instead improving functions so that they don't have the disadvantages you perceive. For example, for

more discoverable code for simple things like summing a list

VSCode (etc) could be made suggest a function with the appropriate type when you press '.'; after the completion, it would replace the . with a |>

conceptually simpler options for beginner code, xs.sum() relies only on dot notation, c.f. xs |> Seq.sum requires knowlesdge of |> and Seq

I do not believe that it is easier for beginners to learn two things than one thing (or to learn one thing only to learn that no-one does that - of course if people start to do that, now you have two things). The question of when to use functions vs methods is already challenging and not well addressed, this change would make it worse, imo.

TL;DR: pick a lane 😊

@cartermp
Copy link
Member

cartermp commented Sep 9, 2021

VSCode (etc) could be made suggest a function with the appropriate type when you press '.'; after the completion, it would replace the . with a |>

I actually have the beginnings of something along those lines (intellisense for pipelines) here for any F# editor, but it's super early stage and will involve some tricky stuff that's currently beyond me to do in my spare time. Any help appreciated 😄

This is good feedback though, and I'd love to have more written out regarding this to offer more

I think it's worth going through who the intended audience is and why they would benefit from this.

I more immediately think of python developers working with collections for the first time. Maybe they would appreciate FSharp.Core.Fluent as a style, or maybe they would be satisfied with better tooling with pipelines.

@stweb
Copy link

stweb commented Sep 9, 2021

I wonder if there's a way to support better autocomplete FSAC tooling for the F# syntax instead - I would prefer that over added language complexity. This could be triggered by typing |> -- maybe also by newline.

@pbiggar
Copy link

pbiggar commented Sep 9, 2021

more immediately think of python developers working with collections for the first time. Maybe they would appreciate FSharp.Core.Fluent as a style,

Another way to look at this is that when people see that .sum works, they would expect that the autocomplete options they are offered are the complete set. This would probably lead them to conclude that the standard library is very sparse.

@ursenzler
Copy link

I can see why this is tempting.
However, I'm not too fond of this suggestion. As mentioned above, expectations would quickly come that everything that can be done via a pipeline call is available via Fluent-style. Otherwise, there will be style breaks in the code: a.foo.bar |> zoo (if zoo is not available in Fluent-style). That would make code much harder to read - in addition to having two styles for the same.
The result is added needed effort for library authors (coding, documenting) and is hard to keep consistent.
If both would be available, I'd probably almost always use the pipeline variant because I think fewer type annotations would be needed.

@BentTranberg
Copy link

Alright, this adds multiple ways of doing things, but as a library that already exists, and not as a language syntax change. This makes the "multiple" argument mote in my view. Everybody can simply use the library right now. Isn't this what we're doing all the time anyway, taking advantage of F#'s power as an algebraic language to express things in less space and more elegantly?

In fact I see all the Cons as the other side of Pros. Some downsides doesn't mean it's bad. Loss of pipeline debugging, yes, but the alternative is there, and debugging can be improved. Library authorship must necessarily be affected. Why not introduce camelCase naming to methods and dot notation?

@dggmez
Copy link

dggmez commented Sep 9, 2021

conceptually simpler options for beginner code, xs.sum() relies only on dot notation, c.f. xs |> Seq.sum requires knowlesdge of |> and Seq

As a beginner that just recently finished F# From the Ground Up with no previous functional programming experience I don't think this is an issue. Having knowledge about what |> is or that there is a module with the same name of the type is just like knowing that you use let to bind to a value or a function. It's part of the basics of game. I remember that I had issues with things like partial application or getting used to this level of type inference (I still don't know why the error "lookup on error of indeterminate type" shows up even though Ionide infers the right type but thanks to Eason I learnt I can fix it with a type annotation). Furthermore I think that assuming Fluent was already part of Core and Eason would have showed me both approaches (because if Fluent is in Core then as Biggar said newbies will expect to learn about it in any learning material they get) then my reaction would have been "why are there two ways to do the same thing? Is there a difference?" and start googling to learn what the differences are and when I should NOT use X and stick to Y.

@dsyme
Copy link
Collaborator Author

dsyme commented Sep 9, 2021

@dggmez

or that there is a module with the same name of the type

The problem is, this bit is not true.

  • Assume you're starting with Dictionary or ResizeArray or System.Collections.Generic.List
  • Now there is no Dictionary or ResizeArray or System.Collections.Generic.List module
  • So what do you do? You have to understand that these things all implement this thing called Seq (which is also called IEnumerable and IEnumerable<T>
  • And then you have to loosely understand the idea of subtyping (which is a complex and slippery concept that Python and initial programmers will be very hazy on)
  • And you have to understand that this Seq thing is good for everything, except you don't use it for List and Array somehow.
  • If you also want to dig into |> you may need to know about currying and partial application and first-class function types.

That is all fine - much of this you need to learn sooner or later - but it really is vastly more depth of understanding than

  • typing .
  • selecting sum()

and you're done.

I generally love the idea but not a fan of camelCase method names, especially in the standard library.

Regarding camelCase v. PascalCase - we would not do this for PascalCase Map, Filter etc. So it would be normalizing the idea that F#-specific APIs can use foo.camelCase(). Guidance would be needed.

As an aside, DiffSharp supports both dsharp.sum(tensor) and tensor.sum() systematically for all, I've found it very pleasant the worst thing being it requires duplication of documentation. Also Python API design actually has this problem too, with PyTorch supporting both torch.sum(tensor) and tensor.sum(), likewise all other operations.

@chkn
Copy link

chkn commented Sep 9, 2021

There is an alternative that might address some of the issues raised above. Instead of adding new boilerplate methods that call the module functions, what if we add a way to allow the existing functions to be used as extension methods?

For instance, when designing libraries for use in both F# and C#, I'll often do something like this:

[<Extension>]
module List =

    [<Extension>] // This is fine: lst1 is "this"
    let append lst1 lst2 = lst1 @ lst2

    // [<Extension>] // Doesn't work: we want `lst` to be "this", not `fn`
    let map fn lst = (* ... *)

The first case works because the first argument is the one we want to be used as "this." If we extend the extension method syntax to allow any argument to be used as "this," then we could do something like:

// Pretend syntax- we could use a different attribute
let map fn ([<Extension>] lst) = (* ... *)

// Now you can do ...

let foo = [1; 2; 3].map (fun x -> x * 2)

@dggmez
Copy link

dggmez commented Sep 9, 2021

@dsyme

Fair enough. It is certainly true that I'm an F# beginner but not a C#/.NET beginner so all that stuff was and is pretty natural to me except for the last point about |>, currying, partial application, etc. For other beginners it's a different ball game.

@pbiggar
Copy link

pbiggar commented Sep 9, 2021

or that there is a module with the same name of the type

The problem is, this bit is not true.

  • Assume you're starting with Dictionary or ResizeArray or System.Collections.Generic.List
  • Now there is no Dictionary or ResizeArray or System.Collections.Generic.List module
  • So what do you do? You have to understand that these things all implement this thing called Seq (which is also called IEnumerable and IEnumerable<T>
  • And then you have to loosely understand the idea of subtyping (which is a complex and slippery concept that Python and initial programmers will be very hazy on)
  • And you have to understand that this Seq thing is good for everything, except you don't use it for List and Array somehow.
  • If you also want to dig into |> you may need to know about currying and partial application and first-class function types.

That is all fine - much of this you need to learn sooner or later - but it really is vastly more depth of understanding than

  • typing .
  • selecting sum()

and you're done.

I agree that this is a mess, but I submit that adding yet another thing, that also works in some cases but not others, adds to the mess instead of cleaning it up. In fact, it would be a big improvement to the stdlib if the rule that "there is a module with the same name of the type" were true in all cases instead of merely relatively often.

@wallymathieu
Copy link

I'm kind of ambivalent. From the perspective of a C# developer switching to F# it could make sense to import System.Linq and write

xs.Select(fun x -> x+1)
    .Where(fun x -> x > 4)
    .OrderBy(fun x -> x)

The option to write this style of code already exists using standard libraries. The fluent style API looks like you remove the |> List. and replace it with . instead.

As a consumer of the fluent API, the assumption would be that it's complete: That given all of the F# standard library methods that you could want to use in a fluent style could be used in a fluent style. Perhaps some sort of generated fluent API surface would make sense (in a specific namespace)?

@nikoloz-pachuashvili
Copy link

nikoloz-pachuashvili commented Sep 9, 2021

With |> you can pipe any function with relevant input, while with fluent style you can not.

@dsyme
Copy link
Collaborator Author

dsyme commented Sep 9, 2021

@pbiggar

it would be a big improvement to the stdlib if the rule that "there is a module with the same name of the type" were true in all cases instead of merely relatively often.

Unfortunately this isn't possible for all of, say, ImmutableCollections.

@JordanMarr
Copy link

JordanMarr commented Sep 10, 2021

I strongly dislike the idea of adding, not a second, but a third way of doing core list manipulation to F#.

New users can already open System.Linq if they want fluent. And in fact my coworker, who is currently writing his first F# project for a large client, instinctively reached for Linq. I think it’s great that he was able to leverage his existing knowledge to get things done in a pragmatic way, but I urged him to refactor to use the F# module functions and pipeline for a more idiomatic approach (and more importantly to embrace F# usage of Option types).

What bothers me the most when teaching F# is having to give nuanced history lessons of why there are multiple ways of doing the same thing. For example: async vs tasks; curried vs tupled args.

Another reason I dislike this idea is that it is so easy and elegant to create pipelines, whereas it is tedious and difficult to create fluent APIs, to the point where you seldom see fluent APIs in a code base unless it’s a public facing API library where someone wants to go out of their way to do a lot of work up front to make it easy for consumers. (there has to be a real incentive to create fluent APIs to make it worth the huge inconvenience.)

My point here is that it’s trivially easy for F# devs to create elegant pipelines of their own that look like first class citizens alongside the core module functions, whereas embracing fluent API will drive a wedge down the most common and idiomatic workflows of F# core. The thought of having to constantly refactor fluent list manipulations back to pipelines really triggers me.

Pipelines are such a celebrated feature amongst F# developers, to the point where they use the forward pipe operator on t-shirts, hats and coffee mugs! This is fixing something that is not broken, and even if it adds some level of convenience, that pragmatism will come with a cost to the perceived beauty of the language.

@Matteo-T
Copy link

For me, the initial/instinctive reaction was "No, do not mess with my beloved |>" (I do not have a T-shirt or a mug with a |> on it, but to me |> is just pure beauty)

Then I tried to rationalize my vote. Not easy. I read all the comments (more than once). Still not easy to articulate a convincing explanation of my reaction. I guess sometimes you gotta do/say what your instinct suggest. :)

Btw, I loved the idea of intellisense/autocmpletion/suggestions after |> suggested by @cartermp.

@purkhusid
Copy link

TBH I would prefer that type discoverability in all IDEs would be improved and then this suggestion is not really that valuable.

@akhansari
Copy link

akhansari commented Sep 10, 2021

F# Fluent API as a library seems legit.
But as part of the Core, I strongly dislike the idea too.

When we compare pros and cons, pros are focused on learning purpose and for beginners.
And I'm not even sure for which kind of beginners. Those who are familiar with fluent api (not a lot) or any kind of beginners that in this case having an explicit api and one way of doing things, in my opinion is preferable.
And I dont know why the discovery would be an issue if every thing is explicit.
Also even if enumerators are a very important notion of dotnet, they are rarely used in F# and usually for consuming C# apis.

On the other hand, cons are related to the production code...
It's interesting to have a more succinct code for some rare one-liners but at which cost?

@jbeeko
Copy link

jbeeko commented Sep 11, 2021

This should not be done. If a language does not believe in itself and its view point on how to achieve a goal then what is the point?

More practically,

  • the con of loosing pipeline debugging seems significant.
  • The point about how examples should be code is significant.

@piaste
Copy link

piaste commented Sep 13, 2021

Very much not a fan. The simplicity and uniformity of F# has often been remarked upon as one of its strongest points, by outside observers as well.

This would compromise that simplicity in order to compensate for a weakness in the tooling - namely, that |> doesn't yet offer Intellisense suggestions. Using fluent style is already a thing for C# compatibility, but it's a subpar experience (type inference, the verbose fun x -> instead of x =>) and raising it to a core library makes the language look worse.

It's totally possible to design a functional language around the fluent style - look at Kotlin, which has the it keyword and several scope functions to improve the readability of fluent chains. But F# has none of those (I believe a more ambitious "make fluent style first-class in F#" suggestion, which included such improvements, would be controversial but it would look a lot better).

On the other side, F# has partial application, free-standing custom operators, and (at the tooling level) pipeline debugging and code lenses to support the pipeline style of coding; Kotlin lacks those, and so I would similarly oppose the introduction of |> in Kotlin.

I also agree with the criticism that third-party libraries would suddenly be expected to provide two aliases for every function, one in each style, and it would be extremely confusing for a new programmer to find that it's neither automatic nor a given.

@wallymathieu
Copy link

Perhaps a lib generator that does the code gen for certain rules? So that you given a F# style module API get extensions?

@Happypig375
Copy link
Contributor

Let's just add automatic IntelliSense for Seq module functions when we type |> after a seq type.

@cartermp
Copy link
Member

@Happypig375 much easier said than done :)

@Happypig375
Copy link
Contributor

@cartermp Still this would be more viable than adding fluent methods :)

@cartermp
Copy link
Member

What do you mean by viable? Incorporating FSharp.Core.Fluent is very easy to do. Proper, discoverable, portable IntelliSense for pipelines is very hard to do.

@JordanMarr
Copy link

Adding IntelliSense for pipelines seems like it would be nearly impossible since anything with the right shape could be piped in.
But I think part of the beauty of pipelining is that it's not constrained to only a handful of Linq methods.
Besides, learning to use the Seq, List and Array modules is likely in every F# day-one guide anyway, and it's also very intuitive IMO.

@pbiggar
Copy link

pbiggar commented Sep 13, 2021

Adding IntelliSense for pipelines seems like it would be nearly impossible since anything with the right shape could be piped in.

I'm not sure I follow. If you know the type of the expression passed into the pipe, you would look for a function or var which takes that argument in it's final position.

We do this in Darklang, it required custom code for pipes but wasnt a challenging implementation.

@JordanMarr
Copy link

Sure, but what scope would you expect it to search? Through your project maybe, but it could literally be anything in the entire framework, so it would have to index everything by the last position argument type. Seems kind of unlikely to me (and unnecessary).

@dsyme
Copy link
Collaborator Author

dsyme commented Sep 13, 2021

Sure, but what scope would you expect it to search? Through your project maybe, but it could literally be anything in the entire framework, so it would have to index everything by the last position argument type. Seems kind of unlikely to me (and unnecessary).

I think it is reasonable to search only one level, that is offer Thing.thing whith the required type match

A challenge might be that all Seq and List offerings would be valid.

@cartermp
Copy link
Member

Adding IntelliSense for pipelines seems like it would be nearly impossible since anything with the right shape could be piped in.

Not impossible, but hard. There are several challenges, in increasing order of difficulty:

  • Adjusting how we determine a "completion context" for each kind of pipelining you can do (|>, ||>, |||>, >>)
  • Understanding which is the "last slot" in a given applicable item (function, ctor, method, etc.)
  • Getting the right kind of information about the thing being piped into something else (and constituent parts of a tuple to splat into functions)

Part of the reason why it's hard is that at the point where completion lists are generated, we aren't working with information that makes it easy to pull apart that information. But assuming that gets done, the next step is a relatively lengthy process of carefully applying the right filtering based on the information above and if the last slot(s) in the candidate list can "match", for some definition of "match" that will include:

  • Exact type matches
  • Generics
  • Subtyping rules

This would likely to go through a few release cycles and usage in the "real world" before it gets to a steady state. I have a very basic prototype that doesn't do this filtering and it's kinda useful, but since it just gives back the full list you still pretty much need to know what you want to do next by typing it out for the list to filter. Each kind of filtering slowly gets you to an ideal kind of list and it would take a few cycles to get that behavior to feel right.

There's some quirks to that, like if you have a generic value and you pipe. No reasonable filtering can be applied for the first one, but the function you pipe into could then influence filtering for the next one. I don't know if people would feel like that's weird or not.

So really it's a complicated thing that would take time to get "right". I think it should happen, which is why I have a branch that sets up basic stuff working and a framework for adding things...but if anyone thinks that this is anywhere close to approaching a similar implementation effort as the suggestion here, they'd be very much mistaken.

@JordanMarr
Copy link

Sure, but what scope would you expect it to search? Through your project maybe, but it could literally be anything in the entire framework, so it would have to index everything by the last position argument type. Seems kind of unlikely to me (and unnecessary).

I think it is reasonable to search only one level, that is offer Thing.thing whith the required type match

A challenge might be that all Seq and List offerings would be valid.

Maybe you could have a short list of the most commonly piped core modules (like List, Array, Seq) that could be displayed at the top of completion list if they matched. Just showing the relevant modules (either List and Seq or 'Array and 'Seq) would be a nice jumping off point to show in the completion list instead of showing the many individual functions.

Below that, you could show individual functions that match within a reasonable scope (one level up, or maybe only within the currently opened modules).

@JordanMarr
Copy link

Not impossible, but hard. There are several challenges, in increasing order of difficulty:

That all sounds like quite a challenge, and then there would also be performance considerations. Would my poor laptop sound like it was about to launch into space the first time I opened the completion list? 🚀

@cartermp
Copy link
Member

No. All the data used to gather a list is already brought into scope in F# editor tooling anyways. You can see what the "full" list is just by doing ctrl+space on a blank line in F# code -- that's every single item available to you at that point in your program unfiltered

@dsyme
Copy link
Collaborator Author

dsyme commented Sep 13, 2021

@cartermp

for some definition of "match" that will include:

We already do this filtering for extension methods, see IsApplicableMethApprox https://github.com/dotnet/fsharp/blob/c88b79509989ba524c41958d6d96a45951344550/src/fsharp/service/FSharpCheckerResults.fs#L469

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