RFA-191 · Case file with fixtures · Case 163 of 694 · Runtime evidence
Why Iterator::zip Can Consume One Extra Left-Hand Item
A general zip probes its first iterator before its second, so discovering that the right side ended can leave one unmatched left item consumed. Exact-size specializations may avoid this; preserve unknown remainders with an explicit peek-based loop.
- Reviewed
- Rust
- Rust 1.98.1, edition 2024
- Targets
- all targets
- Profiles
- dev, release, test
Direct answer
What this Rust failure means
- Why it happens
- A general Zip probes the first iterator before the second on its terminating next call, although exact-size specializations may avoid that extra pull.
- First discriminating check
- Borrow the first non-exact iterator with by_ref, exhaust zip against a shorter second iterator, then inspect the first iterator's next value.
zip returns pairs until one input ends. This familiar description says how many pairs come out, but it does not fully describe what happened to the two input iterators.
The failing program borrows a three-item iterator with by_ref and zips it with a one-item array. One pair is produced. When I inspect the longer iterator afterward, 20 is already gone and the next value is 30.
The terminal call pulled 20 from the left, then discovered that the right side had no matching item.
Pair production and input consumption are different facts
Iterator::zip must ask both inputs for a value before it can return one pair. For the general forward iterator, it probes the first iterator and then the second.
The last attempt is therefore roughly:
left.next() -> Some(20)
right.next() -> None
zip.next() -> None
There is no general Iterator operation that puts 20 back. Ownership of that item has already moved out of the first input.
This does not change the basic output rule: the number of pairs is the shorter input length. It changes what remains available if code expects to continue using a borrowed input after the zipper ends.
Why a simple array test can show something else
While building this case, I first used two array iterators. Rust 1.98.1 did not lose the extra item in that fixture. Their exact lengths allowed a specialized Zip implementation to stop without the same probe sequence.
That is useful optimization, but it is not a promise I can extend to every iterator. I added filter(|_| true) to the left input. The values remain the same, while the adapter no longer provides an exact length. The general behaviour becomes visible.
This is exactly why the case records the iterator capabilities and toolchain. A test that passes for ExactSizeIterator can hide a bug that returns when the source becomes a parser, channel, filter, or other general stream.
I avoid writing a regression test that demands the extra pull from every implementation. The safe application assumption is that a general zip may consume it.
Put the known shorter input first only when it is truly known
The repaired program puts the one-item input on the left. When it ends, zip sees None before advancing the right-hand values, leaving 20 and 30 available.
This is a small repair when one input is structurally the limiter, for example one value per fixed schema field. It is not a general solution for two streams whose end order is unknown. Reversing them only moves which remainder is at risk.
It also reverses the output tuple, so I map the pair if the original field order matters.
Use peekable inputs when both remainders matter
If I must preserve the first unpaired item from either side, I do not use ordinary zip as the owner of discovery. I make both inputs peekable, check that each has a next value, and only then consume both:
while left.peek().is_some() && right.peek().is_some() {
let pair = (left.next().unwrap(), right.next().unwrap());
process(pair);
}
peek may itself advance the underlying iterator into its private cache, as RFA-158 demonstrates, but the cached value remains available through the Peekable. This preserves it at the adapter level.
For fallible or async sources I use the equivalent explicit state machine. The key is that readiness of both sides is established before ownership of either item is committed to a pair.
Side effects make the lost item more serious
An iterator item is not always a copied integer. Calling next may read a line, update a cursor, acknowledge a record, or run an inspect closure. Even if the unpaired value is discarded, the source-side effect already happened.
I keep irreversible acknowledgment outside iterator adapters when exact recovery matters. A stream processing protocol needs an explicit commit point, not an assumption that no output means no input was consumed.
This is related to take_while, which consumes its first rejected element, but the reason differs. take_while needs an item to test a predicate. zip needs a left item before it can discover the right side ended.
Repeated calls after termination are another boundary
The official documentation explains that further calls after the zipper has no pair may try the first input at most once and then the second at most once. With non-fused iterators, a later Some is possible after a previous None in the underlying source.
Most standard iterators are fused or behave like they are, but I do not build a recovery protocol from repeated calls to an ended zipper. If permanent termination is required, fuse states that requirement directly.
What I record when debugging zip
I record more than the collected pairs:
- Which source is the left input?
- Which iterator ended first?
- Does either source implement
ExactSizeIterator? - What does each source yield immediately after zip ends?
- Does calling
nexthave side effects beyond returning a value? - Must either unpaired remainder be preserved?
- Would a peek-based state machine express the real protocol better?
The core principle is that adapter output does not reveal every input transition. zip can return no pair after already consuming one side. When leftovers have value, I design their ownership explicitly instead of treating the shortest-output rule as a remainder guarantee.