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

curry2 :: ((a, b) -> c) -> a -> b -> c #277

Closed
davidchambers opened this issue Nov 10, 2016 · 20 comments · Fixed by #289
Closed

curry2 :: ((a, b) -> c) -> a -> b -> c #277

davidchambers opened this issue Nov 10, 2016 · 20 comments · Fixed by #289

Comments

@davidchambers
Copy link
Member

Now that Sanctuary is close to being an alternative to Ramda rather than a library intended to be used alongside it, we should consider which Ramda functions we would miss were we to remove Ramda from our projects. R.curry and R.curryN are certainly important functions. def performs currying, of course, but not every project which depends on Sanctuary also depends on sanctuary-def.

The type signatures of these Ramda functions are misleading:

curry  :: (* -> a) -> (* -> a)
curryN :: Number -> (* -> a) -> (* -> a)

Accurate type signatures are an important tool for reasoning about functions. The Sanctuary functions would be much simpler:

curry2 :: ((a, b) -> c) -> a -> b -> c
curry3 :: ((a, b, c) -> d) -> a -> b -> c -> d
curry4 :: ((a, b, c, d) -> e) -> a -> b -> c -> d -> e
curry5 :: ((a, b, c, d, e) -> f) -> a -> b -> c -> d -> e -> f

We must choose the maximum supported arity. I don't think it's necessary to go all the way to curry5; curry2 and curry3 should suffice. If you call recall using R.curry or R.curryN to produce a function of arity greater than 3, please speak up.

🍛

@EvanBurchard
Copy link

+1 on currying, generally.

My 2 cents:
I think a generalized curry function is nice though. Otherwise, the currying call is tied to the function signature. So that means an extra step in refactoring, as well as complicating the desire to curry all of the functions in this list/object/whatever. If I get an object from somewhere else that doesn't have curried functions, I need to check the arity of it in its docs, and that's kind of a bummer.

Yes, the type signatures of the Ramda functions aren't very descriptive, but for me that doesn't outweigh the benefits of making them available.

@davidchambers
Copy link
Member Author

Thanks for the feedback, @EvanBurchard.

In my experience blindly currying all the functions provided by a foreign module is not the best approach. It's likely that one of the following will apply to at least one of the functions:

  • the argument order is not ideal for partial application;
  • the function returns a value of type Nullable a but we'd like to return a value of type Maybe a;
  • the function may throw, so we'd like to encase it;
  • the function is variadic and reports its length as 0, but we'd like to treat it as a binary function; or
  • the last argument is optional and we'd like to rely on the default value, so we want a function of arity N − 1 rather than N.

I'm quite comfortable with this level of verbosity given the benefits it affords me:

const S = require('sanctuary');
const _ = require('some-foreign-module');

//  foo :: Array String -> String -> String
exports.foo = S.curry2((faulates, molake) => _.foo(molake, faulates));

//  bar :: Number -> Number -> Number
exports.bar = S.curry2(_.bar);

@rjmk
Copy link
Contributor

rjmk commented Nov 11, 2016

The big issue for me is not so much the behaviour of Ramda's curry function, but the behaviour of the functions it produces. I find them nearly impossible to work with.

I would much rather work with an uncurried function and lose some pointfree, than a function curried Ramda-style. I prefer Haskell-style currying to either, of course, but some find the look of f(x)(y)(z) unbearable.

On the signature of curry, I think I lean towards it being acceptable in JS (like pipe).

One thing worth noting, is that our signatures for all polyadic functions are wrong.

  1. We claim that [ "hello", 3 ] :: (String, Int).
  2. We claim that (s, n) => n :: (String, Int) => Number
  3. ((s, n) => s)([ "hello", 3 ]) :: Number from 1 and 2, but we receive undefined.

We could choose to define our own argument-tuple type <>. (It can only be constructed at application sites). We could force a linked list structure on it -- (a, b, c) => a + b + c would have type <Int, <Int, Int>> => Int.

Then we could have curry :: (<a, b> => c) => a => b => c. And then any arity curry could be constructed with applications of map/compose.

pipe([curry, map(curry), map(map(curry))])((a, b, c, d) => d)(1)(2)(3)(4) === 4.

Not suggesting this is a good idea, but it is possible.

@davidchambers
Copy link
Member Author

The big issue for me is not so much the behaviour of Ramda's curry function, but the behaviour of the functions it produces. I find them nearly impossible to work with.

I'd love to know more about your issues.

On the signature of curry, I think I lean towards it being acceptable in JS (like pipe).

Noted. I'm going to take quite a bit of convincing, particularly since we took the "unprincipled" path with encase and encaseEither and found it to be less convenient than we had imagined (#103).

We claim that [ "hello", 3 ] :: (String, Int).

Do we? The signature of unfoldr features Pair a b:

unfoldr :: (b -> Maybe (Pair a b)) -> b -> Array a

@rjmk
Copy link
Contributor

rjmk commented Nov 11, 2016

I'd love to know more about your issues

If I misapply a Ramda-style function so it receives too many arguments (normally as a result of a higher order function (especially array methods, but also well formed higher order functions)), I get very unexpected behaviour.

I'm going to take quite a bit of convincing

Fair enough. I do quite a lot of the lambda wrapping mentioned in that issue nowadays, so perhaps I have a slightly different opinion on what's painful to others.

The signature of unfoldr features Pair a b

Oops. My fault for assuming. However, that still means we're free to use whatever semantics we desire for argument tuples, as they're not used anywhere else

@davidchambers
Copy link
Member Author

I'd love to know more about your issues

If I misapply a Ramda-style function so it receives too many arguments (normally as a result of a higher order function (especially array methods, but also well formed higher order functions)), I get very unexpected behaviour.

Ah, I see! This would not be a problem with curry2 and friends. Since curry2 would be a ternary function defined via def, the result of curry2(f) would be a curried binary function which would throw if applied to more than two arguments (assuming checkTypes is bound to true for def).

Does this ease your concern?

@rjmk
Copy link
Contributor

rjmk commented Nov 11, 2016

Does this ease your concern?

A fair bit! It's been a while since I've had a chance to use Sanctuary. I forgot how wonderful it is[1]. In the (somewhat unlikely case) that I accidentally apply a function to additional arguments of the correct type (perhaps variable types), I'm not sure whether that will be able to help?

[1]: I'm actually doing a talk on error-handling soon largely informed by Sanctuary's helpfulness

@davidchambers
Copy link
Member Author

In the (somewhat unlikely case) that I accidentally apply a function to additional arguments of the correct type (perhaps variable types), I'm not sure whether that will be able to help?

The first thing sanctuary-def does is check arguments.length. Consider this example:

const $ = require('sanctuary-def');

const def = $.create({checkTypes: true, env: $.env});

//    f :: Any -> Any -> Any
const f = def('f', {}, [$.Any, $.Any, $.Any], (x, y) => x + y);

f.length;
// => 2

f(1).length;
// => 1

f(1, 2);
// => 3

f(1, 2, 3);
// ! TypeError: ‘f’ requires two arguments; received three arguments

I'm actually doing a talk on error-handling soon largely informed by Sanctuary's helpfulness

Excellent! Please post your slides in Gitter after the talk (if you make slides).

@Bradcomp
Copy link
Member

Is there an issue with a general curryN and liftN that I'm missing? They seem to be very helpful functions, and they aren't variadic. I guess the return types aren't really well defined without using dependent types or something like that though.

Especially with curry, I do find myself currying functions with 4 parameters or more on occasion, though not too often. For me, the benefit of currying is that you can create a function that takes many parameters and incrementally build up more specific functions from the general one. While a 4 or 5 parameter function can be unwieldy without currying, it can be really useful once curried.

I'm not super opinionated on this, as lately I've been just manually currying by determining how many parameters I will want to initially pass in, and then returning a function that takes the remaining parameters.

I would definitely want to support curry3 as I use that quite often.

Now that Sanctuary is close to being an alternative to Ramda rather than a library intended to be used alongside it

This is really quite exciting! I've been experimenting with how far I can go with just Sanctuary and not Ramda. Currying is definitely one of the main sticking points I've hit, so this is great.

@EvanBurchard
Copy link

Something I want to point out here is if it is a goal to have "sanctuary as replacement for lodash/ramda," there's a bit more momentum for a general curry function than just in those libraries. Specifically, these resources cover currying without specifying arity in the function name:

https://drboolean.gitbooks.io/mostly-adequate-guide/content/ch4.html#cant-live-if-livin-is-without-you

https://github.com/getify/Functional-Light-JS/blob/master/ch3.md

That doesn't make them right, but it suggests to me that people might think of it as an all-or-nothing thing that is not dependent on arity.

Right now, I feel like the path to learning FP in JS involves (along with reading those resources/watching talks):

  • Getting familiar with ES6 array/object functions
  • Using underscore/lodash to get away from this and have a few more options
  • Use Ramda to put things in the right order and start composing them
  • Use Sanctuary for types and when you care about correct/specific type signatures/nice errors
  • Digging around in the fantasy-land spec and choosing between many implementations
  • Bouncing back and forth between all of these things

I'm just wondering what would provide the least friction between going directly from bullet point 1, 2, or 3 to 4.

HTML5 game frameworks had this similar thing, where people seemed to like either just using something that provided simple APIs or heading straight for Unity. There were a lot of projects that seemed to die on the hill of requiring a build process. This is not that bad. Just something I consider with various libraries.

@rjmk
Copy link
Contributor

rjmk commented Nov 12, 2016

Consider this example

Let me tweak that example slightly to show my issue.

const $ = require('sanctuary-def')
const S = require('sanctuary')
const R = require('ramda')

const def = $.create({ checkTypes: true, env: $.env })

const [ a, b, c ] = S.map($.TypeVariable, [ 'a', 'b', 'c' ])

const f = def('f', {}, [ a, b, c, [a, b, c] /* perhaps wrong syntax */ ], (x, y, z) => [ x, y, z ])

R.map(g => g(3), R.ap([ 'a', 'b', 'c' ].map(f), [ 1, 2, 3 ]))

Here we'll get an f is not a function error. Though this happened because of using an array method, it can happen by using combinators wrong.

@davidchambers
Copy link
Member Author

Is there an issue with a general curryN and liftN that I'm missing? They seem to be very helpful functions, and they aren't variadic. I guess the return types aren't really well defined without using dependent types or something like that though.

Well-defined output types are just as important to me as well-defined input types. One could argue that input-dependent output types are idiomatic in a language as dynamic as JavaScript, and there are many libraries—such as Ramda—which cater to those who agree. I have a different viewpoint. There is no compiler to scour our programs for errors, so the onus is on us as programmers to reason about the types of the values flowing through our programs. This makes it even more important to use simple functions which are explicit about arity (at the very least). It's clear that curry2(f) will evaluate to a value of type * -> * -> *. What is the shape of R.curry(f)? We can't know without determining the arity of f. A Ramda-style curry is ever so slightly more convenient than curry2, as it saves us one keystroke, but the loss of clarity is a more significant concern.

Another concern is implementation complexity. The Ramda team has shown a willingness to accept complex implementations in order to provide the desired API. With complexity come edge cases. With edge cases come bugs. Again, because we have no support from a compiler it's important that we rely on functions we can reason about. There's a striking difference in complexity between the implementations of R.liftN and Z.lift2.

Especially with curry, I do find myself currying functions with 4 parameters or more on occasion, though not too often.

That's enough for me. Let's provide curry2, curry3, curry4, and curry5.

I'm not super opinionated on this, as lately I've been just manually currying by determining how many parameters I will want to initially pass in, and then returning a function that takes the remaining parameters.

I've done this dance many times before. I appreciate the fact that Ramda-style currying saves me from having to choose one of the following types (for some quaternary function):

  • f :: a -> b -> c -> d -> e
  • f :: a -> b -> ((c, d) -> e)
  • f :: a -> ((b, c) -> (d -> e))
  • f :: a -> ((b, c, d) -> e)
  • f :: (a, b) -> (c -> d -> e)
  • f :: (a, b) -> ((c, d) -> e)
  • f :: (a, b, c) -> (d -> e)
  • f :: (a, b, c, d) -> e

Although Ramda-style currying is at odds with my preference for well-defined types (as a function can have all the above types at once), I like to view f(x, y) as syntactic sugar for f(x)(y) for some Ramda-style curried function f.

@davidchambers
Copy link
Member Author

Right now, I feel like the path to learning FP in JS involves [these steps]

Good point, @EvanBurchard. "FP in JS" is just part of the functional programming journey, though. Ideally people will go from Underscore to Ramda to Sanctuary to PureScript to Haskell to Idris and beyond (while pursuing other wonderful approaches such as Lisp). 😄

The more concessions we make in order for Sanctuary to seem familiar to Underscore and Ramda users, the less familiar PureScript and Haskell will seem to Sanctuary users.

One day I'd love to hear a story about a team that had used Ramda, adopted Sanctuary for increased type safety, used it successfully for some time, then realized they could write the same sort of code in PureScript and thus replace their run-time type checking with compile-time type checking.

@davidchambers
Copy link
Member Author

Thanks for the example, @rjmk. I see the problem now. If a function takes three arguments of any types and we accidentally provide three arguments when we meant to provide only one, there's no way for the function to know this was a mistake.

Here we'll get an f is not a function error.

I actually prefer this to having the program work, as it would with manual currying. If I provide too many arguments to a function I'd like my program to crash (with a helpful error message, ideally).

@EvanBurchard
Copy link

@davidchambers thanks for addressing my points.

One nitpick I have is the "saving one keystroke" argument though. It's not just one keystroke for the cases I mentioned. Changing arity of a function involves changing call site, definition, and currying-site. Also currying multiple things (in an array/object) at once is not as simple to map over.

Maybe I'm not embracing the right spirit here, but I my expectation most of the time is zero, one, or infinity. Maybe that's just a feeling I have right now that will change, but imagining a conversation with a coworker like "can we curry six? What just 5? uh. ok" kind of makes me twitch in the same way that the fst function in haskell only working with two-tuples does.

On the other hand, I've softened my position on long/descriptive variable names (eg. for type vars), although I still look sideways at fst over first. Maybe I'll come around to more things. "zero/one/infinity" just as easily makes itself "zero/one/some arbitrary number (possibly a power of two)" anyways, right?

tl:dr; I don't know man. I usually think in (my) ideal interfaces rather than ideal types, but I'm new to this game. You do you.

@rjmk
Copy link
Contributor

rjmk commented Nov 13, 2016

I actually prefer this to having the program work, as it would with manual currying.

I can see that. However, the error is much too far from the source of the problem (here it all occurs in the one-liner, but wouldn't have to in actuality). If things were strictly curried, Sanctuary could be catching these problems at the source.

@davidchambers
Copy link
Member Author

One nitpick I have is the "saving one keystroke" argument though. It's not just one keystroke for the cases I mentioned. Changing arity of a function involves changing call site, definition, and currying-site.

That's true. Wouldn't updating the call sites take most of the effort, though? Say we are wrapping a binary function f_ defined in a third-party module. We expose a curried binary function f. f is called in ten places. A new version of the third-party module is released in which f_ is a ternary function. To update f, we'd need to change curry2 to curry3. The much more significant change would be updating all ten call sites to provide an additional argument to f.

Maybe I'm not embracing the right spirit here, but I my expectation most of the time is zero, one, or infinity. Maybe that's just a feeling I have right now that will change, but imagining a conversation with a coworker like "can we curry six? What just 5? uh. ok" kind of makes me twitch in the same way that the fst function in haskell only working with two-tuples does.

I like the ZOI rule very much. Arbitrary limits are unfortunate, I agree, but are required in some cases. Scala functions cannot have more than 22 parameters, for example. R.curryN has an upper limit too:

> R.curryN(11, R.identity)
! Error: First argument to _arity must be a non-negative integer no greater than ten

Note: It is possible to avoid this limit if one is willing to use the Function constructor. See #15.

@davidchambers
Copy link
Member Author

If things were strictly curried, Sanctuary could be catching these problems at the source.

Excellent point, @rjmk! This is an argument in favour of regular currying over Ramda-style currying.

@EvanBurchard
Copy link

Interesting stuff on the limits with scala/ramda. And you're right that N call-sites still means 1 update to the curry2/3/whatever, not another N.

As far as interface, were you picturing a flexible type that still allows the uncurried version? I'm guessing you're against that:

curriedAdd(1)(2) and also curriedAdd(1, 2)

@davidchambers
Copy link
Member Author

As far as interface, were you picturing a flexible type that still allows the uncurried version?

Yes, that's what I have in mind. Here's the curry2 implementation (using the latest version of sanctuary-def):

//  curry2 :: ((a, b) -> c) -> a -> b -> c
var curry2 =
def('curry2',
    {},
    [$.Function([a, b, c]), a, b, c],
    function(f, x, y) { return f(x, y); });

Note that curry2 is actually a ternary function.

Usage example:

//  pow :: Number -> Number -> Number
var pow = curry2(Math.pow);

Each of the following expressions evaluates to 1000:

pow(10)(3);
pow(10, 3);
pow(S.__, 3)(10);

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging a pull request may close this issue.

4 participants