Ranging Near and Far

Quick question: if you have a function of three parameters,

function f(a, b, c) { /* ... */ }

and the first and the third parameters are optional, what's the expected behavior when it's called with two parameters?

f(x, y);
  --> {a == x, b == y, c == undefined, ...} // or
  --> {a == undefined, b == x, c == y, ...} // or
  --> // something weirder depending on the actual values of x, y?

Are you sure?


I've was trying to write up my long post on the philosophy behind Ramda, focusing on several important features, including simplicity, when Mike brought to my attention a function from another library that is anything but simple. Since Ramda has a similar function, it might be instructive to compare the APIs of the functions to see the different tradeoffs the two libraries make. Besides, this will help distract me from that really long post, which is bogging me down.

Here is the description from the Underscore docs on the range function:

.range([start], stop, [step]) 

A function to create flexibly-numbered lists of integers, handy for each
and map loops. start, if omitted, defaults to 0; step defaults
to 1. Returns a list of integers from start to stop, incremented
(or decremented) by step, exclusive. Note that ranges that stop
before they start are considered to be zero-length instead of negative
-- if you'd like a negative range, use a negative step.

Examples:

_.range(10);         //=> [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
_.range(1, 11);      //=> [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
_.range(0, 30, 5);   //=> [0, 5, 10, 15, 20, 25]
_.range(0, -10, -1); //=> [0, -1, -2, -3, -4, -5, -6, -7, -8, -9]
_.range(0);          //=> []

Here's Ramda's closest equivalent:

R.range :: Number -> Number -> [Number]

Returns a list of numbers from from (inclusive) to to (exclusive).

Examples:

range(1, 5);   //=> [1, 2, 3, 4]
range(50, 53); //=> [50, 51, 52]

Flexibility

Clearly the Underscore function is more flexible. It can count backwards as well as forwards, and it allows you to step by different amounts.

Ramda came later. Why in the world wouldn't it try to at least match that flexibility? I can suggest three good reasons:

  • We didn't peek. A range function is an obvious thing to want, and when we needed one, I wrote one. There was only one API question in my mind, and that was whether the top end would be inclusive or exclusive. I thought exclusive was better, in parallel to how for loops work. Python
    uses ranges extensively and a quick look at the Python docs was all it took to confirm that I would go with exclusive. I never bothered to look at Underscore. This function seemed too obvious.
  • We didn't need it. YAGNI is a strong motivating principle. I don't recall ever needing a range such as [20, 18, 16, 14, 12, 10, 8]. Adding flexibility just because someday somehow someone might need it is a road to madness. Just imagine how much simpler it would be if your for-loops looked more like for var i in [1 .. 10] {/* ... */}. How much do you think you would miss the flexibility that the current ugly for-loop syntax offers? For me it's once or twice a year at most. I think I could work around it in those rare cases.
  • It's way too complex. That question about the first and third optional parameters of course had to do with the Underscore function. This is a function that becomes complex enough to use for the more unusual cases that there's a good chance you'll have to consult the documentation each time you want to use them. Ramda's philosophy is that things should remain simple. A function should do just one thing.

Simplicity vs Complexity

What does the Ramda function do? I'll bet you can answer that for the simple cases. There are perhaps edge cases which need to be documented. (What happens with non-integers? What happens if to is less than from?) But you already know how to use it. You simply supply the starting value and one greater than the ending value: R.range(1, 4); //=> [1, 2, 3].

Now let's talk about Underscore. We can also call _.range(1, 4); //=> [1, 2, 3]. But Underscore also creates backwards ranges. No fair looking back at the definition. If you wanted to create the reverse of that range would it be _.range(4, 1)? Probably not, right, it's still probably inclusive in the first parameter, and exclusive in the second. So we want to start with 3. I couldn't really be _.range(3, 2), stopping just below the end parameter, right; it must act symmetrically and stop at the one just above that parameter? So it must be _.range(3, 0), right?

Wrong.

You can look at the definition again now, if you like. The important part is the
last sentence:

Note that ranges that stop before they start are considered to be
zero-length instead of negative -- if you'd like a negative range, use a
negative step.

So if you create basic increasing ranges, you can skip the step parameter, but for decreasing ranges, even those decreasing by one, you need to include it.

Now let's look at the key sentence of the documentation, strangely buried as its third one:

Returns a list of integers from start to stop, incremented
(or decremented) by step, exclusive.

We really only know that "exclusive" applies to stop because we've read the Ramda documentation, because we've viewed the examples, or because we're just smart that way. While this is not exactly a problem with the API, only with the way the API is documented, they do go hand in hand. Simple functions can generally be documented simply. Complex ones often end up with complex circumlocutions.

To my mind, the greatest complexity in Underscore's function is the parameter dance of optional, required, optional. In Ramda, we don't use optional parameters. And by putting the parameters likely to change the least first, the ones likely to change the most last we make it easy to build new functions from old by simple currying. So were we to decide that we wanted a steppedRange function, we would probably start with the step, building range from it by currying in 1. Or if we wanted to allow decreasing ranges, we could do so with a simple implementation change and another sentence of documentation. I see no need, but it would be easy to update Ramda to allow R.range(3, 0); //=> [3, 2, 1]. More importantly it would remain simple, in the sense that Rich Hickey describes.

But What if I Want?

Okay, so the Ramda API is simpler than the Underscore one. What if someone really wants the features available in Underscore? Shouldn't a library, especially a utility library like these capture as many scenarios as possible?

My answer is that a good API should make the common scenarios easy and the uncommon ones possible.

So if Ramda isn't going to give you Underscore's _.range(3, 0, -1); //=> [3, 2, 1], what can it offer instead? Well Ramda is built around the notion of composing small functions. How about a simple composition of two functions?:

R.reverse(R.range(1, 4)); //=> [3, 2, 1]

That's not so painful.

But what about using a step? Underscore also has _.range(10, 20, 2); //=> [10, 12, 14, 16, 18]. How can Ramda achieve that?

How about this?:

var evens = R.reject(R.modulo(R.__, 2));
evens(R.range(10, 20)); //=> [10, 12, 14, 16, 18]

But this is still pretty specific. What if someone wanted something more general, something that handled an arbitrary start, stop, and step?

Maybe this will do:

R.reject(R.pipe(R.add(R.modulo(-start, step)), R.modulo(R.__, step)))(R.range(start, stop));

Of course, you may have noticed that I've just essentially implemented the Underscore API here, which is something I've claimed no interest in doing. (Not quite all of it, though. This only handles positive step values. Others are left as an excercise for the reader.) A system that needs this sort of flexibility should clearly have it, and should clearly have a function dedicated to it; you don't want a line like the above littering your code-base. But that does not mean such a function needs to be in Ramda itself. So Ramda will make it easy enough for you to build this yourself if you choose, but for its own API will err on the side of simplicity.