Skip to content

adds the nth function for iterables #56580

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 29 commits into from
Jun 19, 2025
Merged

adds the nth function for iterables #56580

merged 29 commits into from
Jun 19, 2025

Conversation

ghyatzo
Copy link
Contributor

@ghyatzo ghyatzo commented Nov 16, 2024

Hi,

I've turned the open ended issue #54454 into an actual PR.
Tangentially related to #10092 ?

This PR introduces the nth(itr, n) function to iterators to give a getindex type of behaviour.
I've tried my best to optimize as much as possible by specializing on different types of iterators.
In the spirit of iterators any OOB access returns nothing. (edit: instead of throwing an error, i.e. first(itr, n) and last(itr, n))

here is the comparison of running the testsuite (~22 different iterators) using generic nth and specialized nth:

@btime begin                                                                                                                                                                                                                     
    for (itr, n, _) in $testset                                                                                                                                                                                           
         _fallback_nth(itr, n)                                                                                                                                                                                                           
    end                                                                                                                                                                                                                          
end                                                                                                                                                                                                                              
117.750 μs (366 allocations: 17.88 KiB)

@btime begin                                                                                                                                                                                                                     
  for (itr, n, _) in $testset                                                                                                                                                                                           
    nth(itr, n)                                                                                                                                                                                                              
  end                                                                                                                                                                                                                          
end                                                                                                                                                                                                                              
24.250 μs (341 allocations: 16.70 KiB)

"""
nth(itr, n::Integer)

Get the `n`th element of an iterable collection. Return `nothing` if not existing.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Returning nothing makes it impossible to distinguish between "the nth element was nothing", and "there was no nth element". Perhaps return Union{Nothing, Some}?

Copy link
Contributor Author

@ghyatzo ghyatzo Nov 16, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point.
Should it be Union{nothing, Some} even in those cases where we know there can't be a nothing value in the iterator (for sake of uniform api)? I.e. Count Iterator or Repeated (with its element different than nothing) or AbstractRanges

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it should, otherwise it would be too confusing.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would just throw an error if there is no nth element. There could also be a default argument as in get, where a user can pass a value that should be returned if no nth element exists.

I don't really follow the logic that the spirit of iterators is to return nothing in such cases?

Copy link
Contributor

@mcabbott mcabbott Nov 17, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree nothing is weird, your iterator can produce that. Some seems a bit technical & unfriendly? An error seems fine. Matches what first([]) does.

I suppose it can't literally be a method of get since it goes by enumeration not keys:

julia> first(Dict('a':'z' .=> 'A':'Z'), 3)
3-element Vector{Pair{Char, Char}}:
 'n' => 'N'
 'f' => 'F'
 'w' => 'W'

julia> nth(Dict('a':'z' .=> 'A':'Z'), 3)
'w' => 'W'

@adienes
Copy link
Member

adienes commented Nov 16, 2024

how would this compare to a more naive implementation like

nth(itr, n) = first(Iterators.Rest(itr, n))

?

@LilithHafner LilithHafner added triage This should be discussed on a triage call iteration Involves iteration or the iteration protocol feature Indicates new feature / enhancement requests labels Nov 16, 2024
@ghyatzo
Copy link
Contributor Author

ghyatzo commented Nov 16, 2024

how would this compare to a more naive implementation like

nth(itr, n) = first(Iterators.Rest(itr, n))

?

Rest requires knowing the state used by the iterator, which is often considered an implementation detail and hard to pick automatically (unless i am missing something!)
If the state was known first(Rest(itr, n)) would probably be the fastest, since you alwasy do at most one iteration.
but knowing the correct n-1 state means that you most likely calculate n state directly.
In that case then a specialization would be even better!

@mcabbott
Copy link
Contributor

mcabbott commented Nov 17, 2024

Seems like a lot of code.

I reproduced the above benchmark here:
https://gist.github.com/mcabbott/fe2e0821e9bfe5cc7643bb15adf445d0
I get 75.625 μs for first(Iterators.drop(itr, n-1)) vs 1.558 μs for the PR. However, this is entirely driven by one case, Cycle{Vector{Int64}}. Some other cases are faster, some slower (String). Maybe they ought to be discussed in follow-up PRs?

No strong position on whether this needs a name or not, but perhaps this first PR can focus on that, and let the implementation be just:

nth(itr, n::Integer) = first(Iterators.drop(itr, n-1))
nth(itr::AbstractArray, n::Integer) = itr[begin-1+n]

@ghyatzo
Copy link
Contributor Author

ghyatzo commented Nov 17, 2024

A lot of the code is for optimizing out of bound checking. If we go with davidantoff suggestion of letting nth just error on oob n most of the actual code can be scrapped while retaining the speed.

@jakobnissen
Copy link
Member

I disagree with throwing an error. In cases where you don't know if an nth element exists, that forces a try-catch which is both slow and brittle. I would imagine that most ordered iterators with a known length support indexing, so this would probably mostly be used precisely when the length is unknown.

@davidanthoff
Copy link
Contributor

I think another consideration here is consistency: the other functions we have that take an individual element from an iterator are first and last, and both throw an error if you ask them for something that doesn't exist (i.e. when you call them on an empty source). In my mind, first, nth and last should have the same kind of design, so that also speaks in favor of throwing an error.

I agree with @jakobnissen that in some situations being able to handle this without an exception would be nice, but on the flip side, I can also see scenarios where an error seems much better, in particular in interactive sessions where I might be playing around with some data and this function could be very useful. And especially in an interactive scenario it would be super inconvenient if Some was used...

Maybe the best design would be to allow for both scenarios. Say something like

nth(itr, n, nothrow=false)

So the default would be that an exception is thrown if the nth element doesn't exist, but when nothrow=true then nothing is returned if the element doesn't exist, and on success things are wrapped in Some.

@ghyatzo
Copy link
Contributor Author

ghyatzo commented Nov 18, 2024

We could also opt for relying on the IteratorEltype trait to check if an iterator can contain nothing elements.
With ::HasEltype() we can dispatch over eltype(itr) <: Union{Nothing, T} where T.
In that case nth can return Some(nothing) while otherwise just return nothing since that would be unambiguous for those collections that do not have nothing in them. Of course wrapping with Some would be the default in case we have a ::EltypeUnknown Iterator.

Some is already expected in workflows with Union{Nothing, T} so that wouldn't introduce any extra complexity.

Although I see the similarity with first and last I'd be more akin to accomunate nth(itr, n) to the their siblings first(itr, n) and last(itr, n) and, I would argue, that first is a bit of an outlier in throwing on an empty collection, since for example,
according to documentation (and code) last never really throws an error:

"Return the end point of an AbstractRange even if it is empty."

the error in last([]) is from getindex that receives a 0 from

lastindex(a::AbstractArray) = (@inline; last(eachindex(IndexLinear(), a))) # equals to last(OneTo(0))

Similarly, both first(itr, n) and last(itr, n) rely on the take(itr, n) iterator which simply returns nothing when finishing the elements of the underlying iterator.

From this my idea that in principle iterators are non throwing by default, any throwing should be done one level higher and not at the iterator level itself (like how getindex and last interact). Iteration is a "low level" interface and I believe it should give the user the choice on how to handle "end states" of the iteration.

@davidanthoff
Copy link
Contributor

We could also opt for relying on the IteratorEltype trait

I have to admit, I think that is the option I like least of all of the proposed options so far :) It would make it very tricky to write generic code that uses the nth function, essentially now I would have to check the trait every time I call nth on something to be able to correctly interpret the return value from nth.

I'd be more akin to accomunate nth(itr, n) to the their siblings first(itr, n) and last(itr, n)

To me nth(itr, n) is conceptually way closer to first(itr) and last(itr) because all of these produce single values, rather than streams of values. Whenever a function produces a stream of values there is a simple, natural way to return no value: namely an empty stream. But that is exactly not possible for functions that are supposed to return just one value.

From this my idea that in principle iterators are non throwing by default

Agreed, but the whole difference between first(itr) and first(itr, n) is the one does not produce an iterator, while the other one does.

I still think that my proposal with an argument like nothrow would be the cleanest solution here :), are there things that you think are problematic about it?

@mcabbott
Copy link
Contributor

mcabbott commented Nov 18, 2024

Is there any precedent for a nothrow keyword?

We could also follow get(A, key, default), as you suggested earlier. It seems [edit: in this case!] a little confusing that this goes by enumeration not indexing, so maybe it shouldn't be called Iterators.get, but is there a good suggestive name? getnth(iter, number, default) or getcount or something? Somehow using nth(iter, n, default) seems a bit at odds with first, last, at least to me.

@ghyatzo
Copy link
Contributor Author

ghyatzo commented Nov 19, 2024

It would make it very tricky to write generic code that uses the nth function, essentially now I would have to check the trait every time I call nth on something to be able to correctly interpret the return value from nth.

I think it's already hard to write generic code that covers both generic collections and Union{Nothing, Some{T}} collections.
Most likely you'd need a specialized function to handle the Somes anyway.
So my reasoning was that since handling of collections of Union{Nothing, Some{T}} already requires a special context that uses Somes, nth in that context would just behave like you'd expect.
But everywhere else, is always the same.

Whenever a function produces a stream of values there is a simple, natural way to return no value: namely an empty stream. But that is exactly not possible for functions that are supposed to return just one value.

Agreed, but the whole difference between first(itr) and first(itr, n) is the one does not produce an iterator, while the other one does.

first(itr, n) and last(itr, n) are not iterators but just wrappers (like nth), they collect an iterator to produce a single value. The only difference is that this value is a collection of elements instead of just an element.

I still think that my proposal with an argument like nothrow would be the cleanest solution here :), are there things that you think are problematic about it?

Not really, I don't have particularly hard opinions about it. In the original issue I had proposed something similar with nth(itr, n, skip_checks=false). What I am most concerned about is forcing the use of Some.

It seems a little confusing that this goes by enumeration not indexing, so maybe it shouldn't be called Iterators.get, but is there a good suggestive name? getnth(iter, number, default) or getcount or something? Somehow using nth(iter, n, default) seems a bit at odds with first, last, at least to me.

My proposal for nth was an attempt to stay in the "cardinal numbering" semantic sphere like first or last.
Another inspiration was the iter.nth(n) from Rust, and in that case the n was actually used as an index so nth element == iter.nth(n-1) which i found very confusing but I digress.
I can get behind the default argument though!

@ghyatzo
Copy link
Contributor Author

ghyatzo commented Nov 19, 2024

@jakobnissen
Copy link
Member

Having thought about it, I do have some sympathy for the argument of @davidanthoff that it should behave like first and last.

I do see myself wanting to use it in code like:

fourth_field = @something nth(eachsplit(line, '\t'), 4) throw(FormatError(
    lazy"Line $lineno does not contain four tab-separated fields fields"
))

Which would now instead be

fourth_field = first(@something iterate(drop(eachsplit(line, '\t'), 3)) throw(FormatError(
    lazy"Line $lineno does not contain four tab-separated fields fields"
)))

That's certainly doable (especially since, for iterators of unknown length, most of the clever tricks that nth can do doesn't apply, so drop + iterate will do the same thing), but it's slightly less nice. But the consistency with first and last does perhaps weigh more heavy.

@jariji
Copy link
Contributor

jariji commented Nov 21, 2024

What is the semantic difference between this function and getindex or get?

@adienes
Copy link
Member

adienes commented Nov 21, 2024

nth and trynth seems a bit more julian than a nothrow kwarg

matching tryparse and trylock,

@davidanthoff
Copy link
Contributor

nth and trynth seems a bit more julian than a nothrow kwarg

Yes, agreed! Having two distinct functions probably also helps with type stability.

Another naming scheme I thought about is nth_or_nothing, or something along those lines, Rust has that kind of pattern a fair bit. But I think @adienes idea is actually better as there seems to be more precedence in Julia for that.

@aplavin
Copy link
Contributor

aplavin commented Nov 21, 2024

Julia has a bunch of patterns for handling this already, so one has some freedom to choose "consistent with what?" :)
For example, first and last error when there's no element. While findfirst returns nothing, exhibiting the same confusion between "not found" and "found nothing".

@jariji
Copy link
Contributor

jariji commented Nov 21, 2024

I see 4 ways of handling errors in Julia:

  • Union{T,Nothing}: These functions are convenient because they don't require unwrapping T, but imho more trouble than they're worth because in generic library programming you can't safely assume T and Nothing are disjoint, which means libraries have to document complex APIs or risk data corruption.

  • throw(IndexError): This is convenient because it doesn't require any unwrapping and is unambiguous. It's not noexcept, obviously, but that's often fine in many use cases.

  • Option{Ok{T},Err{E}}: With Moshi.jl, Julia has the foundations for proper option types; we just need a package with Option{Ok{T},Err{E}} and some Rust-style functions like and_then, or_else implemented on it. This style requires a bit more code but produces reliable and efficient systems because all branches can be covered with compile-time exhaustive pattern matching.

  • Union{Some{T},Nothing} is just a lightweight Option with less-informative error values.

Personally I'd be happy with Base having both throwing functions and Option-returning functions. I'd prefer to minimize the amount of Union{T,Nothing} functions spreading through the ecosystem because they complicate correct generic programming.

@mcabbott
Copy link
Contributor

mcabbott commented Nov 21, 2024

The 5th option is Union{T,S} where you supply S -- like get. That, 2 (error, like getindex) and 4 (Some/Nothing) are the competitors.

What is the semantic difference between this function and getindex or get?

It takes the iteration count, not the index. (Same on Vector, different on Dict, or OffsetArray.)

While findfirst returns nothing, exhibiting the same confusion between "not found" and "found nothing".

That's one's not as bad, as it's either an index or nothing. (You could make Dict(nothing=>2, missing=>3), but you could also name your team "who", "what" and "I don't know"...)

@jariji
Copy link
Contributor

jariji commented Nov 21, 2024

It takes the iteration count, not the index

I see, thanks.

That's one's not as bad, as it's either an index or nothing. (You could make Dict(nothing=>2, missing=>3), but you could also name your team "who", "what" and "I don't know"...)

The problem with "just assume users do what you expect" is that (1) nobody ever documents what they expect and (2) even documented it increases the complexity of usage. No library function using findfirst ever documents that passing a nothing key is undefined behavior. And if they did, the caller would have to increase the complexity of their code to work around the failure in the special case of nothing keys, instead of just using a simple procedure that always works. Imho this kind of API will increase the overall level of complexity and opportunity for mistakes.

The 5th option is Union{T,S} where you supply S -- like get. That, 2 (error, like getindex) and 4 (Some/Nothing) are the competitors.

get(c,k,d) is a good point. That's a good option where appropriate, though the 2-arg function still needs to do something, unless it's just not provided?

@stevengj
Copy link
Member

Might be nice to accept an AbstractVector{<:Integer}, similar to getindex, so that you could do nth(itr, 5:7) or nth(itr, [3, 17, 245]).

@stevengj
Copy link
Member

But I agree with nth(itr, n) throwing an error for out-of-bounds, and nth(itr, n, default) returning default if n is out-of-bounds. Similar to get.

@jariji
Copy link
Contributor

jariji commented Nov 27, 2024

@stevengj

Might be nice to accept an AbstractVector{<:Integer}, similar to getindex, so that you could do nth(itr, 5:7) or nth(itr, [3, 17, 245]).

Nonscalar indexing getindex(xs, ixs) diverges from modern julia practice of using explicit rather than implicit broadcasting when mapping a function over a collection. Discussed at length in #30845. Personally I'd rather this practice not be extended into new functions.

@mcabbott
Copy link
Contributor

I presume the point of nth(iter, [3, 17, 245]) vs. broadcasting nth.(Ref(iter), [3, 17, 245]) is that it would run the iterator only once.

How well nth fits with the existence of iterators which don't like to be run twice, or are expensive to run through, that seems a bit awkward.

@LilithHafner LilithHafner added triage This should be discussed on a triage call and removed triage This should be discussed on a triage call labels Dec 5, 2024
@ghyatzo
Copy link
Contributor Author

ghyatzo commented May 29, 2025

I've changed the code to be decoupled from first and drop directly, and instead being the "unrolled" version

function _nth(itr, n)
    # unrolled version of `first(drop)`
    n > 0 || throw(ArgumentError("n must be positive"))
    y = iterate(itr)
    for i in 1:n-1
        y === nothing && break
        y = iterate(itr, y[2])
    end
    y === nothing && throw(BoundsError(itr, n))
    y[1]
end

some benefits are:

slightly faster than first(drop(itr, n-1)):

bench from the testset
[ Info: first(drop) / unrolled

[ Info: Int64
3.6 ns / 3.0 ns 	  1.19x 	 n/N = 1//1
[ Info: Base.Generator{UnitRange{Int64}, var"#25#26"}
7.0 ns / 5.5 ns 	  1.28x 	 n/N = 1//1
[ Info: SubArray{Int64, 2, Base.ReshapedArray{Int64, 2, UnitRange{Int64}, Tuple{}}, Tuple{UnitRange{Int64}, UnitRange{Int64}}, false}
2.8 ns / 2.8 ns 	  1.00x 	 n/N = 1//1
[ Info: Pair{Int64, Int64}
4.0 ns / 3.3 ns 	  1.20x 	 n/N = 1//1
[ Info: Cycle{Vector{Int64}}
9208.0 ns / 3.0 ns 	  3026.96x 	 n/N = 1//1
[ Info: Take{Repeated{Float64}}
4.0 ns / 4.3 ns 	  0.92x 	 n/N = 1//1
[ Info: Vector{Int64}
2.1 ns / 2.1 ns 	  0.98x 	 n/N = 1//1
[ Info: Base.Iterators.ProductIterator{Tuple{UnitRange{Int64}, UnitRange{Int64}}}
7.4 ns / 4.9 ns 	  1.50x 	 n/N = 1//1
[ Info: @NamedTuple{a::Int64, b::Int64, c::Int64, d::Int64, e::Int64}
4.6 ns / 4.6 ns 	  1.00x 	 n/N = 1//1
[ Info: NTuple{5, Int64}
4.0 ns / 3.3 ns 	  1.19x 	 n/N = 1//1
[ Info: Char
3.7 ns / 3.0 ns 	  1.21x 	 n/N = 1//1
[ Info: Base.ReshapedArray{Int64, 2, UnitRange{Int64}, Tuple{}}
2.4 ns / 2.4 ns 	  1.00x 	 n/N = 1//1
[ Info: StepRange{Int64, Int64}
3.7 ns / 3.7 ns 	  1.00x 	 n/N = 1//1
[ Info: Base.Pairs{Int64, Int64, LinearIndices{1, Tuple{Base.OneTo{Int64}}}, UnitRange{Int64}}
10.6 ns / 6.8 ns 	  1.56x 	 n/N = 1//1
[ Info: SubArray{Int64, 0, Array{Int64, 0}, Tuple{}, true}
2.1 ns / 2.1 ns 	  1.00x 	 n/N = 1//1
[ Info: Flatten{Take{Repeated{Vector{Int64}}}}
106.5 ns / 3.0 ns 	  35.01x 	 n/N = 1//1
[ Info: Flatten{Tuple{UnitRange{Int64}, UnitRange{Int64}}}
20.7 ns / 17.6 ns 	  1.18x 	 n/N = 1//1
[ Info: Base.Iterators.Zip{Tuple{UnitRange{Int64}, UnitRange{Int64}, UnitRange{Int64}}}
16.1 ns / 6.1 ns 	  2.62x 	 n/N = 1//1
[ Info: String
10.8 ns / 6.1 ns 	  1.76x 	 n/N = 1//1
[ Info: Bool
3.7 ns / 3.1 ns 	  1.19x 	 n/N = 1//1
[ Info: Flatten{Take{Repeated{String}}}
68.8 ns / 11.4 ns 	  6.02x 	 n/N = 1//1
[ Info: Cycle{Tuple{Tuple{}}}
3.0 ns / 3.3 ns 	  0.91x 	 n/N = 1//1
[ Info: Base.Iterators.Filter{typeof(isodd), UnitRange{Int64}}
8.9 ns / 6.5 ns 	  1.38x 	 n/N = 1//1

uniform errors: now everything is a BoundsError instead of the cryptic Collection must be non empty arising from the interplay between first and drop. Which makes sense when calling first directly, but not so much when calling nth([1,2,3,4], 5). Moreover, doesn't need to artificially parrot the behaviour of the base case for the two specialization.

This makes it also consistent with the AbstractArray version which throws a BoundsError (also before)

@ghyatzo ghyatzo marked this pull request as ready for review June 14, 2025 14:37
@LilithHafner LilithHafner requested a review from adienes June 14, 2025 21:33
@adienes
Copy link
Member

adienes commented Jun 15, 2025

I'm sorry to suggest yet another modification as I know this PR has seen a fair bit of back and forth, but I wonder if it wouldn't be a bit cleaner structured something like this

nth(itr, n::Integer) = _nth(IteratorSize(itr), itr, n)
Base.@propagate_inbounds nth(itr::AbstractArray, n::Integer) = itr[begin + n-1]

# infinite cycle
function _nth(::Union{HasShape, HasLength}, itr::Cycle{I}, n::Integer) where {I}
    N = length(itr.xs)
    N == 0 && throw(BoundsError(itr, n))

    # prevents wrap around behaviour and inherit the error handling
    return nth(itr.xs, n > 0 ? mod1(n, N) : n)
end

# Flatten{Take{Repeated{O}}} is the actual type of an Iterators.cycle(iterable::O, m) iterator
function _nth(::Union{HasShape, HasLength}, itr::Flatten{Take{Repeated{O}}}, n::Integer) where {O}
    cycles = itr.it.n
    torepeat = itr.it.xs.x
    k = length(torepeat)
    (n > k*cycles || k == 0) && throw(BoundsError(itr, n))

    # prevent wrap around behaviour and inherit the error handling
    return nth(torepeat, n > 0 ? mod1(n, k) : n)
end

function _nth(::IteratorSize, itr, n::Integer)
    # unrolled version of `first(drop)`
    n > 0 || throw(BoundsError(itr, n))
    y = iterate(itr)
    for _ in 1:n-1
        y === nothing && break
        y = iterate(itr, y[2])
    end
    y === nothing && throw(BoundsError(itr, n))
    y[1]
end

to use dispatch and avoid that isa Union{...} control flow. this I believe would have the additional advantage that the specializations calling nth(torepeat, n > 0 ? mod1(n, k) : n) and nth(itr.xs, n > 0 ? mod1(n, N) : n) can themselves specialize those calls. as-is IIUC an iterator of, for example, nested Cycles would benefit from the specialization at only the first "layer" before hitting the generic fallback.

@ghyatzo
Copy link
Contributor Author

ghyatzo commented Jun 15, 2025

No problem, it's better to get this things right the first time around than afterwards. It's also my fault for not staying on top of it more, but too many things fighting for my free time lately.

I remember trying the holy trait style in one of the first iterations. Don't recall exactly why I moved away from it in the end. I'll try again here since the point you make about nested specializations is a very good one.

@ghyatzo
Copy link
Contributor Author

ghyatzo commented Jun 15, 2025

I've given it a go and it works quite well, witout performance penalties.
I've had to add the furter specializations:

nth(itr::Cycle{I}, n::Integer) where {I} = _nth(IteratorSize(I), itr, n)
nth(itr::Flatten{Take{Repeated{O}}}, n::Integer) where {O} = _nth(IteratorSize(O), itr, n)

since there is the issue that Cycles are ::IsInfinite by default and ended up not calling the specialized versions.

The second specialization for finite cycles also currently returns SizeUnknown due to some missing methods when calculating the IteratorSize of flatten. (and potentially also issues with Take since taking n of infinitely sized objects is also infinite in length, but at the moment Take defaults to HasLength.)

we could really use an IteratorElsize...

@adienes
Copy link
Member

adienes commented Jun 15, 2025

I've had to add the furter specializations:

woops good point

w.r.t. IteratorElsize it's a neat idea but should probably be left to another PR; it's OK imo to leave open some known performance holes as long as the design is clean and makes sense. then if improvements to other parts of the machinery can help patch those holes it can be done independently.

the last point I'll make is that these methods for Cycle are not really correct (and cannot really be made correct) when the inner iterator is a Stateful with HasLength, as the fast path will (intentionally) advance the inner iterator mod1(n, N) times vs the generic iterator advancing the inner iterator n times.

granted, it's a bit exotic to be handed a cycle of a stateful iterator with length, so maybe that's ok.... but just something to keep in mind. it's also an existing bug here #43235 so IMO I'd say it's not PR blocking

ideally, something along the lines of #43388 would merge and then the fast paths would simply check this trait as well.

@ghyatzo
Copy link
Contributor Author

ghyatzo commented Jun 15, 2025

Yeah IteratorElsize should definitely be on its own PR if not even a series of PR to tidy up the current iteration interface.

I was about to say that currently cycles of stateful iterators do not work to begin with, but you raise a valid point nonetheless.
It could be worth to introduce a shortcircuit method for cycles of stateful iterators to jump directly to the generic version that should work if in the future cycles of stateful iterators are fixed... and maybe waiting for #43388?
Or better to just leave this as it is and plug the holes as corks become availble?

@adienes
Copy link
Member

adienes commented Jun 15, 2025

personally I think it's good as-is and I wouldn't add more complexity to paper over bugs caused elsewhere

I would just remove the link in the docstring here https://github.com/JuliaLang/julia/pull/56580/files#r2147386533 (otherwise nth docstring links to itself), and I think a NEWS entry is appropriate since it's new public API.

seeing as

  • the semantics match what have been approved by triage
  • the fast paths have been simplified to only two "obvious" cases (arrays and cycles) and have quite a few supporting benchmarks & tests
  • the only potential issue I could identify are new symptoms of an existing bug, and not new bugs themselves
  • it seems that @LilithHafner has entrusted me to review

I'll tag this to be merged once it passes CI (including the blocking NEWS label)

@adienes adienes added needs news A NEWS entry is required for this change merge me PR is reviewed. Merge when all tests are passing labels Jun 15, 2025
ghyatzo and others added 3 commits June 15, 2025 17:51
Co-authored-by: Andy Dienes <[email protected]>
@adienes adienes removed the needs news A NEWS entry is required for this change label Jun 15, 2025
@ghyatzo
Copy link
Contributor Author

ghyatzo commented Jun 15, 2025

Errors seems unrelated to this PR.

@adienes adienes merged commit 58e20a1 into JuliaLang:master Jun 19, 2025
5 of 7 checks passed
@DilumAluthge DilumAluthge removed the merge me PR is reviewed. Merge when all tests are passing label Jun 22, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature Indicates new feature / enhancement requests iteration Involves iteration or the iteration protocol
Projects
None yet
Development

Successfully merging this pull request may close these issues.