Python Topics : Iterables vs. Iterators
Understanding Iterables
an iterable is any object capable of returning its members one at a time
can be iterated over in a loop
an object is considered iterable if it meets one of the following conditions
  • it implements the __iter__ method which returns an iterator for the object
  • it implements the __getitem__ method enabling index-based access to its elements
common examples of iterables include familiar data structures such as lists, tuples, and dictionaries
dictionaries naturally iterate over their keys
Python provides ways to iterate over values or key-value pairs of the dictionary as well
Decoding Iterators
iterators enable the process of iteration
are objects that track the current position during iteration and proceed to the next element when prompted
to be classified as an iterator an object must implement
  • the __iter__ method which returns the iterator instance itself
    design facilitates the use of iterators in contexts expecting an iterable
  • the __next__ method which moves to the next item in the sequence
    upon exhausting the elements it should signal this by raising a StopIteration exception
standard library offers a range of built-in iterators
including functions range, filter, map, and enumerate
iterators vary in their operation
some generating values on the fly and others applying a function to each item in an iterable
Implementing Iterators and Iterables
custom iterable with __getitem__
all iterable objects have inherent iterator capabilities
when iterating over index-based iterable objects the process is based on the object's length
Dictionaries use their keys iterable for iteration functioning similarly to other index-based iterable objects

a simple iterable object that allows for index-based access to its elements

class NumberIterable:
    def __init__(self, *numbers):
        self.numbers = numbers

    def __getitem__(self, idx: int) -> int:
        if idx < len(self.numbers):
            return self.numbers[idx]
        raise IndexError("list index out of range")

numbers = NumberIterable(1, 2, 3, 4, 5)
for number in numbers:
    print(number)
can also create iterators from iterables by using the iter function
iterator =  iter([1, 2, 3, 4, 5])
print(iterator)
for i in iterator:
    print(i)
building an iterator with __next__
class Range:
    def __init__(self, start: int, end: int, step: int = 1):
        self.start = start
        self.end = end
        self.step = step
        self.current = start

    def __iter__(self):
        return self

    def __next__(self):
        if self.current > self.end:
            raise StopIteration
        current = self.current
        self.current += self.step
        return current


my_range = Range(0, 10)
first = next(my_range)
print(f"first: {first}")
second = next(my_range)
print(f"second: {second}")
for item in my_range:
    print(item)
the Range class mimics built-in range function
generates numbers within a specified range
by implementing the __iter__ and __next__ methods the type can yield the generated numbers
This does not depend on an external iterable, instead generating them from an internal state
the next function can then be used to manually progress through an iterator
allows for the provision of a default value to be returned in case the iterator is depleted
The Power of Generators
generators offer a streamlined way to create iterators
by using the yield keyword, functions are converted into generators
the underlying process is the same
each iteration calls the generator's __next__ method and returns the result

def my_generator(n):
    # initialize counter
    value = 0
    # loop until counter is less than n
    while value < n:
        # produce the current value of the counter
        yield value
        # increment the counter
        value += 1

# iterate over the generator object produced by my_generator
for value in my_generator(3):
    # print each value produced by generator
    print(value)
generators are valuable for their ability to be lazily evaluated
simplifies the creation of iterators and iterables
achieve this by relinquishing control back to their caller upon reaching the yield keyword
effectively pauses execution until prompted to continue
Comprehending Comprehensions
another approach to generating and utilizing iterators and iterables is through the use of comprehension expressions
these expressions have a syntax that closely resembles the objects they generate, with the exception of generators
List Comprehension
even_numbers = [i for i in filter(lambda x: x % 2 == 0, range(10))]
print(even_numbers)
produced an iterable (even_numbers) by linking iterators together
comprehension expressions leverage the capability of iterators to be chained in a clear and readable manner
syntax below produces a tuple
generator_expression = (i for i in filter(lambda x: x % 2 == 0, range(10)))
print(generator_expression)
# unpack the tuple
print(*generator_expression)
index