This shouldn't affect any existing uses of ranges in for loops, but: previously, range syntax (e.g. 1..=5) returned an Iter, and now (as of this PR) it returns a Range, which is a new builtin type with .iter(), .iter_rev(), and step_by().
It also automatically provides a known length to iteration when possible (sometimes it's too big, e.g. the total length if you actually iterated through it all would exceed U64.highest, but that should be rare in practice).
The most common broken code samples in exercism/roc look like:
(1..<10).map(...) (16 times)(1..<10).fold(...) (11 times)List.from_iter(1..<10) (7 times)(1..<10).join_map(...) (3 times)List.from_iter(1..<10).rev() (once)I've temporarily changed these to the following, but it would probably be better to add a few functions to Num.Range:
(1..<10).iter().map(...) => add map to Num.Range?(1..<10).iter().fold(...) => add fold?List.from_iter((1..<10).iter()) => add to_list?(1..<10).iter().join_map(...) => add fold_map?List.from_iter((1..<10).iter()).rev() => add rev?I've taken a stab at integrating the new range API for my date/time library gregorian, and I've noticed there's quite a few additional methods required on the type to support the full API. Some like range_iter are needed for the internal workings of Range, but must be added to the public API of the type.
There's a difference between what I'd like documentation to communicate ("you can use ..= and ..< with this type") and what documentation is actually communicating (a dozen or so methods with non-intuitive types). To a smaller difference is also there (but smaller) for methods like .plus(). Maybe these kinds of API-contract-fulfilling functions could be collapsed in documentation?
For ranges in particular, what do you think of swapping out the concrete Range type with a where-alias, and let each type implementing range-functionality to bring its on implementation? The current generic Range implementation necessarily needs to delegate a lot of work to the underlying type it's a range of, creating all these extra methods in the public API. A concrete Range type for a specific kind of range could contain the full implementation of range-functionality, removing the need for a big contract between Range(a) and a.
Aurélien Geron said:
it would probably be better to add a few functions to
Num.Range:
(1..<10).iter().map(...)=> addmaptoNum.Range?(1..<10).iter().fold(...)=> addfold?List.from_iter((1..<10).iter())=> addto_list?(1..<10).iter().join_map(...)=> addfold_map?List.from_iter((1..<10).iter()).rev()=> addrev?
those all sound fine to me except for fold_map - I don't really think that one makes sense for ranges. :smile:
@Jasper Woudenberg can you show me some code examples? Are you talking about things like date1..=date2?
Yes! So this is the old code I have for that:
https://git.sr.ht/~jwoudenberg/roc/tree/main/item/gregorian/Date.roc#L152-177
So it implements range_inclusive and range_exclusive, both functions aliases for a particular operator. The code I linked is for the Weekday type, in the same file I have the same for Month and Day.
If I understand the new API correctly I would need to implement for each of these types the following (public) methods:
range_inclusive_torange_inclusive_fromrange_exclusive_torange_exclusive_fromrange_len_if_knownrange_itergotcha - what did you have in mind as an alternative? I'm not totally sure how the "where instead of concrete Range" would work specifically :sweat_smile:
I was thinking the following interface for types implementing ranges (taking Date as an example)
range_inclusive : Date, Date -> range where [range.Iteratable(Date)] # ..=
range_exclusive : Date, Date -> range where [range.Iteratable(Date)] # ..<
Iteratable(a) : range where [
# Same type signatures these functions currently have.
range.range_inclusive_to : ...,
range.range_inclusive_from : ...,
range.range_exclusive_to : ...,
range.range_exclusive_from : ...,
range.range_len_if_known : ...,
range: range_iter : ...,
]
Or, I guess simpler alternative: if the range syntax could be made to support descending ranges we might not need the Range type, because iter_rev can be expressed more directly. I think that would be nice, avoids the need for another type with a lot of API overlap to List and Iter.
To allow types to not support descending iteration we could have:
range_inclusive : Date, Date -> Try(Iter(Date), [DescendingNotSupported]) # ..=
range_exclusive : Date, Date -> Try(Iter(Date), [DescendingNotSupported]) # ..<
(using the fact both would be called at compile time)
The only functionality that Range offers not supported by the API then is efficient skipping-iterators, it could be left to the type to define custom methods for those where it makes sense.
Writing that gave me an insight: the Range type felt funny to me and I think I understand why now: Range is not useful by itself, the only reason it exists is as a way-station between range-syntax like 0..<4 and an iterator that you actually use. It seems to me that the need for such an in-between is proof that the range syntax maybe isn't nice enough, or it would have sufficed by itself.
I think extending the range syntax so it can stand by itself, _or_giving Range an API that allows it to replace range operators fully, both seem nicer to me than the current design. Right now you have these two half-API's, (to be) documented in two different places (language spec and std docs respectively), that need to be glued together to do anything.
Jasper Woudenberg said:
I think extending the range syntax so it can stand by itself, _or_giving
Rangean API that allows it to replace range operators fully, both seem nicer to me than the current design.
hm, I'm not sure what either of these options would look like in practice :thinking:
do you have any specific ideas?
Sure!
(A) simplest option:
Support descending ranges using existing operators, i.e. 5..=2 or 5..<2. The second exclusive operator is maybe a bit weird in a descending case, but only if you think about it for longer and even then I think the meaning is unambiguous, so I wouldn't change it. Because ranges can now be defined in either order, there's no need for iter_rev anymore, so range syntax can produce Iter directly and the Range type is no longer needed, as it was before the change announced in this thread.
If we want to allow types to forbid descending ranges for whatever reason, then we change the types of the functions for the operators like so:
range_inclusive : Date, Date -> Try(Iter(Date), [DescendingNotSupported]) # ..=
range_exclusive : Date, Date -> Try(Iter(Date), [DescendingNotSupported]) # ..<
Lost in this scenario: the option to specify a step-size with the range. This can be mediated in at least two ways:
Iter could provide step and implement it by periodically skipping elements based on the step size. Downsize: this is less efficient then generating the right elements from the beginning.gregorian I had iter take a step size. Downside: this would mean the API for stepping isn't standardized.Personally this option appeals to me. It seems simple and the step-size functionality built into Rangealone I don't think weigh up against the extra complexity. I don't know other languages that integrate step size into their concept of a range, nor use cases that become a lot nicer thanks to this change. Could be I missed some leading up to this decision though.
(B) The option of dump range syntax in favor of going all in on creating a builder syntax for range:
Range.from(4).to_inclusive(5).step(3).iter()
Range.from(4).to_exclusive(5).step(3).iter()
Range.from(4).to_exclusive(1).step(3).iter()
Very flexible and could be made to support many more range-builder options if we come up with them. For instance, open-ended ranges could be supported.
This doesn't look as nice as the range operators, but has the advantage the Range module and docs contain all you need for building ranges. Also, the point where .iter() is called could call a single method on the underlying type passing all the options collected by the Range API.
Alternative API design using records and optional fields:
Range.iter({ start: Inclusive(4), end: Exclusive(5), step: 2 })
(C) combining step sizes into range syntax. This is an extension of option (A) in case we don't want to compromise on step sizes.
One approach would be to do as Excel: let the user specify the first two values in a series and let Roc infer the step size from that:
(1, 3)..=9 # 1,3,5,7,9
(1, 3)..<9 # 1,3,5,7
This would require implementing range_inclusive and range_exclusive on the 2-tuple type, with the 2-tuple passing the values through through to two new methods on the type for which the range is constructed:
Date :: U32.{
range_inclusive_step : Date, Date, Date -> Iter(Date)
range_inclusive_step = |first, second, end| ...
range_exclusive_step : Date, Date, Date -> Iter(Date)
range_exclusive_step = |first, second, end| ...
}
Like in option (A) the extra methods for stepped iterations above could be made to return a Try(Iter(Date), [DescendingNotSupported]) if types need to be able to disallow descending ranges.
Here's how other languages handle step size:
range(start, stop, step)1..10 step 21:2:101 .. 2 .. 101..10//21 to 10 by 2{1..10..2}[1, 3..10](1..10).step_by(2)(1..10).step(2)stride(from: 1, to: 10, by: 2)seq(1, 10, by=2)Oh wow, I had no idea it was this common. I've worked with quite a few of these languages never realizing this syntax existed.
It looks like a fair amount of languages contain the step directly in their range syntax: Julia, F#, Elixir, Scala, Bash, Haskell
These languages appear to use regular functions for constructing ranges: Python, Swift, R
In the list above the languages that construct a range first, then let you call a function on that passing a step size: Kotlin, Rust, Ruby
Though for each of these Range is itself a type of iterable that can be passed to for loops directly.
Aurélien Geron sagde:
Here's how other languages handle step size:
- Scala:
1 to 10 by 2
It's worth noting that Scala does not have special range syntax, it's just using infix notation for two regular functions named to and by in this example. I don't know if something similar applies to any of the other examples.
And honestly, I would be happy to question the range syntax in general. It makes good sense to have a Range construct of some kind, but in my 10+ years of development, I feel I've only seen extremely trivial uses of the syntax. A few specific functions for building certain ranges seem like they would cover the use case, and they have the benefit of being named and documented, whereas looking up syntax is notably harder.
Jasper Woudenberg said:
(A) simplest option:
Support descending ranges using existing operators, i.e.
5..=2or5..<2. The second exclusive operator is maybe a bit weird in a descending case, but only if you think about it for longer and even then I think the meaning is unambiguous, so I wouldn't change it. Because ranges can now be defined in either order, there's no need foriter_revanymore, so range syntax can produceIterdirectly and theRangetype is no longer needed, as it was before the change announced in this thread.
doesn't this already work? :thinking:
what I mean is that the range syntax just desguars into method calls, and the method can just look at its two arguments and decide what to do based on whatever it got
True, I guess it's more of a matter of the standard library currently choosing to return an empty iteration instead of a descending iteration:
» List.from_iter((5..=2).iter())
[]
For consistency's sake I did the same with the iterators in gregorian, but technically I could have done something else.
I guess what option (A) comes down to then is to revert the introduction of the Range type and instead update range_inclusive and range_exclusive implementations to return a descending iterator when the destination number lies below the start number.
ok so https://roc.zulipchat.com/#narrow/channel/397893-announcements/topic/Breaking.20change.3A.20removing.20Iter.2Erev.20and.20Iter.2Etake_last/near/616216896 was the original motivation for this, and stdlib allowing descending ranges would address that too I believe - right @Aurélien Geron?
also a trick we can do to minimize runtime cost of that branch is to do it outside the iterator so it gets run at compile time in the common case where you're doing it with literals
in other words, if ...check which arg is bigger... { return forward iterator } else { return reversed iterator }
Yes, I'd be totally happy with descending ranges. Just one more thing: (0..<1_000_000_000).step_by(1_000_000) should be efficient (it shouldn't do a 1-by-1 iterator and then skip 99.9%).
yeah that last one I think we can do if we make "step by" be a thing you specify when creating the iterator
as in, we add Iter.step_by and let you customize how it works
This sounds perfect to me. If I can write for i in 10..=1 and for i in (1..=1_000_000_000).step_by(1_000_000), and (1..=10).map(...) and (1..=10).collect() and (date_from..=date_to).step_by(Days(2)) (or something like that) then I'm a happy man. :smile:
As @Jasper Woudenberg said, 5..<2 looks a bit weird, but that's probably not an issue in practice (I guess we could allow 5..>2 but it might cause other problems, such as what to write when iterating between a and b and we don't know ahead of time which is bigger).
I wonder if that's an argument for the two syntaxes being 5..2 and 5..=2 :thinking:
the argument for 2..<5 is that it's more self-descriptive, but it's actively confusing in the case of 5..<2 it's actively confusing, which feels worse if that's going to be a common way people use ranges (and which is certainly supported, regardless of how commonly it is used)
obviously if you know that both 5..2 and 5..=2 exist, then you can re-derive which is inclusive and which is exclusive if you forget
Actually, I just realized that there's a fairly common pattern where 5..<2 would be surprising. For example:
factorial = |n| {
var $result = 1
for i in 2..<n {
$result = $result * i
}
$result
}
I realize that there are much simpler implementations, but bear with me. Today, factorial(1) correctly returns 1. If we let 2..<1 mean "from 2 down to 1 (excluded)", then the result will become 2.
My point is that users might expect a..<b to do nothing at all if a ≤ b.
that is a good point
I wonder how people feel about 2..n :thinking:
in that context
Indeed, 2..n might make the user think twice in this case (which is a good thing). Or perhaps we should force the user to be explicit about the direction they expect, such as a..<b to go up, and a..>b to go down.
2..n looks totally fine to me
I like .. and ..=, seems clear to me!
I feel like having a special syntax for this is less clear than something like
Range.({ from: 1, to: 10, by: 2})
Jasper Woudenberg said:
Alternative API design using records and optional fields:
Range.iter({ start: Inclusive(4), end: Exclusive(5), step: 2 })
I guess this is my favourite suggestion -- sorry I haven't been following this thread closely
My first reaction was "wow that's way too long" for something so common. But I guess optional fields would make this shorter. For the most common usage, from 0 to n, it would look like this (assuming default start is Inclusive(0), and default step is 1:
for i in Range.iter({ end: Exclusive(5) }) { ... }
I guess I could live with that, but I much prefer 0..5 however, tbh.
FWIW, the Exercism exercises contain 38 ranges, exactly half ..< and half ..=. Almost all the ..< start at 0 (16 out of 19). The ..= are more varied, for example 'A'..='Z'. Only 2 step_by and 1 iter_rev.
For reference, there are 284 match, 87 .map(...), 80 ??, 62 %, 27 { ..rec, ...}. So ranges are reasonably frequent.
Richard Feldman said:
I wonder how people feel about
2..n:thinking:
Are we saying this always means 2 up to n (exclusive), or it can mean up to or down to depending on n?
If you leave off the <, doesn't this have the problem that @Aurélien Geron was pointing out where users would expect it to do nothing if 2 >= n?
Not sure if I'm missing something in terms of what's being proposed, but I think having a..b flip direction based on the values of a and b (and also possibly based on whether or not they can be evaluated at compile time) is inviting bugs.
But you could have:
a..b for "a up to b exclusive"a..=b for "a up to b inclusive"a..>b for "a down to b exclusive"a..>=b for "a down to b inclusive"I'd vote for that. (My second favorite would be the same thing but with a..<b and a..<=b for the first two options.)
To me this seems like it solves the problem of a..b flipping meaning based on the values, while still providing convenient syntax for both forward and reverse directions.
If a range of numbers is defined such that from is greater than to, you could also reasonably expect to generate a range that goes up to the highest number of the type, overflows, and then hits to, without ever descending. I would rather just use a reverse function than try to make sensible syntax for this.
I want to keep the range syntax so we can use it in pattern matching
not saying exactly what that syntax should be, just that it's useful beyond creating ranges - e.g. for matching http status codes 400..<500 =>
Would the descending range be needed for that? Just defining that range syntax always produces an ascending range seems like a good simplicification, and then anyone wanting to create a descending one can call reverse on it.
That's pretty much the change announced at the start of this thread, downside is that it makes things a more complicated in other ways. The tldr; is that reverse() isn't defined on Iter anymore (because those are not as a rule all reversible), and so being able to define reverse() would require the range syntax to produce an intermediate Range type that defines reverse() along with iter().
Eric Rogstad said:
Not sure if I'm missing something in terms of what's being proposed, but I think having
a..bflip direction based on the values ofaandb(and also possibly based on whether or not they can be evaluated at compile time) is inviting bugs.
I can imagine situations where the programmer intends to create an ascending range 2..n without realizing that for some values of n that would descend. But wouldn't in such cases the current behavior of returning an empty iterator most likely also be a bug?
Richard Feldman said:
I want to keep the range syntax so we can use it in pattern matching
:heart_eyes: this would be really cool!
Would this still be achievable if range syntax goes back to directly producing an Iter instead of an intermediate Range type?
I think the syntax would mean something different in the case of patterns regardless
also, I think it's definitely clear that we should not have Iter.rev - so I think the open question here is what range syntax should do in the non-pattern scenario :smile:
I'd also vote for @Eric Rogstad 's proposal (the second option because it's more explicit): 1..<10, 1..<=10, 10..>1, 10..>1. These force the user to choose the direction, which is a good thing IMO. As for 10..<1, I vote for an empty iterator. That's the standard behavior in Rust, Kotlin, Ruby, Python, Haskell, F#, Julia, Matlab, and others, and I never had any issue with it.
That said, since descending ranges and ranges with step ≠ 1 are relatively uncommon (~8% of all ranges), perhaps we only need a special syntax for ascending-by-one ranges (i.e., ..< and ..=) and the rest can be handled using a clear-but-long function call such as Range.iter({ start: Inclusive(10), end: Exclusive(0), step: 2 }). Note:
step: -2 for descending ranges, but it might be a hassle with unsigned integers.direction: Descending in this example.Jasper Woudenberg said:
I can imagine situations where the programmer intends to create an ascending range
2..nwithout realizing that for some values ofnthat would descend. But wouldn't in such cases the current behavior of returning an empty iterator most likely also be a bug?
Can you give an example of where it would be a bug?
To me this seems like equivalent to writing a loop where the end condition is already true. Definitely a bug if the end condition can never be true, but I think there are lots of normal cases where sometimes when you encounter the loop the end condition is already true and sometimes it's not. (For a concrete example of that kind of case, see the factorial example that @Aurélien Geron shared above.)
Aurélien Geron said:
That said, since descending ranges and ranges with step ≠ 1 are relatively uncommon (~8% of all ranges)
That's part of my motivation for preferring a..b and a..=b over a..<b and a..<=b. You just make the common case as simple as possible. And then you have to be a tiny bit more explicit if you want to do something weird (e.g. a..>b and a..>=b).
Aurélien Geron said:
perhaps we only need a special syntax for ascending-by-one ranges (i.e.,
..<and..=) and the rest can be handled using a clear-but-long function call such asRange.iter({ start: Inclusive(10), end: Exclusive(0), step: 2 })
I think a major downside of this kind of syntax is that while technically it's "clear", it's pretty hard to read at a glance (at least for me).
I'd much prefer to look at something like (0..10).step(2). Maybe there's a little more up-front cost of understanding the syntax the very first time you come across it, but then there's less cost every other time you read that kind of code. Vs the longer-more-explicit syntax, which would feel (to me) more effortful to read every time.
I'm happy with both .. and ..<. My slight preference for ..< is that it's more explicit, plus it's not actually more frequent than ..= (at least in Exercism exercises).
I fully agree that readability matters. It's one of the reasons why I prefer the new Roc syntax with static dispatch, it makes the code more concise and (to me) easier to read.
If I accept for the moment that the iterator syntax should have a clearly defined direction as a correctness thing, then it seems incongruent to me that there isn't some sort of error if the user passes an end value that comes before the start value.
Jasper Woudenberg said:
If I accept for the moment that the iterator syntax should have a clearly defined direction as a correctness thing, then it seems incongruent to me that there isn't some sort of error if the user passes an end value that comes before the start value.
Should this give you an error in C++ if you call it when n is zero?
for (int i = 0; i < n; i++) {
cout << i << "\n";
}
Or should you not be able to implement factorial this way in Roc?
factorial = |n| {
var $result = 1
for i in 2..<n {
$result = $result * i
}
$result
}
Eric Rogstad said:
Or should you not be able to implement factorial this way in Roc?
I don't think so, no, because the for-loop explicitly defines what it does. The range syntax is more compact and ambiguous. I don't immediately think of a for-loop when I see a range.
Eric Rogstad said:
Should this give you an error in C++ if you call it when
nis zero?
I don't have super strong feelings about that particular implementation of factorial. It's using the fact that 2..<n is empty for n <= 3 in a neat way, you wouldn't be able to do that anymore with descending ranges. I think it could be adapted by writing ..<(n.lowest(3)) instead, seems fine to me too? It's a bit more explicit about how it handles the smaller numbers.
Last updated: Sep 03 2026 at 15:16 UTC