Skip to Content
Course content

113: Custom Iterator Implementations Revisited

Click on the "Edit" button in the top corner of the screen to edit your slide content.

A few years ago, I was reviewing a PR from a junior dev who was building a custom circular buffer for a high-throughput telemetry system. He had the basic Iterator trait implemented, and it worked fine for simple loops. But the moment he tried to use .rev() or .collect::>().reverse(), he realized his iterator only moved forward. When he tried to implement DoubleEndedIterator, he hit a wall: he couldn't figure out how to track the "tail" of the buffer relative to the "head" while maintaining the borrowing rules. He spent two days fighting the borrow checker because he was trying to store indices that he thought were constants, but were actually dynamic offsets.

If you've already implemented a basic iterator, you know the drill: define a struct, implement the Iterator trait, and write the next method. But in production-grade Rust, we rarely need just a simple forward-only stream. We often need iterators that can be consumed from both ends, iterators that don't "restart" unexpectedly, or iterators that tie their lifetime strictly to the collection they are traversing. This is where the "simple" implementation starts to leak.

Tying the Iterator to the Data's Lifetime

The most common stumbling block I see is the relationship between the iterator's lifetime and the data it yields. If your iterator returns references to data owned by a collection, your iterator struct must carry a lifetime parameter. You can't just return &T; you have to return &'a T, where 'a is the lifetime of the source collection.

Take a look at this implementation for a CircularBuffer. Notice how the iterator doesn't own the data; it holds a reference to the buffer. This ensures that the buffer cannot be dropped or mutated while the iterator is still alive.

struct CircularBuffer<T> {
    data: Vec<T>,
    head: usize,
    size: usize,
}

struct CircularBufferIter<'a, T> {
    buffer: &'a CircularBuffer<T>,
    current: usize,
    processed: usize,
}

impl<'a, T> Iterator for CircularBufferIter<'a, T> {
    type Item = &'a T;

    fn next(&mut self) -> Option<Self::Item> {
        if self.processed == self.buffer.size {
            return None;
        }

        // Calculate the actual index in the underlying Vec
        let idx = (self.buffer.head + self.current) % self.buffer.data.len();
        let item = &self.buffer.data[idx];
        
        self.current += 1;
        self.processed += 1;
        Some(item)
    }
}

I always tell people to pay close attention to type Item = &'a T;. If you omit that 'a, the compiler will scream at you because it has no way of knowing how long that reference is valid. You're explicitly telling Rust: "This item lives as long as the buffer I'm iterating over."

Bidirectional Traversal with DoubleEndedIterator

Now, let's address what my colleague struggled with. To make an iterator work with .rev(), you need the DoubleEndedIterator trait. This requires implementing next_back(). The tricky part is that next_back() must consume elements from the opposite end of the iteration, and it must eventually meet next() in the middle.

In a circular buffer, the "back" is the element immediately preceding the head (wrapping around the end of the vector). To implement this, we need to track how many elements we've consumed from both the front and the back to avoid overlapping.

impl<'a, T> DoubleEndedIterator for CircularBufferIter<'a, T> {
    fn next_back(&mut self) -> Option<Self::Item> {
        if self.processed == self.buffer.size {
            return None;
        }

        // The back is the element just before the current 'tail'
        // Tail is (head + size) % len, so we go back one from there.
        let tail_idx = (self.buffer.head + self.buffer.size + self.buffer.data.len() - 1 
                       - self.current) % self.buffer.data.len();
        
        // Note: This is a simplified mental model. In a real impl, 
        // you'd need to track 'back_processed' separately from 'current'.
        let item = &self.buffer.data[tail_idx];
        
        self.processed += 1;
        Some(item)
    }
}

It's easy to get the math wrong here. The key is remembering that next() and next_back() are effectively eating away at the same pool of available elements. If you don't decrement a shared counter or track the boundaries carefully, you'll end up yielding the same element twice or panicking on an out-of-bounds index. I usually recommend sketching the indices on a piece of paper before coding the modular arithmetic; it saves a lot of debugging time.




📋 Practical Task

Implement a Bidirectional Sliding Window Iterator

Your task is to create a custom iterator called WindowIter that yields slices of a fixed size from a provided slice, but supports bidirectional iteration.

Requirements:

  • Create a struct WindowIter<'a, T> that holds a reference to a slice &'a [T] and a window_size: usize.
  • Implement the Iterator trait where Item` is &'a [T]. next() should return the next sliding window (e.g., for a slice [1, 2, 3, 4] and size 2, it should yield [1, 2], then [2, 3], then [3, 4]).
  • Implement the DoubleEndedIterator trait. next_back() should return the windows starting from the end of the slice and moving backwards (e.g., [3, 4], then [2, 3], then [1, 2]).
  • Ensure that next() and next_back() do not overlap—once an element is yielded by either method, it cannot be yielded again.

Test Case:

let data = [1, 2, 3, 4, 5];
let mut iter = WindowIter::new(&data, 3);
assert_eq!(iter.next(), Some(&data[0..3]));
assert_eq!(iter.next_back(), Some(&data[2..5]));
assert_eq!(iter.next(), Some(&data[1..4]));
assert_eq!(iter.next(), None);
Rating
0 0

There are no comments for now.

to be the first to leave a comment.