What should these evaluate to?
List.range 1 4
List.range 2 3
List.range 2 2
List.range 3 2
List.range 4 1
I think everyone's opinions are welcome here, as this might be a design choice that's highly influenced by personal preferences and prior exposure.
A) [2, 3] ... [] ... [] ... [] ... []
B) [2, 3] ... [] ... [] ... [] ... [3, 2]
C) [1, 2, 3] ... [2] ... [] ... [] ... []
D) [1, 2, 3] ... [2] ... [] ... [2] ... [3, 2, 1]
E) [1, 2, 3] ... [2] ... [] ... [3] ... [4, 3, 2]
F) [1, 2, 3] ... [2] ... [2] ... [] ... []
G) [1, 2, 3] ... [2] ... [2] ... [2] ... [3, 2, 1]
H) [1, 2, 3] ... [2] ... [2] ... [3] ... [4, 3, 2]
I) [1, 2, 3, 4] ... [2, 3] ... [2] ... [] ... []
J) [1, 2, 3, 4] ... [2, 3] ... [2] ... [3, 2] ... [4, 3, 2, 1]
K) Something else
I'm not sure I'm following the "syntax" of the examples!
Personally, I'd prefer the default for ranges to be half-open. This is a hotly debated topic of course, but Dijkstra had the following to say about 'indexes should start at zero' and 'ranges should be half open': https://www.cs.utexas.edu/users/EWD/transcriptions/EWD08xx/EWD831.html
if half open means on the side such that List.range 1 4 = [1, 2, 3], I agree. this is the usual definition and seems quite common
@Brian Carroll option E means
List.range 1 4 == [1, 2, 3]
List.range 2 3 == [2]
List.range 2 2 == []
List.range 3 2 == [3]
List.range 4 1 == [4, 3, 2]
@JanCVanB you made a copy pasted error. Your first ³ lines all say 1 4 rn.
Thank you! Fixed.
Nice, it looks like there's consensus on preferring options C D E F G H. Any thoughts on reversibility, reversed open side, and empty ranges? Option E = reversible + right-side-open + emptyable.
(deleted)
I see option E as very natural, but I wanted to survey y'all in a somewhat unbiased way. (Especially since List.range is currently implemented as non-reversible and non-emptyable.)
Yeah, I would pick E personally.
Though I guess it does have the minor disadvantage that List.range 4 1 is not the reverse of List.range 1 4
what about labeled arguments?
List.range : { first : Int a, last : Int a } -> List (Int a)
this seems the least error-prone to me. There are conventions, but none of them are self-documenting; you always have to look it up, and if you're just glancing at someone else's code you might not spot a problem with it if you have the wrong mental model of what it's doing.
the first and last fields also tell you what the first and last elements of the list will be, so it should also work as expected no matter which is bigger than the other
#LateralThinking
That deviates from the "half-open" norm, but its explicitness avoids confusion
I feel like generally as a programmer you want 0 to n-1 which is the only reason first and last feel weird
Cause then you always are calculating n - 1, instead of just using n. But that is super minor
I think the record nudges devs away from indexing hell, which I'd like: https://roc.zulipchat.com/#narrow/stream/231635-compiler-development/topic/indexing.20hell/near/288758539
So if I want to generate a range of length 10 starting from 20, then with the current API, I do this
start = 20
length = 10
List.range start (start+length)
But with the suggested API above, I do this
start = 20
length = 10
List.range { first : start, last : start + length - 1 }
This is quite prone to off-by-one errors.
So in my view, the reason the half-open design is more common is because very often in a real program you are calculating those parameters from a start and a length. Maybe in a loop (or recursion).
And then the half-open design is much easier to deal with.
I suspect that the "closed" version looks better and more intuitive in one-line examples in documentation but is more bug-prone in practice.
List.range {startAt: start, endBefore: start + length}?
Polymorphism to the rescue?
List.range {start: 20, stop: 30}
List.range {start: 20, length: 10}
List.range {start: 20, finish: 29}
Perhaps we should have rangeIncl/rangeInclusive and rangeExcl/rangeExclusive and no range?
I like that on first glance, but then would we need rangeInclusiveOnTheLeftSideButExclusiveOnTheRightSide?
Inclusive range, exclusive range, and programmer range....yikes :grimacing:
This topic was moved here from #compiler development > List.range boundaries by Anton.
Martin Stewart said:
I've implemented nonempty versions of all the exposed List functions https://github.com/MartinSStewart/Nonempty.
One exception is
List.rangewhich currently returns[]if you call it with something like this:List.range 3 1. I was wondering if it would make more sense to have it instead return[ 3, 2, 1 ]? The reason is that thenNonemptyList.rangecould be guaranteed to always return a nonempty list.
@Martin Stewart please read the discussion above and let us know what you think!
I unfortunately can't see the earlier discussion. The first message I see is the Nofication Bot saying the topic was moved.
Nevermind, I just don't know how to use Zulip
The best next step is probably to get one or two competing PRs going for the 2 most popular designs, so that people can see implementations and tests.
@Anton HALP nvm
JanCVanB said:
Martin Stewart please read the discussion above and let us know what you think!
Those darn tradeoffs coming up all the time :sweat_smile: . I don't think I have anything to add besides the data point about how it makes my NonemptyList.range function nicer to use if it can have the exact same behavior as List.range. Which is only possible if List.range always returns a List containing at least one item (i.e. List.range 1 1 == [ 1 ] and List.range 3 1 == [ 3, 2, 1 ]).
That makes sense! Thanks for adding that.
Unfortunately nothing prevents people from passing in ranges which are outside of the srange of the list, say List.range [1,2,3] 20 100.
So even if higher-to-lower ranges return elements, you still have to return a list and cannot return a NonEmpty
Hmm, the current design(s) of List.range do not include a List parameter, what do you mean by that?
Sorry! I got confused with List.sublist :sweat_smile:
Disregard what I said
:siren::loudspeaker: SURVEY TIME! All opinions welcome.
(though I'm unsure about using "@ all", haha)
How should List.range work?
A) List.range 1 3 == [1, 2]
B) List.range 1 3 == [1, 2, 3]
C) List.range { start: 1, stop: 3 } == [1, 2]
D) List.range { first: 1, last: 3 } == [1, 2, 3]
E) Something similar to __, but ____________.
F) Something completely different, specifically ____________.
G) There should be multiple builtin range functions.
H) There should be one builtin range function that accepts multiple input types. (Is this polymorphism? Idk.)
Implications to note, for all Ints x and y:
A) List.range x x == [] and List.range x y != List.reverse List.range y x.
B) List.range x y != [] and List.range x y == List.reverse List.range y x.
C) see A
D) see B
Also, a step/increment parameter can be easily added later, regardless of the basic design.
</SURVEY-TIME></please-scroll-up-and-read-then-scroll-down-and-vote> :grinning_face_with_smiling_eyes::siren:
My current leaning is G
Basically something like C and D. One with endAt (inclusive range on both sides) and the other with endBefore (inclusive start, exclusive end).
G as well. The name "range" through me off a little. I figured range would return the smallest and largest number in the list, but you meant enumeration. Perhaps something like the enumeration functions provided by Haskell's vector library would be better?
Perhaps, for clarity, Num.range... would be a better namespacing than List.range... for the function(s) we're discussing.
although I prefer B/D's behavior, as it should end at rather than before the second argument.
I think "ends before" is mostly a programming convenience due to often having a start and length. So then you have { start: start, endsBefore: start + length }.
Also, interesting point on naming, I guess range comes more from imperative language naming, but enumeration might before more common in functional languages? I guess you are thinking of the mathematical range of a function when you see List.range. always really interesting how different background bring very different naming expectations. Like when I see enumeration, I think of a c/c++ style enum or even rust. Which is more like a tag union in Roc.
I think it would be nice to have a dedicated Range datastructure as part of the standard library, with multiple constructors and maybe some light syntactic sugar, and then e.g. a List.fromRange.
Rust and Elixir are two examples of languages that thought that only supporting a single kind of range was fine, until a few years down the road it turned out that there was real benefit to supporting both half-open and inclusive ranges.
A dedicated Range datastructure does look like it would be good for execution speed and memory use.
I'm going with G; rangeIncl and rangeExcl. I feel like it would be easy to forget what the record field names are if you had to pass the boundaries as a record.
I vote A for the second poll
In the future it would be good to use numbers for polls, then we can vote with emoji :two:
@Anton :a: :wink:
There is no C I believe, only :a: and :b:
:c:
Ah, I see :sad:
Ah i C!
I'd rename a function to generateRange and gave it third parameter Bounds : [ Incl , Excl ]
Doesn't range deserve to have its own set of operators? Something like:
1...3 == [1, 2, 3]
1..<3 == [1, 2]
This makes for-loop-style code much more ergonomic:
(1...10)
|> List.map \_ ->
// code
@Folkert de Vries suggested in https://github.com/roc-lang/roc/pull/4211#issuecomment-1268558719 to continue the discussion here:
I agree that we should have separate inclusive and exclusive ranges. IMO we should also provide stepping in them. The elixir ecosystem went through a breaking(-ish) change because of not providing stepping from the beginning: https://elixir-lang.org/blog/2021/05/19/elixir-v1-12-0-released/#stepped-ranges
rereading the earlier discussion, I like Brendan's proposal as a single-function design because:
List.range : { start : Int a, endsBefore : Int a, step ? Int a } -> List (Int a)
there were some discussions of having separate functions for inclusive and exclusive ranges; here's what that might look like:
List.rangeExclusive : { first : Int a, endBefore : Int a, step ? Int a } -> List (Int a)
List.rangeInclusive : { first : Int a, last : Int a, step ? Int a } -> List (Int a)
there's also the possibility of doing them without record arguments:
List.rangeExclusive : Int a, Int a -> Int a
List.rangeExclusiveBy : Int a, Int a, Int a -> Int a
List.rangeInclusive : Int a, Int a -> Int a
List.rangeInclusiveBy : Int a, Int a, Int a -> Int a
another option:
List.range : {
start : Int a,
end : [Before (Int a), At (Int a), Length Nat],
step ? Int a
} -> List (Int a)
calling that one would look like:
List.range { start: 1, end: At 10 }
List.range { start: 2, end: Before 12, step: 2 }
List.range { start: 1, end: Length (List.len myList), step: 3 }
So my one issue with including step as an argument is that it means your start and end must always be min to max (if step defaults to 1). I think it is nice to have a generic form that can take max to min or min to max.
oh, so I would assume you could still do max to min with a positive step
e.g. if you're using unsigned integers
List.range { start: 10, end: At 1 }
What does this do?
[ 10, 9, 8, 7, 6, 5, 4, 3, 2, 1 ]
So the default step isn't 1 then? what is it?
no, it's still 1
but step gets subtracted if start > end
otherwise what if you have unsigned ints for start and end? how would you ever specify decreasing by more than 1 each step?
Ok. Then what is the difference between these:
List.range { start: 10, end: At 1, step: 1 }
List.range { start: 10, end: At 1, step: -1 }
List.range { start: 1, end: At 10, step: 1 }
List.range { start: 1, end: At 10, step: -1 }
I guess I'm implicitly making the argument that step should be unsigned I guess :big_smile:
otherwise what if you have unsigned ints for start and end? how would you ever specify decreasing by more than 1 each step?
would be the reverse of what you just said, make step always the signed form and then add it in. Would mean everything is signed until casted to the final value.
ah, interesting
that works better from a type perspective because it means you can have step be Int *
whereas for unsigned you have to pick a specific number, which gets awkward because if you pick U128 you're paying a perf cost, but if you pick something lower, then it's not possible to have a 128-bit step :thinking:
ok, so here's another possibility:
List.range :
{
start : Int a,
end : [Before (Int a), At (Int a), Length Nat],
}
-> List (Int a)
List.rangeBy :
{
start : Int a,
end : [Before (Int a), At (Int a), Length Nat],
},
Int *
-> List (Int a)
so List.range would always use a step of either 1 or -1 depending on whether or not start > end
whereas List.rangeBy would add the step argument each time, whatever value you provide there
I like that
what do others think?
I don't have a strong opinion about this but I like it too!
Can we move the step parameter into the record?
And then can we combine both functions by making it an optional field?
I love the use of tags here, and as a side note it might be the first "functionality tags" that new Roc devs are exposed to, which is a great use case for showing off their power
We can maybe do that, but it requires making sense of all of these more directly:
List.range { start: 1, end: At 10}
List.range { start: 10, end: At 1}
List.range { start: 1, end: At 10, step: 1 }
List.range { start: 10, end: At 1, step: 1 }
List.range { start: 1, end: At 10, step: -1 }
List.range { start: 10, end: At 1, step: -1 }
My big issue is that if step is optional, it has to default to some value, theoretically 1. If it defaults to 1, that means that List.range { start: 10, end: At 1} would return []. You would always have to write List.range { start: 10, end: At 1, step: -1}.
I definitely think List.range { start: 10, end: At 1} should result in [10, 9, 8, 7, 6, 5, 4, 3, 2, 1].
I guess you could default it to 0 and special case 0 to mean add or subtract 1 depending on what makes mores sense for the range?
Probably a silly idea, but what if you only used tags and no record?
List.range (StartAt 1) (EndAt 10)
List.range (StartAt 1) (EndBefore 10)
List.range (StartAt 10) (EndAt 1)
@Brendan Hansknecht I don't see how moving step from a separate parameter to a record field changes the cases the function must handle.
I guess I will focus in on the one specific issue and not the many cases. If we use an optional step that defaults to 1.
ex1: List.range { start: 1, end: At 10} -> List.range { start: 1, end: At 10, step: 1} -> [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
ex2: List.range { start: 10, end: At 1} -> List.range { start: 10, end: At 1, step: 1} -> []
I think ex1 is working as expected. I think ex2 is wrong. I think ex2 should output [10, 9, 8, 7, 6, 5, 4, 3, 2, 1]
I also don't see why we'd need a "default" numerical value - why not an "inferred" numerical value? Pseudocode:
step = if start < end then 1 else -1
I am pretty sure that is how optional record fields work. It is optional because it gets set to a specific value in the function definition. Though maybe I am wrong about optional record fields.
Perhaps step's input type should be a tag union like [ByOne, By (Int *)] to support this
I guess more tags would solve this.
ALWAYS MOAR TAGS haha
They're so dang expressive.
@Tommy Graves I'm honestly neutral on one record parameter or multiple positionals parameters, since these tags are so expressive they don't need keys/names
Maybe there's some other reason for/against mono-parameter functions that tilts the balance
As long as the tags are generally known at compile time/the functions get inlined, tags should be just fine. In some situations, they can lead to a performance cost that gets magnified by hot loops.
minutes = List.range (StartAt 0) (EndBefore 60) (StepBy 5) looks great :)
Maybe a tad verbose, but definitely explicit. Also, without the record and optional field, you would have to write:
minutes = List.range (StartAt 0) (EndBefore 60) Default
Feels a bit odd. Though I think I am mostly bothered by the need to use parenthesis.
haha
minutes = List.range (StartAt 0) (EndBefore 60) StepByOne
Agreed, the parentheses are meh... and always including the tag is meh...
StepByOne will maybe confuse users as a name since it might be a step of 1 or a step of -1
Here's a curveball idea - what if positive step always took you toward your endpoint?
countdown = List.range { start: At 100, end: At 0, step: By 10 } looks fine to me
This will simplify calls for many start > end use cases, hopefully with no performance cost :fingers_crossed:
so
{ start: At 100, end: At 0, step: By 10 } -> [100, 90, 80, 70, 60, 50, 40, 30, 20, 10, 00]
{ start: At 100, end: At 0, step: By -10 } -> [00, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]
{ start: At 0, end: At 100, step: By 10 } -> [00, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]
{ start: At 0, end: At 100, step: By -10 } -> [100, 90, 80, 70, 60, 50, 40, 30, 20, 10, 00]
I've had this happen before, where my start and end bounds are dynamic and on an overlapping domain... and I have to add in a little logic for keeping the signedness of the step in sync with their relationship
Oh, and here I was assuming that a "wrongly"-signed step would give you a near-infinite loop like
{ start: At 0, end: At 100, step: By -10 } -> [0, -10, -20, ... crash?
Idk if that's desirable for anyone, maybe... And maybe it just wraps around the numberline without overflowing??
I think we definitely don't want the near infinite loop. It would lead to a panic anyway from over/underflow. So in those cases, we probably would just return []. Or I guess make it relative to start/end.
We could also just take the absolute value of the step and make it go from start to end regardless :shrug:
Looks like we're a bigger fan of the record though? I like being able to elide the default tag value for step :)
Idk if the At tag is necessary for the start, but it makes it more consistent
I imagined with the tag approach you would want a separate function for defining the step, so you don't have to specify anything in the default case.
Or we could buy into optional function parameters :laughing:
I also don't like the need to use parentheses -- I wish you could do
List.range StartAt 0, EndBefore 60,
to avoid the parentheses, or something like it.
List.range [StartAt 0, EndBefore 60]
:laughing:
JanCVanB said:
Idk if the
Attag is necessary for thestart, but it makes it more consistent
you could have start be either At or After, for symmetry with the end being At or Before - although probably nobody would ever use After in practice :big_smile:
Was just about to suggest that :D
to me, the most important things here are:
(I just thought of a use case for After:
countup = List.range { start: After 0, end: At 100, step: By someDynamicStep }
for things like 2, 4, 6 or 5, 10, 15)
huh, interesting!
Hmm...That worked differently than I expected. With After 0, I expected the first element to always be 1. so the output would be 1, 3, 5, ... and 1, 6, 11, 16, ...
oh I was assuming After would mean "step once past this number"
That does sound pretty reasonable. I guess I just wasn't expecting it. So thought it would be worth mention my immediate thought. Anyway, I think after using step makes sense and is fine.
Brendan Hansknecht said:
I guess you could default it to 0 and special case 0 to mean add or subtract 1 depending on what makes mores sense for the range?
this seems reasonable since step by 0 doesn't make any sense and is not otherwise useful :stuck_out_tongue:
so then the proposal would be:
List.range :
{
start : [At (Int a), After (Int a)],
end : [At (Int a), Before (Int a), Length Nat],
step ? Int *
}
-> List (Int a)
if you give a step of 0, it means "move by 1 from start towards end, whether that would mean +1 or -1" and that's what step defaults to if you don't specify it in the record
thoughts on that design?
step ? Int a instead of *?
This will be the clearest range function I've ever seen, in a standard library or otherwise :heart_eyes:
and it's an inspiration for merging functionality via tags!
Step maybe can't be Int a? What if I want a range of U64, but with a start greater than end, so negative step, but using the same int type would limit to only positive step.
Very true, but if it's Int * then there's no way for the implementation to correctly cast it to add with Int a, right?
Would have to be a low level? Or compiler magic? Not sure. But yeah, i don't think it could be written in current userland roc.
(lateral thinking - if that's the only counterpoint to using Int a, that could support having positive step always point from start to end like List.range { start: At 50, end: At 10, step: 10 } == [50, 40, 30, 20, 10])
I am going to try and work on a this update since it seems useful to fix for AOC. Question on boundaries: should wrapping be allowed?
what should List.range { start: At 0i8, end: At 128i8, step: -16 } return?
[0, -16, -32, -48, -64, -80, -96, -112, 128]
Or panic due to overflowing the max negative
Or [] for being invalid.
:thinking: what do other languages do?
python: [] but it has infinitely sized integers and would break if it tried to generate that range.
rust: not writable with ranges
c/c++: don't have it. user manually makes it with for loop or using std::iota, i guess
js: at least from my quick googling, doesn't have it
let's do panic for now - that's the most upgradeable way because it means nobody can depend on it working a different way
I had an idea about this feature. I really love the new API, List.range {start : At 0, end : Before 10, step : 2 } its so easy to work with. However, I was wondering if this should live in Num instead, I feel like it's actually a Num.range not a List.range.
interesting! What do others think?
I've always thought of this as a list thing, not a number thing
Num.range makes more sense (to me) for what rust does, where a Range<usize> is really its own type, not just a vector with the numbers in some range
When first skimming the stdlib docs, I originally thought List.range was going to provide a subsclice operation: "give me the elements from this list that fall within this range".
Almost everything else in List takes a list as input.
fwiw, List.sublist would benefit from the same tag style approach that range was given
I think List.range may be easier to find for beginners when searching. If you want to produce a list of numbers, I'd go looking in the list docs.
What would be the criteria for putting functions into a module? It can't be the return type alone...
For Roc, it largely seems to be the input type (see Num.toStr vs Str.toU128). iow Num is what you use when you already have a Num, and Str is what you use when you already have a Str.
Auxiliary cases, like List.single, don't follow that rule, but there also isn't a OneOfSomething module to attach it to :wink:
If this becomes a wider topic of conversation, it'd certainly be worth its own thread.
also you have to consider dependencies between modules. So the Str module can use the numeric types, but the Num module cannot use Str because that would create a cyclic dependency
Should the Length tag which range accepts be Len instead, in order to align with List.len ?
Yeah, that sounds like a great idea. I mix that up every time I use the function
Last updated: Jun 16 2026 at 16:19 UTC