Closed adit-hotstar closed 6 months ago
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 actualNumber.range
function usingNumber.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.
range(start, end, step?)
- Generate a sequence with a specified exclusive end.inclusiveRange(start, end, step?)
- Generate a sequence with a specified inclusive end.span(end, step?)
- Generate a sequence starting from 0 and with a specified exclusive end.inclusiveSpan(end, step?)
- Generate a sequence starting from 0 and with a specified inclusive end.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.
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;
}
};
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
andinclusiveRange
might be reasonable, but I don't agree withspan
andstep
. 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.
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....
Currently, the range proposal is very complicated. Thanks to iterator helpers we can greatly simplify the
range
function.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.We could also provide additional helper functions for generating specific sequences.
This simplifies our table considerably.
Advantages
range
function is greatly simplified.range
function promotes the use of iterator helpers and functional programming.Number.range(to)
vsNumber.range(from)
debate elegantly.Number.range(n)
generates a finite sequence of integers from 0 to n-1, like in Python.Number.range()
generates an infinite sequence of integers, like in Haskell.n
can't be bigger thanNumber.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.