Uncurried by default?

Now that ReScript is able to focus on optimizing for the JS use case and not “held back” by OCaml compatibility anymore, shouldn’t we consider making uncurried calls the default?

Of course this would be another breaking change, and I don’t know the implications it would have in the compiler, but it would bring the following improvements:

  1. Better performance and simpler JS code: No more Curry._1(...) calls in the generated code that cost performance, especially when they happen in the hot path.
  2. Therefore no more need for manual performance tuning for such cases, which is a pain anyway, as making a function uncurried requires changes to all the call sites.
  3. Make things easier for beginners who sometimes struggle with problems where they are inadvertently calling a function with one or more arguments missing.
  4. Simplified JS interop - no more need for bs.uncurry etc.
  5. If everything is uncurried by default, we can also get rid of the Belt forEach vs. forEachU etc. distinction.

We could simply flip the current . syntax, i.e. if we have a function f, then f(a, b) would be an uncurried call, meaning that the function would need to have exactly two parameters, whereas f(. a) would mean a curried call (partial application of f).

Not sure if the latter would be needed at all though as you can always just spell out the closure b => f(a, b).

What do you think?

3 Likes

There’s one missing feature parity: the optional argument is not support in uncurried calling convention, what do you think?

My personal experience:

“Curried by default” is one the best features of the language, and one of my primary reasons for using Rescript.

Regarding points 1 and 2: this kind of feels like demoting a fantastic language feature for the purpose of micro optimisations. Sure, it can be a little annoying to see that a fully applied function in my .res file is wrapped in a Curry._x in my .bs.js file, but I’m yet to notice it actually making a difference to my apps.

Regarding 4: is this really a pain point?

Regarding 5: I wouldn’t consider this an improvement, merely a side effect of removing the feature.

I think point 3 has merit, though my opinion is:

a. Isn’t this taken care of by the type system? If a value is used incorrectly, surely you can rely on the compiler to tell you?

b. I think it’s fair to assume an understanding of basic FP concepts when starting with an FP language. If someone is confused by currying/partial application, shouldn’t they be mentored?

I still consider myself a beginner with Rescript and FP in general, for what it’s worth.

14 Likes

I would agree, but @Hongbo 's point is pretty devastating. Not worth losing optional/default parameters, IMO.

Rather, the errors should be clear to guide newcomers into the right direction.

2 Likes

I would love for an in-depth explanation as to why currying is done in runtime vs compile-time

2 Likes

Someone else is probably much more qualified to explain it than me, but here’s my basic understanding.

When the compiler is able to statically analyze a function and uncurry it at compile-time, then it does. However, there are cases where static analysis is impossible. This is most common with callbacks and functions that return functions (like React hooks).

Here, f(1, 2) is uncurried at compile-time because it’s easy to statically analyze it:

let f = (a, b) => a + b
f(1, 2)

But if we change it to a callback, then it has to uncurry it at runtime. The compiler can’t statically analyze whether the callback is already curried or not.

// ReScript
let f = (a, b, callback) => callback(a, b)
//JavaScript
var Curry = require("./stdlib/curry.js");
function f(a, b, callback) {
  return Curry._2(callback, a, b);
}

One thing that may help this make sense is considering that the following two functions have the exact same signature:

// ReScript
let add = (a, b) => a + b

let addCurried = a => {
  let _ = () // Add a side-effect so ReScript doesn't optimize
  b => a + b
}

ReScript sees int => int => int as equivalent to (int, int) => int. However, their compiled JavasScript is different:

// Javascript
function add(a, b) {
  return a + b | 0;
}

function addCurried(a) {
  return function (b) {
    return a + b | 0;
  };
}

Since either one could potentially be used as our callback, the runtime uncurrying is necessary.

2 Likes

The crux of the problem is that in a curried language, functions only have one argument. If you create a “multi arg function”, at the language level it’s modelled as a series of nested single-arg functions.

This is why the compiler can’t statically determine whether a function call will execute a function or just add another curried arg onto a function call - that information is thrown away during parsing.

In some module-local cases functions can be optimised to avoid runtime currying, but in most scenarios the checks have to be done at runtime.

1 Like

Do we have numbers on that? My impression is certainly that it costs performance, and I’ve switched hot code to uncurried syntax, but I’m not totally convinced. Maybe it depends how hot the hot code is.

I’m on the fence with this suggestion. I can see the benefits, but uncurried functions are still weird in many ways and curried “just works”. I don’t think the perceived benefits of this change would be worth losing that.

4 Likes

I don’t see any benefit from removing one of the main features of the language. One of the reasons why I like functional languages is because automatic currying. I don’t think that you should remove a mayor selling point for few cases where you can just manually optimize it, which is in fact the only proper way of optimizing something.
If premature optimization is evil, this is the mother of all premature optimizations.
Also I don’t think you should underestimate the beginners that much. If they are trying rescript it is probably because they are interested by the language and features like this. Optimizing for the few people that may not like it is another premature optimization.
If you continue changing the language to match Javascript you will end with just a syntax on top of Javascript.

5 Likes

Placeholder arguments offer much more than currying: you can specify any argument position, and you don’t need any runtime on functions not using them.

let test = map(someNumbers, add(1, _))
let otherTest = map(someNumbers, subtract(_, 1))

It is also matches the syntax used for the pipe-first operator.

let number = someNumber->add(1, _)

And as a slight aside, if you wanted to expose currying (and I think we have to), I think we should pick a new syntax rather than repurpose the old syntax. Maybe this,

let add = @curry (a, b) => a + b
let test = map(someNumbers, add(1, ...))

@Hongbo out of interest, is the optional argument support impossible to add, or just needs the work to be done?

@Hongbo out of interest, is the optional argument support impossible to add, or just needs the work to be done?

That would be my question, too. Losing optional parameters would be a dealbreaker for me. :slightly_frowning_face:

One of the reasons why I like functional languages is because automatic currying.

Having several big ReasonReact and Reason React Native apps in production, we found that we are only rarely making use of automatic currying in our code and would not mind if it needed to be made explicit in some way. YMMV, especially if you following a more “hardcore FP” style.

Still, I don’t think making currying explicit/“opt-in” would make ReScript less of a functional language than keeping it implicit/“opt-out”. It would just better fit the target platform (JS).

1 Like

I extensively use and appreciate currying across a large ReScript codebase. Losing it will be very sad. I’ve had performance issues in my ReScript codebases, but they were always due to the choice of bad data structures and algorithms. We’re now starting to work on a CPU-heavy codebase in ReScript, and I still don’t see any place where currying is going to be the bottleneck. If it becomes a problem, in the occasional case, we can always uncurry explicitly.

4 Likes

To clarify, I don’t think anyone is saying remove it. Only to make it opt-in rather than opt-out.

4 Likes

We are busy improving toolings this half so I don’t think we have budget for investigate adding optional support for uncurried calling convention

6 Likes

Ok, thanks. Maybe this can be revisited at a later point. I completely understand that tooling improvements have top priority for now.

1 Like

I think it really depends on the task at hand.

Most of our externals use explicit uncurrying because we encountered problems on several occasions, when we did otherwise.
Therefore I wouldn’t mind externals to be uncurried by default.

On the other hand we intentional use currying very often in our code base.

2 Likes

It’s possible with whole program optimization to intelligently uncurry every instance. The reason bsc isn’t doing it is because it cannot look further than the compilation unit. I think that it would be way nicer to have a “production” mode that automatically uncurries functions during compile. Cristiano also mentioned it could be added to reanalyze. To me this is the best solution because we can get rid of all somethingU functions in the standard library and we don’t have to manually uncurry functions on call and definition site anymore.

5 Likes

Our mileage definitely varies :wink:

Since we’re not talking about removing curry completely I think I would be fine with this, but not until uncurry has parity in as many ways as possible.

2 Likes

I think auto-currying has been on my top 3 list of most annoying part of ReScript. It has led to several runtime bugs for us (where a function gets curried instead of called, and it’s not catched at compiled time because the compiler can’t always tell that you didn’t mean to do that and no matter what we say about the compiler warning for effectful functions that should return unit, it still happens to us without any warning) as well as really hard to decipher error messages (because the compiler infers that let data = func(a, b) is a function, as it’s automatically curried, and will error out when you use data with a strange error. And if you’re unlucky, the error will surface much lower down the call stack, since the compiler will infer argument types etc…).

I wouldn’t want to lose optional arguments though, so if that’s the tradeoff I think we should keep it the way it is until we find a better solution.

4 Likes

Perhaps autocurrying can be disabled just for res, so all existing code keeps working? (Assuming optional arguments are addressed).

1 Like