Skip to content

Inconsistent behaviours for last(::Zip) with differing iterator size types #58922

Open
@jakewilliami

Description

@jakewilliami

Request

Currently, last(::Zip{Tuple{UnitRange, Count}}) (for example) is undefined because the latter iterator (Count) has length Base.IsInfinite().

In my opinion, last(::Zip) should be defined so as to obtain the last element of the Zip iterator.


Expected Behaviour

The case where all zipped iterators have finite shapes is well-defined:

julia> last(zip(1:10, 2:11))
(10, 11)

And the short circuiting of the smallest iterator is also well-defined:

julia> last(zip(1:3, 2:11))
(3, 4)

I believe this should also extend to non-finite iterators:

julia> last(zip(1:3, Base.Iterators.countfrom(2)))
(3, 4)

This is trivially equivalent to

last(z::Zip) = last(collect(z))

But this is undefined behaviour:

julia> last(zip(1:3, Base.Iterators.countfrom(2)))
ERROR: MethodError: no method matching lastindex(::Base.Iterators.Count{Int64, Int64})

Draft Patch

Although last(::Zip) may be intentionally undefined behaviour, I think we should at least explicitly error (like we do in reverse(::Zip)). That being said, I seldom have good ideas, so let me know if you disagree. It may also be more convoluted that it's worth.

It may be (currently) undefined because of the implication that

last(z::Zip) = Base.map(last, z.is)

Which is (rightly) undefined for iterators of infinite lengths. However, my proposition is that last(::Zip) takes the last element of the zipped iterators. Trivially, that is

last(z::Zip) = last(collect(z))

As mentioned, the current implementation of last(::Zip) is well-defined for zipped iterators whose lengths are all finite, but it needs to be handled differently for the edge case that they are not all finite.

I've taken the liberty of drafting a function that would solve this. I'm sure there's a more elegant way to write this for someone who is more familiar with the Iterators standard library.

import Base: IteratorSize, IsInfinite, SizeUnknown, _counttuple
import Base.Iterators: Zip, and_iteratorsize, _zip_min_length, take, drop

and_iteratorsize(a, b, tail...) = and_iteratorsize(a, and_iteratorsize(b, tail...))

function last(z::Zip{Is}) where {Is <: Tuple}
    IteratorSize(z) === IsInfinite() &&
        throw(ArgumentError("Cannot get last element of zipped iterators of unknown, infinite, or unequal lengths"))

    tsz = _counttuple(Is)::Int
    szs = ntuple(n -> IteratorSize(fieldtype(Is, n)), tsz)

    # Base case: all iterators in `z` are of finite length
    if and_iteratorsize(szs...) !== SizeUnknown()
        # last(::Zip) implementation as at 3049893a
        return getindex.(z.is, minimum(Base.map(lastindex, z.is)))
    end

    # Edge case: at least one iterator in `z` is of not finite or its size is unknown.
    # In order to get the last element of potentially infinite iterators, the best
    # thing we can do is take n and then drop all but the last element.  This is
    # required because infinite iterators don't typically implement `getindex`.
    n = _zip_min_length(z.is)
    _last(i) = only(drop(take(i, n), n - 1))  # alternatively use `nth` from #56580 
    return Base.map(_last, z.is)
end

System Information

julia> versioninfo()
Julia Version 1.11.5
Commit 760b2e5b739 (2025-04-14 06:53 UTC)
Build Info:
  Official https://julialang.org/ release
Platform Info:
  OS: macOS (arm64-apple-darwin24.0.0)
  CPU: 8 × Apple M2
  WORD_SIZE: 64
  LLVM: libLLVM-16.0.6 (ORCJIT, apple-m2)
Threads: 1 default, 0 interactive, 1 GC (on 4 virtual cores)

Metadata

Metadata

Assignees

No one assigned

    Labels

    iterationInvolves iteration or the iteration protocol

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions