Skip to Content
Course content

114: DoubleEndedIterator and ExactSizeIterator

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

I've noticed a recurring pattern when I review code from developers moving into Rust: they treat .rev() as if it's a magic method built into the Iterator trait itself. They'll write a custom iterator to handle some complex data traversal, try to call .rev() on it to process the items in reverse, and then get frustrated when the compiler tells them the method doesn't exist.

Stop assuming .rev() is universal

Here is the reality: the Iterator trait only knows how to move forward. If you want to move backward, you need the DoubleEndedIterator trait. The .rev() method is actually provided by a blanket implementation for anything that implements DoubleEndedIterator.

Look at this snippet. I've built a simple LogBuffer that iterates over a slice of strings. If I only implement Iterator, I'm stuck moving in one direction.

struct LogBuffer<'a> {
    logs: &'a [String],
    index: usize,
}

impl<'a> Iterator for LogBuffer<'a> {
    type Item = &'a String;

    fn next(&mut self) -> Option<Self::Item> {
        if self.index < self.logs.len() {
            let res = &self.logs[self.index];
            self.index += 1;
            Some(res)
        } else {
            None
        }
    }
}

fn main() {
    let logs = vec!["Error 1".to_string(), "Error 2".to_string()];
    let mut iter = LogBuffer { logs: &logs, index: 0 };
    
    // This would fail to compile:
    // let reversed = iter.rev(); 
}

To fix this, we don't change next(); we add next_back(). This tells Rust, "I know how to pop elements from the tail just as easily as I can from the head."

impl<'a> DoubleEndedIterator for LogBuffer<'a> {
    fn next_back(&mut self) -> Option<Self::Item> {
        if self.index < self.logs.len() {
            self.index -= 1; // This is a simplification; real logic needs bounds checking
            // In a real scenario, you'd track a 'back' index separately
            // but for the sake of the point:
            Some(&self.logs[self.index])
        } else {
            None
        }
    }
}

I'll be honest: implementing DoubleEndedIterator manually can be a bit of a brain-bender because you have to carefully manage your indices so next() and next_back() don't "cross" and return the same element twice. But once you do, .rev() just starts working.

Stop treating size_hint as a promise

The second misconception is about ExactSizeIterator. Many learners think that because Iterator has a size_hint() method, the iterator already "knows" its size. It doesn't. size_hint() returns a (lower, upper) bound. For many iterators (like those filtering a stream), the upper bound is None because we have no clue how many items will actually pass the filter.

If you are collecting an iterator into a Vec, Rust uses size_hint() to allocate memory. If the iterator is just a standard Iterator, the Vec might have to re-allocate and copy its contents multiple times as it grows. This is a performance killer in hot loops.

By implementing ExactSizeIterator, you are making a formal contract with the compiler: "I guarantee that lower == upper."

impl<'a> ExactSizeIterator for LogBuffer<'a> {
    // We don't even need to write a method body here!
    // Just by implementing this empty trait, we signal that 
    // our size_hint() is always precise.
}

Now, when you call .collect(), Rust sees the ExactSizeIterator trait, calls size_hint(), and performs exactly one heap allocation for the entire collection. It's a small change that can lead to massive performance gains when dealing with large datasets.




📋 Practical Task

Build a Bidirectional Token Stream

You are building a basic lexer for a custom language. You have a TokenStream that wraps a Vec<Token>. Currently, it can only be processed from start to finish, which makes implementing certain parsing logic (like looking ahead or backtracking) difficult.

Your Task:

  • Implement DoubleEndedIterator for TokenStream so that it can be processed in reverse using .rev().
  • Implement ExactSizeIterator for TokenStream to ensure that collecting the tokens back into a Vec is memory-efficient.
  • Ensure that next() and next_back() do not return the same token; they should meet in the middle and then return None.
#[derive(Debug, PartialEq)]
enum Token {
    Keyword,
    Identifier,
    Literal,
}

struct TokenStream {
    tokens: Vec<Token>,
    front: usize,
    back: usize,
}

impl TokenStream {
    fn new(tokens: Vec<Token>) -> Self {
        let len = tokens.len();
        TokenStream {
            tokens,
            front: 0,
            back: len,
        }
    }
}

impl Iterator for TokenStream {
    type Item = Token;

    fn next(&mut self) -> Option<Self::Item> {
        if self.front < self.back {
            let token = self.tokens[self.front].clone(); // simplified for example
            self.front += 1;
            Some(token)
        } else {
            None
        }
    }

    fn size_hint(&self) -> (usize, Option<usize>) {
        let remaining = self.back.saturating_sub(self.front);
        (remaining, Some(remaining))
    }
}

// TODO: Implement DoubleEndedIterator
// TODO: Implement ExactSizeIterator

fn main() {
    let tokens = vec![Token::Keyword, Token::Identifier, Token::Literal];
    let stream = TokenStream::new(tokens);
    
    // Test your implementation here:
    // 1. Check if .rev() works.
    // 2. Check if collecting into a Vec uses the exact size.
}
Rating
0 0

There are no comments for now.

to be the first to leave a comment.