Why Number.range instead of a new object like Range?

I've read through the proposal, the notes from the April TC39 meeting, and the core-js polyfill and I have several questions:

  1. Why split constructors for BigInt and Number ranges? (Number.range / BigInt.range)
    a. Is there a reason that Range(<number|bigint>, <number|bigint>) isn't a good idea as long as the types match?
    b. None of the other methods on the Number object return new types. They either return a number (e.g. parseInt) or a primitive (e.g. isFinite, toLocaleString)

  2. Why return an iterator instead of an iterable?
    a. Unexpectedly stateful objects like iterators have been a footgun in the past. For instance, RegExp objects with the global flag maintain state and the rule of thumb is to create them as needed instead of reusing them (e.g. if (/foo/g.test(str)) instead of const FOO = /foo/g; /* ...later */ if (FOO.test(str)).)
    b. It would be simpler to call Range(1, 5).toIterator() than to construct an iterable from an iterator.

  3. The proposal explicitly calls out if (x in Range(0, 30)) as a non-goal, but this seems like a common use-case of ranges in other languages.

  4. Why return undefined for bad inputs rather than an empty iterable?
    a. for (let i of Range(0, 5, 10) { /*...*/ } throws if the bad range returns undefined, but iterates zero times if an empty iterable is returned.

  5. Why store start, end, step, and inclusiveEnd on the range? No other iterator/iterable stores metadata like this.

1b. “A number or a primitive” is already “a number or not a number”; whether “not a number” includes only primitives or not doesn’t seem relevant to me.

  1. There’s no such thing as an iterable by itself - “iterable” is a protocol, that a number of things implement. All built-in iterators also are iterable, so the proposal as-is does return an iterable - just, an iterable iterator, versus an iterable something else.

  2. It seems like not throwing here would be most likely to be hiding a bug - the cases where returning an empty iterator is a convenience seem like they’d be an extreme minority.

  3. I agree; it seems kind of strange to attach all this metadata publicly to the iterator.

1 Like

if (x in Number.range(0, 10) would require range being a proxy and subverting the Reflect.has trap to have a different semantic meaning than checking the existence of a property. Which is likely why it’s under the “‘magic” section.

Maybe there is a way of providing a range check api as a method instead of with syntax

1 Like

1a) We expect that we'll have more types of ranges in the future, for things like dates/times, strings, etc. These may or may not be distinguishable purely from argument types, and they might require or allow additional metadata beyond what numeric ranges do, so stacking them all in one Range object didn't seem great.

2b) If you need a reusable Range, we already have a way to do that - ()=>Range(1,5). Each invocation will produce an iterator.

  1. The "in" operator already has a meaning on objects, including Range objects - whether or not the object contains a particular key. We can't overload that with a notion of range-containment without causing ambiguity.

Also, unfortunately, the notion of what's in a range is fraught for Number ranges, at least, if the step is not an integer or dyadic fraction - you have to explicitly perform the iteration to figure out precisely what floats are in it. For example, is 0.3 in Number.Range(0, 1, .1)? Depends! 0 + .1 + .1 + .1 certainly does not quite equal .3.

You might be thinking instead of intervals, which have an easy and well-defined notion of what's inside of them, based on just comparing the value to the start/end and taking inclusive/exclusive into account. They still wouldn't be able to override the in operator, but they could at least have a well-behaved .contains() method.

2 Likes

I wasn't thinking of an interval for this question, but checking if a number is a member of the range (set), like
2 in Number.range(0, 10, 2) === true
and
3 in Number.range(0, 10, 2) === false

I hadn't considered the non-dyadic fraction cases, but that is clearly an issue with using the in operator. How does the current proposal handle iterating over Number.range(0, 1, 0.1)? Will it give 0, 0.1, 0.2, 0.30000000000000004?

As it happens, no, it'll give you the expected .3 answer. The range values are explicitly specified as start + n*step (rather than an accumulating sum) precisely to minimize the chance of floating-point value drift. (And to avoid issues with precision loss as your value gets large, so you don't infinite-loop accidentally.)

But that does still mean that you can't reliably tell whether a value is in the range or not without iterating it. BigInt.Range() doesn't have this problem, tho.

2 Likes

Though 0.3 is 0.299999999999999988897769753748434595763683319091796875 and 0.1 * 3 === 0.3 is false.

Dang, I should have actually tested that, huh.

Well, just illustrates the problem beautifully. Number.Range(0, 1, .1).contains(.3), which one would absolutely reasonably expect to return true, actually returns false.

1 Like

Well, reasonably... based on incorrectly assuming exact, or decimal floating point arithmetic. JS Number is specified in binary, hence it cannot represent .1 or .3 exactly. It is not reasonable to assume .1±rounding * 3 === .3±rounding

Anyway, a range iterator with inexact floating-point step should not be encouraged, it's a terrible misfeature that nobody wants. Use linspace instead.

I did some quick playing around in a Node REPL, and here's what I've found: 0.1 + 0.2 and 0.1 * 3 both evaluate to 0.3 + Number.EPSILON/3.

So it might be possible to figure out a way to get it correctly rounded, but it'll take a lot more work. (For one, 1.1 * 14 evaluates to 1.1*14 - Number.EPSILON*5.)

Floating-point range iterators are useful provided they're close enough. It's just not a common use case.

I've been thinking about the "in" operator in regards to ranges, and I realized that an object with both keys and values as the numbers of the range would work for this. I know it's not a good polyfill (for that we'd definitely need the Proxy for has) but I still think the following syntaxes would be convenient:

  • for (let i in range)
  • for (let i of range)
  • if (i in range)
for (let i of Number.range(10, -Infinity, -1)) {
  announceCountdownNumber(i);
 }

for (let i in Number.range(10, -Infinity, -1)) {
  /* I can never remember if I should use 'in' or 'of' */
  announceCountdownNumber(i);
}

// announcing countdown
function announceCountdownNumber(x) {
  if (x in Number.range(10, 0, {inclusive: true, step: -1}) {
    console.log(x);
  } else {
    console.log('T+', Math.abs(x))
  }
}

crappy polyfill that allows all these syntaxes:

function kindaRange(start, end, step) {
  // doesn't validate args or provide defaults or allow infinite ranges
  var result = {};

  // allows for..in and "x in range" syntaxes
  for (let i = start; i < end; i += step) {
    result[i] = i;
  }

  // allows for..of syntax
  result[Symbol.iterator] = function*() {
    yield* Object.keys(result);
  };

  return result;
}

What happens when i have a range from -2**53 to 2**53?

How do you define "correctly rounded"? When the range constructor receives (start, stop, step), how can you tell whether I wrote Range(0, 20, 1.1) or Range(0, 20, 1.1000000000000001) or Range(0, 20, 2476979795053773 / 2251799813685248)? The value of step is the same in all cases, because the first two have infinite binary representation, and get rounded to the third. You cannot know whether I expect to hit 15.4 after 14 steps, if you round to 15.4 it's equally likely you're "correct" as it is you're "incorrect".

Really, the only time you would want a non-integer step is when you know it can be represented exactly, such as 0.125 for example. In most of cases, though, you want Range(start, end, numSteps).

What does the current proposal/polyfill do for 2^53? I know it doesn't address "in" syntax or behavior, but if the iterator gets to (or past) the maximum safe integer, what should the user expect to happen? Wouldn't the same behavior apply to the "in" syntax? (I assume that the behavior is as wonky as trying to do math in that range of numbers.)

The range i specified includes only safe integers - i was only using it to illustrate that you’d have to have an object with 2**54 properties on it for in to work properly.

It's the standard definition used within floating point math contexts: "correctly rounded" = within 0.5 ulps of the result (ulps being a technical term). In this case, it'd mean returning 0.3 for step 4 of `Range(0, 1, 0.1)

Those familiar with floating point math implementation would know what I'm talking about here.

What's the point of doing some shenanigans to arrive at 0.3, though? Simple 3*0.1 gives 0.30000000000000004 which is correctly rounded, too.

>>> Fraction(0.3) - 3 * Fraction(0.1)
Fraction(-1, 36028797018963968)
// -0.5 ulp

>>> Fraction(0.30000000000000004) - 3 * Fraction(0.1)
Fraction(1, 36028797018963968)
// +0.5 ulp

It doesn't return the same number. Run 0.1 * 3 === 0.3 in a REPL.

Obviously, I already wrote that 3*0.1 gives 0.30000000000000004. Both float64(0.3) and float64(0.30000000000000004) are good approximations of 3*float64(0.1).