FSharpPlus icon indicating copy to clipboard operation
FSharpPlus copied to clipboard

[API Proposal]: Extend the reduce and reduceBack to Collections

Open fxdmhtt opened this issue 7 months ago • 41 comments

Background and motivation

From the requirements point of view: the fold function must provide init, reduce does not require init, which means no longer needs to write boilerplate "match with" code like "extract the first element from generic Collections as init, the remaining elements as sequence, and then pass it to fold".

From a reasonable point of view: list/array/seq all provide native reduce methods, list/array also provides native reduceBack methods. This generic encapsulation is not difficult to implement, and other languages ​​(except fundamental FPs like Haskell) generally provide reduce methods, which is reasonable.

I know there is a reduce function implementation now, but that one is very difficult to use. To be honest, I have no idea how to pass parameters.

API Proposal

let inline reduce (folder: 'State->'T->'State) (foldable: '``Foldable<'T>``) : 'State

let inline reduceBack (folder: 'T->'State->'State) (foldable: '``Foldable<'T>``) : 'State

API Usage

reduce (*) [1; 2; 3; 4; 5] |> should equal 120
reduce (+) ([]: int list) // throws an exception

Alternative Designs

No response

Risks

No response

Are you willing to help with a proof-of-concept (as PR in that or a separate repo) first and as pull-request later on?

Yes, please assign this issue to me.

fxdmhtt avatar Jun 07 '25 02:06 fxdmhtt

The existing generic reduce function is a safe function, therefore it operates on Semigroups, more specifically collections which have at least one element.

The reduce functions provided in FSharp.Core are unsafe and although we're not free of unsafe functions here, we love to have some discussions before implementing them.

I think a good compromise could be to implement a tryReduce function. What do you think?

gusty avatar Jun 08 '25 15:06 gusty

I think the idea of ​​implementing tryReduce is questionable.

  1. This is a self-made name that does not exist anywhere else (as far as I know), which may cause ambiguity, misleading, or extra learning costs.
  2. The reduce function provided in FSharp.Core is unsafe, which is not a reason to implement a safe reduce function. Functions with the same name should behave the same way, after all, we are just doing paradigm encapsulation.
  3. We can also implement a safe reduce function, which is obtained by simplifying the boilerplate code. Specifically, even if the source parameter is actually an empty container (not null), it does not affect our return of Unchecked.defaultof<'State>. Of course, this may introduce other ambiguities,

So I think we should implement the tryReduce function and return the 'State option or something similar. Then keep reduce as an unsafe implementation that encapsulates FSharp.Core.

I would like to hear your opinions again.

fxdmhtt avatar Jun 08 '25 16:06 fxdmhtt

If we decide to make reduce "unsafe", based on the fact that in FSharp.Core a function exists already with that name and it's unsafe, we should find another name for the existing safe version of reduce.

Then we have to consider backwards compatibilities, if it's not possible to guarantee, we'll have to hold to v2 for releasing it, however even in this case the PR won't be blocked.

gusty avatar Jun 08 '25 18:06 gusty

Can we ignore "safety" for now and add the reduce paradigm function of seq/array/list in the current version?

The current reduce function cannot work on these three types, but these three types are the most commonly used types. So there is no conflict, as long as we are willing to ignore the "safety" issue.

fxdmhtt avatar Jun 10 '25 10:06 fxdmhtt

We could, but not without adding (or renaming) the existing safe one. Otherwise we'll be kind of losing functionality. Any suggestion for the other function name ?

gusty avatar Jun 12 '25 04:06 gusty

I believe in your wisdom

fxdmhtt avatar Jun 12 '25 04:06 fxdmhtt

I'm not very creative this morning so at the moment I can just propose the name borrowed by Haskell's fold1.

However in Haskell that's a non-safe function, but we don't need to match them.

gusty avatar Jun 12 '25 05:06 gusty

For the renaming, why not just borrow from Haskell, that is foldl for safe reduce and foldr for safe reduceBack.

BurningLutz avatar Jun 12 '25 06:06 BurningLutz

For the renaming, why not just borrow from Haskell, that is foldl for safe reduce and foldr for safe reduceBack.

This will lead to breaking updates and affect compatibility.

Personally, I think the root cause is that the current version of the API is poorly designed.

fxdmhtt avatar Jun 12 '25 06:06 fxdmhtt

I also think that naming should first consider the consistent meaning in the F# context, and then borrow the conventions of Haskell.

fxdmhtt avatar Jun 12 '25 06:06 fxdmhtt

For the renaming, why not just borrow from Haskell, that is foldl for safe reduce and foldr for safe reduceBack.

@BurningLutz those names are following a Haskell convention which doesn't match the one in F#. In F# the convention is, for the left, the function without any suffix and for the right, the function name with Back suffix.

This will lead to breaking updates and affect compatibility.

@fxdmhtt Hmmm, I don't see how it will affect compatibility, but ....

I also think that naming should first consider the consistent meaning in the F# context, and then borrow the conventions of Haskell.

... this is the main point here, we want to stick to F# naming conventions. I just mentioned Haskell the same way I could have mentions Scala or other language.

Personally, I think the root cause is that the current version of the API is poorly designed.

I agree that the API has room for improvement—no design is ever perfect. That said, I do think it’s worth mentioning that, relative to other F# libraries around, this one tries to follow a fairly disciplined approach to API design. Decisions about which functions to expose are made carefully, aiming for consistency and usability across a wide range of types—even if some of those use cases remain more theoretical than practical for now.

That said, if you feel there are fundamental issues with the current design, I’d genuinely appreciate it if you could point them out. I’m always open to improving things where it truly matters and we're collecting input for V2.

gusty avatar Jun 12 '25 08:06 gusty

Hahahaha, I don't have the ability to find any "fundamental issues", I fully believe in your ability.

I just think that some official APIs need to be converted to proprietary data structures before they can be used in FSharpPlus, which is not convenient and will lead to a low usage rate of these APIs.

I would like to express my sincere gratitude for your hard work.

fxdmhtt avatar Jun 12 '25 08:06 fxdmhtt

Thanks, I really appreciate your feedback and kind words.

some official APIs need to be converted to proprietary data structures before they can be used in FSharpPlus,

I'm not entirely sure what you mean here, but I’m guessing you're referring to the wrapping/unwrapping that's sometimes needed—especially to enable different behavior from generic operators.

If that's the case, I'd say a large portion of those situations were addressed with the relatively recent addition of non-sequential applicatives. With that in place, types like ZipList or Validation are much less necessary than they used to be.

That said, we’ll probably keep supporting them since the library is meant to offer multiple ways to approach a problem—so users can pick the one that best fits their style or use case.

gusty avatar Jun 12 '25 08:06 gusty

@BurningLutz those names are following a Haskell convention which doesn't match the one in F#. In F# the convention is, for the left, the function without any suffix and for the right, the function name with Back suffix.

@gusty I see, following the language's convention is important.

But I think I was confused by the conversation between you two.

You said:

The existing generic reduce function is a safe function, therefore it operates on Semigroups, more specifically collections which have at least one element.

Which reduce did you refer to? Is this one? But this one is a partial function, it is undefined when the reducible is empty, and a partial function is not considered to be safe.

Further, there really is a safe counterpart of reduce, the fold function. It is defined here.

So, both the unsafe reduce and the safe fold are defined in F#+.

Now I have no idea what's the problem here. Is it the problem that currently there is no Reduce static members defined for builtin collections like list/array/seq inside Foldable?

BurningLutz avatar Jun 13 '25 03:06 BurningLutz

Now I have no idea what's the problem here. Is it the problem that currently there is no Reduce static members defined for builtin collections like list/array/seq inside Foldable?

Absolutely right

fxdmhtt avatar Jun 13 '25 03:06 fxdmhtt

Which reduce did you refer to? Is this one? But this one is a partial function, it is undefined when the reducible is empty, and a partial function is not considered to be safe.

@BurningLutz that reduce function is not partial as it will not operate on potentially empty collections, I mean it won't compile, which makes it a total function. Currently there's no way it will throw an exception at runtime.

The proposal is to make it available to potentially empty collections, therefore make it a partial function. The rationale is again implicit F# conventions: the same function exists in F# core and it's partial.

If we do that, we'll need to keep the total function under a different name, hence the question, which name should we use.

gusty avatar Jun 13 '25 05:06 gusty

I mean it won't compile, which makes it a total function. Currently there's no way it will throw an exception at runtime.

Ok, I think I got it.

You mean the Reduce merely defined a generic reduce function which accepts only two arguments: the binary operator and the reducible. And since there is no Reduce members defined for those collections that may be empty, it is a total function.

I agree with this. In fact I see there are Reduce members for those NonEmpty collections, and they are well-defined.

But I think there do exist some inconsistencies, for example the generic find function. This find is a partial one, which perfectly reflect the original find functions (of array/list/seq etc...) defined in F# core.

But when it comes to reduce, the semantics has changed. It "suddenly" becomes a total one, which is not consistent with the original reduce functions defined in F# core.

I think this is the root that confused OP.

BurningLutz avatar Jun 13 '25 06:06 BurningLutz

Yes, that's the whole point of this discussion. Any suggestion for the name of the new total function?

gusty avatar Jun 13 '25 06:06 gusty

I mean it won't compile, which makes it a total function. Currently there's no way it will throw an exception at runtime.

Ok, I think I got it.

You mean the Reduce merely defined a generic reduce function which accepts only two arguments: the binary operator and the reducible. And since there is no Reduce members defined for those collections that may be empty, it is a total function.

I agree with this. In fact I see there are Reduce members for those NonEmpty collections, and they are well-defined.

But I think there do exist some inconsistencies, for example the generic find function. This find is a partial one, which perfectly reflect the original find functions (of array/list/seq etc...) defined in F# core.

But when it comes to reduce, the semantics has changed. It "suddenly" becomes a total one, which is not consistent with the original reduce functions defined in F# core.

I think this is the root that confused OP.

Yes, but the problem is that now that this is the case, any "error correction" done on the current reduce is a breaking change.

fxdmhtt avatar Jun 13 '25 06:06 fxdmhtt

Yes, that's the whole point of this discussion. Any suggestion for the name of the new total function?

My personal suggestion is to make a breaking change.

Even so, it does not affect the existing safe reduce function.

fxdmhtt avatar Jun 13 '25 06:06 fxdmhtt

Yes, but the problem is that now that this is the case, any "error correction" done on the current reduce is a breaking change.

How is it a breaking change? Non-empty collections will still compile with reduce, it's just that if you further change your code, now you will have the possibility to have runtime errors, but that's not a breaking change, since you changed your code.

gusty avatar Jun 13 '25 06:06 gusty

If we decide to make reduce "unsafe", based on the fact that in FSharp.Core a function exists already with that name and it's unsafe, we should find another name for the existing safe version of reduce.

Then we have to consider backwards compatibilities, if it's not possible to guarantee, we'll have to hold to v2 for releasing it, however even in this case the PR won't be blocked.

I don't think so, but you thought so before.

So can we reach a consensus?

Simply adding a non-safe, generic reduce wrapper for seq/list/array that is completely consistent with the behavior of FSharp.Core is the conclusion of this issue.

fxdmhtt avatar Jun 13 '25 06:06 fxdmhtt

How is it a breaking change?

In Haskell, introducing a new instance (or a static member in F#) is considered a potential breaking change. Because users could have already defined their own instance, and this will cause an overlapping/duplicate instance error.

For example, a user could have defined a Reduce member for int array type, making the reduce a total function, and acts like a sum (return 0 if array is empty). I know this is very silly but it has a possibility.

I don't know if such a case will cause breaking change in F# though...

BurningLutz avatar Jun 13 '25 06:06 BurningLutz

I see, but here is not possible for the user to add a Reduce member for an array type and make it part of the type-classy-like behavior. It would have to be changed in this library.

At some point every single introduction of a new function is a breaking change, in the sense that it might clash with an existing function already named like that, but for obvious pragmatic reasons these kind of (breaking) changes are not taken into consideration.

gusty avatar Jun 13 '25 07:06 gusty

Simply adding a non-safe, generic reduce wrapper for seq/list/array that is completely consistent with the behavior of FSharp.Core is the conclusion of this issue.

I think at this point in the discussion we all 3 participants so far agree in that. The only blocking question is the name of the new total function.

  • fold1 ?
  • reduce1 ?
  • something not ending with 1 ?

gusty avatar Jun 13 '25 07:06 gusty

Simply adding a non-safe, generic reduce wrapper for seq/list/array that is completely consistent with the behavior of FSharp.Core is the conclusion of this issue.

I think at this point in the discussion we all 3 participants so far agree in that. The only blocking question is the name of the new total function.

  • fold1 ?
  • reduce1 ?
  • something not ending with 1 ?

What I mean is, just call reduce directly, by creating a new type Reduce and a static method Invoke.

fxdmhtt avatar Jun 13 '25 07:06 fxdmhtt

You are talking about the implementation already. We're not yet there, but yes, that would be the way to implement it.

gusty avatar Jun 13 '25 07:06 gusty

I think at this point in the discussion we all 3 participants so far agree in that. The only blocking question is the name of the new total function.

* `fold1` ?

* `reduce1` ?

* something not ending with `1` ?

Just be curious, do we really need to keep this total reduce at all? We already have a perfectly total "reduce" function, the fold. It only has a slightly different calling convention.

I think F# core provides the partial reduce for convenience and the total fold for safety, we could do the same thing.

BurningLutz avatar Jun 13 '25 07:06 BurningLutz

No, no, no, I mean, the function is called "reduce".

Automatic overloading via type inference.

fxdmhtt avatar Jun 13 '25 07:06 fxdmhtt

OK, I think there may be a misunderstanding, so let me recap for clarity:

  • The proposed reduce function will operate over all collections, including empty ones. This makes it partial—also referred to as an unsafe function—because it can fail at runtime when given an empty collection.

  • The currently existing total (i.e., safe) version of reduce will be replaced by a function with a different name. We’ll need to propose and agree on a suitable name for it.

Just be curious, do we really need to keep this total reduce at all? We already have a perfectly total "reduce" function, the fold. It only has a slightly different calling convention.

Well, I’d argue the difference is more than just a “slightly different calling convention.” fold has a different signature and a higher cognitive load, especially for less experienced developers. While it's true that many total operations can be expressed in terms of a fold, that alone isn't a strong argument for removing purpose-specific total functions like reduce. The presence of a simpler, total reduce makes code more readable and expressive in common use cases—where we want a safe reduction with a natural default or initial value inferred.

gusty avatar Jun 13 '25 07:06 gusty