tc39 / proposal-iterator.range

A proposal for ECMAScript to add a built-in Iterator.range()

Home Page:https://tc39.es/proposal-iterator.range/

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

A simpler range proposal

adit-hotstar opened this issue · comments

Currently, the range proposal is very complicated. Thanks to iterator helpers we can greatly simplify the range function.

Number.range = function* (n) {
    if (n === undefined) {
        for (let i = 0; i <= Number.MAX_SAFE_INTEGER; i++) {
            yield i;
        }
    } else if (Number.isSafeInteger(n)) {
        for (let i = 0; i < n; i++) {
            yield i;
        }
    } else {
        throw new TypeError(`Expected a safe integer but got ${n}`);
    }
};

BigInt.range = function* (n) {
    if (n === undefined) {
        for (let i = 0n; true; i++) {
            yield i;
        }
    } else if (typeof n === 'bigint') {
        for (let i = 0n; i < n; i++) {
            yield i;
        }
    } else {
        throw new TypeError(`Expected a bigint but got ${n}`);
    }
};

The simplified range function always returns the sequence of integers starting from 0. The programmer can then use the .map iterator helper to convert the sequence of integers into the desired sequence.

Start Step End Code Mathematical Interpretation
0 1 undefined Number.range() [0,1…
0 1 n Number.range(n) [0,1…n)
0 x undefined Number.range().map(i => x * i) [0,x…
0 x n Number.range(Math.trunc(n / x)).map(i => x * i) [0,x…n)
m 1 undefined Number.range().map(i => m + i) [m,m+1…
m 1 n Number.range(n - m).map(i => m + i) [m,m+1…n)
m x undefined Number.range().map(i => m + x * i) [m,m+x…
m x n Number.range(Math.trunc((n - m) / x)).map(i => m + x * i) [m,m+x…n)

We could also provide additional helper functions for generating specific sequences.

Number.rangeStep = (x, n) => Number.range(n === undefined ? n : Math.trunc(n / x)).map(i => x * i);

Number.rangeFrom = (m, n) => Number.range(n === undefined ? n : n - m).map(i => m + i);

Number.rangeFromStep = (m, x, n) => Number.range(n === undefined ? n : Math.trunc((n - m) / x)).map(i => m + x * i);

This simplifies our table considerably.

Start Step End Code Mathematical Interpretation
0 1 undefined Number.range() [0,1…
0 1 n Number.range(n) [0,1…n)
0 x undefined Number.rangeStep(x) [0,x…
0 x n Number.rangeStep(x, n) [0,x…n)
m 1 undefined Number.rangeFrom(m) [m,m+1…
m 1 n Number.rangeFrom(m, n) [m,m+1…n)
m x undefined Number.rangeFromStep(m, x) [m,m+x…
m x n Number.rangeFromStep(m, x, n) [m,m+x…n)

Advantages

  1. The specification and the implementation of the range function is greatly simplified.
  2. The range function promotes the use of iterator helpers and functional programming.
  3. We solve the Number.range(to) vs Number.range(from) debate elegantly.
    1. Number.range(n) generates a finite sequence of integers from 0 to n-1, like in Python.
    2. Number.range() generates an infinite sequence of integers, like in Haskell.
  4. There will never be an overflow because n can't be bigger than Number.MAX_SAFE_INTEGER. Currently, the overflow behavior is not yet decided. Note that even if we generate a number every microsecond, it will still take almost 286 years to generate all the numbers from 0 to MAX_SAFE_INTEGER.

This is interesting. How do others think?

@adit-hotstar Great write-up!

I have no immediate thoughts regarding specification, implementation, functional programming, or overflow behavior. I do have an opinion on the relative simplicity for users of JavaScript.

In the general case

for (let number of Number.range(Math.trunc((end - start) / step)).map(i => start + step * i)) { ... }

is much harder to write, read, and understand compared to

for (let number of Number.rangeFromStep(start, step, end)) { ... }

Using additional helper functions (rangeStep, rangeFrom, rangeFromStep) would be simpler than calling map but seem, to me, less simple for authors, readers, teachers, students, and document writers compared to only having range(start, end, step).

for (let number of Number.range(start, end, step)) { ... }

We solve the Number.range(to) vs Number.range(from) debate elegantly.

I suspect many people have different strongly held opinions on which solution is more elegant!

My preference, regarding the range(start) vs range(end) debate, is to make both start and end non‑optional as I think it's easy enough and clearer to type 0, in Number.range(0, end) and 0n, in BigInt.range(0n, end).

I agree that Number.range(end) is a good addition and some extra complexity like inclusive is overcomplicating this proposal, but I agree with @Andrew-Cottrell that leaving start, end and step arguments are required.

I think that if passed only 1 argument it should be end, in this case, start should be 0 / 0n.

The third argument should be just a number step instead of options object.

The rest functionality could be added by iterator helpers.

Is inclusive really overcomplicating? I think it's useful when you need it and may be hard to specify without it when dealing with a negative or floating-point number.

@Jack-Works yes, it's useful. However, it's the complication of the signature - object options argument. However, for me, it's not principal.

I like the simplicity of this (without the additional helpers) - step arguments are so incredibly rare in my experience that using iterator helpers map seems quite acceptable to me.

Having a different start/end, however, seems important, as this is a much more common use case.

The use of the step argument is not rare in my experience and I would be fairly strongly opposed to leaving it out. Number.range(Math.trunc((n - m) / x)).map(i => m + x * i) is not readable.

Is inclusive really overcomplicating?

The methods Array.prototype.copyWithin, Array.prototype.fill, and Array.prototype.slice each accept an inclusive start & exclusive end and do not accept an optional inclusive flag.

I think it's useful when you need it and may be hard to specify without it when dealing with a negative or floating-point number.

A wise person once suggested this approach to get an inclusive range

let inclusive = Number.range(start, end + step, step);

A wise person once suggested this approach to get an inclusive range

let inclusive = Number.range(start, end + step, step);

Another wise person explained why it's better to have 😂😂😂😂

Yeah, it's just about ergonomic. I believe that's why many other languages (ruby, groovy, swift, kotlin, etc.) support both exclusive/inclusive ranges. And for those not have built-in inclusive range, there are always stackoverflow questions (try search "python inclusive range" 😂)

The use of the step argument is not rare in my experience and I would be fairly strongly opposed to leaving it out. Number.range(Math.trunc((n - m) / x)).map(i => m + x * i) is not readable.

What about something like this?

Number.range().map(i => start + step * i).takeWhile(n => n < end)

It's a bit more readable. The only problem is that currently takeWhile is not one of the proposed iterator helpers. However, if the takeWhile iterator helper is added then the range function can be simplified even further.

Number.range = function* () {
    for (let i = 0; i <= Number.MAX_SAFE_INTEGER; i++) {
        yield i;
    }
};

BigInt.range = function* () {
    for (let i = 0n; true; i++) {
        yield i;
    }
};

At this point, we should probably rename the function to something else like sequence. We could probably define the actual Number.range function using Number.sequence.

Is inclusive really overcomplicating? I think it's useful when you need it and may be hard to specify without it when dealing with a negative or floating-point number.

I have a strong preference for multiple functions instead of a single function with multiple options. The implementation of a single function with multiple options will tend to become complex. On the other hand, the implementation of each of the multiple functions can be kept simple.

My preference would be to have a Number.range function for exclusive ranges and a Number.inclusiveRange function for inclusive ranges. Personally, I feel like multiple functions is also better for people reading the code as the names of each of the functions can be more descriptive of what the function does.

By the way, in terms of the sheer character count using a different function wins.

Number.range(start, end, { inclusive: true }) // 45 characters
Number.inclusiveRange(start, end) // 33 characters

At this point, we should probably rename the function to something else like sequence. We could probably define the actual Number.range function using Number.sequence.

My library implements a sequence function similar to the following

/** @private @type {!Object<string, !IteratorIterable<number>>} */
const sequences = Object.create(null);

/** 
 * @public
 * @param {string=} name - optional
 * @return {!IteratorIterable<number>} - non-null
 */
function sequence(name) {
    if (name) {
        if (!(name in sequences)) {
            sequences[name] = range(0, Number.MAX_SAFE_INTEGER);
        }
        return sequences[name];
    }
    return range(0, Number.MAX_SAFE_INTEGER);
}

which I have found useful. For example, I've used named sequences to assign consecutive IDs to named form controls. The unnamed version acts like a simplified range factory function, which might be useful in some algorithms but I've haven't found much use for it so far.

I'd rather have a general purpose range in ECMAScript that user libraries could use to build simplified and/or specialised functions like my sequence above. I guess I prefer to have ECMAScript engines supply the tricky, complex, or engine optimizable implementations that can easily be composed, simplified, and specialised by user libraries.

After reading all the comments, I've been thinking a lot about how to simplify the range function by splitting it into multiple simpler functions. I came to the conclusion that we can split the range function into the following five simpler functions.

  1. range(start, end, step?) - Generate a sequence with a specified exclusive end.
  2. inclusiveRange(start, end, step?) - Generate a sequence with a specified inclusive end.
  3. span(end, step?) - Generate a sequence starting from 0 and with a specified exclusive end.
  4. inclusiveSpan(end, step?) - Generate a sequence starting from 0 and with a specified inclusive end.
  5. step(step, start?) - Generate a sequence with the specified step and with an unspecified end.

These five functions can be used to generate the whole gamut of sequences.

Start End Step Inclusive End Code
0 - x N/A step(x)
0 n ±1 false span(n)
0 n ±1 true inclusiveSpan(n)
0 n x false span(n, x)
0 n x true inclusiveSpan(n, x)
m - x N/A step(x, m)
m n ±1 false range(m, n)
m n ±1 true inclusiveRange(m, n)
m n x false range(m, n, x)
m n x true inclusiveRange(m, n, x)

The default value of start is 0. The default value of step is ±1 depending upon the start and end. The default value of end is unspecified. We don't use infinity or negative infinity for the default value of end because for the Number type the length of the sequence is Number.MAX_SAFE_INTEGER + 1 which is finite. Hence, it's more accurate to say that the default value of end is unspecified. When the default value of end is unspecified, we shouldn't care whether the end is inclusive or exclusive. We just let the function decide for itself.

Sequences with a Specified End

The range, inclusiveRange, span, and inclusiveSpan functions are used to generate sequences with a specified end. The span and inclusiveSpan functions are just specialized versions of range and inclusiveRange respectively, with start defaulting to 0. The start, end, and step arguments must all be finite. In addition, the step must be non-zero. Hence, these functions can only generate finite sequences. Here's an example implementation of the Number.range function.

Number.range = function* (start, end, step = start > end ? -1 : 1) {
    if (!Number.isFinite(start)) {
        throw new TypeError(`Expected start to be a finite number but got ${start}`);
    }
    if (!Number.isFinite(end)) {
        throw new TypeError(`Expected end to be a finite number but got ${end}`);
    }
    if (!Number.isFinite(step)) {
        throw new TypeError(`Expected step to be a finite number but got ${step}`);
    }
    if (step === 0) {
        throw new RangeError(`Expected step to be a non-zero number but got ${step}`);
    }
    const length = Math.trunc((end - start) / step);
    for (let i = 0; i < length; i++) {
        yield start + step * i;
    }
};

Sequences with an Unspecified End

The step function can be used to generate sequences with an unspecified end. The step and start arguments must be finite. In addition, the step must be non-zero. Since the end is unspecified, these functions can be used to generate potentially infinite sequences. For example, the BigInt.step function will always generate an infinite sequence whereas the Number.step function will generate a finite, albeit potentially very long, sequence. Here's an example implementation of the Number.step function.

Number.step = function* (step, start = 0) {
    if (!Number.isFinite(step)) {
        throw new TypeError(`Expected step to be a finite number but got ${step}`);
    }
    if (step === 0) {
        throw new RangeError(`Expected step to be a non-zero number but got ${step}`);
    }
    if (!Number.isFinite(start)) {
        throw new TypeError(`Expected start to be a finite number but got ${start}`);
    }
    for (let i = 0; i <= Number.MAX_SAFE_INTEGER; i++) {
        yield start + step * i;
    }
};

I think splitting range and inclusiveRange might be reasonable, but I don't agree with span and step. They're basically the same with 0 provided.

I think splitting range and inclusiveRange might be reasonable, but I don't agree with span and step. They're basically the same with 0 provided.

span(end, step) is just range(0, end, step). However, step(step, start) is not the same as range(start, Infinity, step) or range(start, -Infinity, step). See my example implementation of step. Nowhere in the implementation are we using end. It's a totally different type of function which can be used to generate potentially infinite sequences. The simplified range can't do what step can and step can't do what range can.

I'm fine with not having span and inclusiveSpan, but I think step is definitely more helpful than writing range(start, ±Infinity, step) for two reasons. First, using Infinity and -Infinity as sentinel values makes the range function unnecessarily more complex. Second, it's not accurate to say that the end is ±Infinity because for the Number type the end is finite, i.e. Number.MAX_SAFE_INTEGER. Not having to specify the end at all is better.

I'd encourage you to copy my implementation of Number.step and try it out in a repl. It's really quite pleasant to use.

> let it = Number.step(1);
> it.next();
{ value: 0, done: false }
> it.next();
{ value: 1, done: false }
> it.next();
{ value: 2, done: false }
> it.next();
{ value: 3, done: false }
> it = Number.step(-1, 10);
> it.next();
{ value: 10, done: false }
> it.next();
{ value: 9, done: false }
> it.next();
{ value: 8, done: false }
> it.next();
{ value: 7, done: false }

First, using Infinity and -Infinity as sentinel values makes the range function unnecessarily more complex.

The behavior of Infinity passed as end falls out of the obvious semantics for range; it's not a special case and it adds no complexity. Your implementation had to special case it to make it not work.

Second, it's not accurate to say that the end is ±Infinity because for the Number type the end is finite, i.e. Number.MAX_SAFE_INTEGER

Actually no, you can still get values bigger than MAX_SAFE_INTEGER, you just starting to lost values.

image

First, using Infinity and -Infinity as sentinel values makes the range function unnecessarily more complex.

[…] it's not a special case and it adds no complexity. Your implementation had to special case it to make it not work.

I stand corrected. Using Infinity and -Infinity as sentinel values doesn't add any complexity. 😄

Second, it's not accurate to say that the end is ±Infinity because for the Number type the end is finite, i.e. Number.MAX_SAFE_INTEGER

Actually no, you can still get values bigger than MAX_SAFE_INTEGER, you just starting to lost values.

I'll admit that the end is not Number.MAX_SAFE_INTEGER. However, the end is still finite. Consider the following code.

const it = Number.range(0, Infinity, 1e307);
console.log(it.toArray().length); // 18

I would have expected it to be an infinite iterator. However, we reach Infinity in 18 steps. Of course, 18 * 1e307 is not really Infinity. It's just one of those weird things about double-precision floating point numbers.

Now, the question is whether this is the desired behavior?

From the user point of view (and my personal one as well), I agree with @Andrew-Cottrell, it's way easier to have a simple function to do a range passing a start, end, and an optional step parameter would cover 99% of the cases.

I think the idea of having other functions that slightly change the behavior of the original one is a bit to add more things to learn for a language with already a lot of things to learn... And the fact that you needed a comparison table to show the differences between span, step, and range (and variations), also kinda proves this point...

Besides, I think it makes more sense to think of a Number.range as a "sequence of numbers that goes from X to Y in Z steps" which, at least for me, means that X and Y are included, if we wanted to exclude the end, we could do Y-1 or X+1 for the start. If I'm using a range, I expect to write the least amount of code possible, it's just a range, after all, this is what the end user would think.

The optimal idea for me would be something as suggested before with:

for (let number of Number.range(start, end, step)) { ... }

Or even better, as other languages do:

for (let number in x..y) { ... }

Which, I believe, is being covered on #13 and #19

There seems to be a lot of for... stuff here. I'm happy with range any way you shape it (mostly) as long as I can use map, forEach, filter, etc.

If Infinity is involved, then there needs to be a lazy way to access...

How about dynamic generation of values in, say, an array?
Number.range(0, 10, 1) to give [0, 1, 2, ..., 9] or with 10 inclusive. I don't know about you but that'd be a convenient behaviour.
Perhaps it can be allowed behind a flag passed as an optional argument - Number.range(...args, isDynamic)

@ogbotemi-2000 that's already what the proposal would do - Iterator.range(0, 10).toArray().

I agree, do tell me however which of the lines of codes below is better

Iterator.range(...args).toArray()
or
Array,from(Iterator.range(...args))

Personally, I'd choose the latter anyday hence why I suggest that redundant methods such as toArray be done away with since the same result can be obtained via Array.from

Any thumbs ups?

@ogbotemi-2000 they both work, you can choose either way you like. toArray is from the iterator helper proposal, and it's a great improve of DX when iterator is used in a long chain.

I'll close this issue for now for housekeeping. At today's TC39 meeting, delegates are generally satisfied with the status quo API.

Hold on a second, Iterator.range(start, end).toArray() basically does what Array.from({length:10}).map((e,i)=>i++) already does.
Besides if the entire purpose of this repository is for implementing a functionality similar to Python's for in in range(start, end) in JavaScript then, Number.range(start, end) should be made to simply return an iterator that may then be iterated via a for ... of loop.

This will reduce the rather needless code present in the current definition of Number.range or Iterator.range

@ogbotemi-2000 yes, but this proposal does it lazily.

I am taking lazily to mean on a need to use basis. If not then, do explain.

yes, that's right. it generates one number at a time instead of all the numbers at once.

From the tone of your voice, I could tell that you're excited by this lazy perk baked into the current proposal, that's good.
Overriding the definition of next in an iterable is a surefire way to have this perk as follows:

Object.create([].values(), {
{
  next:{
    value:_=>{/*return {value:<Number>, done:<Boolean>} based on internal conditions */ }
  }}
})

The iterable obtained from [].values() is the internally used ArrayIterator

The lazy bit comes to light when next above is called.
However, it is the approach used in the proposal that I have a bone of contention with. Here is what I mean:

 const generatorPrototype = Object.getPrototypeOf(Object.getPrototypeOf((function* () {})()));
 const origNext = generatorPrototype.next
/*next then gets a new definition pertaining to Iterator.range via new Proxy(next ...)*/

IMHO the approach used above to get a reference to the internally used next method above doesn't make into the final draft of Iterator.range's function body and I give my reasons below:

You may agree that the first code block makes it easier to define what next does because it was created from an iterable - [].values() which is from an empty array that we have total control over: being hard-coded -[], and not by new Array(n).

The second code block, aside the risk of prototype pollution, is just not right being that a reference to next, which is to be overridden, was gotten from the prototype of the prototype of a generator and not defined/overridden on an empty object that we created ourselves and not obtain via a __proto__ chain just like the first code block exemplifies.

Further more, having to decide which reference of next is to be used internally among the options below will sidetrack the development of the current draft of this proposal as such a choice require tests and safety checks.

(function*(){})().__proto__.next
/*same next on both prototypes?*/
(function*(){})().__proto__.__proto__.next

I therefore advise the delegates in charge to test and consider the first code block in this reply as an alternative.
If otherwise, I hope they also offer logical opinions on why the current proposal will remain unchanged.

Number.range(start, end) should be made to simply return an iterator that may then be iterated via a for ... of loop.

Yes. It is an iterator. You can use it with for of loop.

for (const i of Iterator.range(0, 10))

@ogbotemi-2000 Looks like you're referencing the polyfill behavior, please don't. You should check out the specification: https://tc39.es/proposal-iterator.range/

I do not understand what you mean, please explain @Jack-Works

It means that you've referred to "the code", but a language proposal only has a spec. a proposal repo's polyfill is just for illustrative purposes, and isn't how it will actually be implemented.

Thank goodness.

@ljharb can you please go through the message I sent regarding the alternative to the current proposal? I'd like to hear your thoughts on how practical a code it is and whether it can be adopted for the actual implementation of Iterator.range

Your suggested alternative is very unclear - the proposal already returns an iterator, that can be used with anything that works with iterators.

Okay then, as long as the current proposal isn't what will be the body of Iterator.range, it is all good....