<jupyter_start><jupyter_text>The `iter()` Function As we have seen before, the `iter()` function is used to request an iterator object from an iterable. For example:<jupyter_code>l = [1, 2, 3, 4]
l_iter = iter(l)
type(l_iter)<jupyter_output><empty_output><jupyter_text>And we can use that iterator to iterate the collection by calling `next()` until a `StopIteration` exception is raised.<jupyter_code>next(l_iter)
next(l_iter)<jupyter_output><empty_output><jupyter_text>We also saw how sequence types are also iterable even though they are not actual iterables - they do not have an `__iter__` method, but instead they have a `__getitem__` method. Python had no problem iterating a sequence object - in fact behind the scenes an iterator is built by Python in order to iterate using the `__getitem__` method:<jupyter_code>class Squares:
    def __init__(self, n):
        self._n = n
    
    def __len__(self):
        return self._n
    
    def __getitem__(self, i):
        if i >= self._n:
            raise IndexError
        else:
            return i ** 2
sq = Squares(5)
for i in sq:
    print(i)<jupyter_output>0
1
4
9
16<jupyter_text>But, we can also do this:<jupyter_code>sq_iter = iter(sq)<jupyter_output><empty_output><jupyter_text>And we now have an iterator for `sq`!<jupyter_code>type(sq_iter)
'__next__' in dir(sq_iter)<jupyter_output><empty_output><jupyter_text>What happens is that Python will first try to get the iterator by invoking the `__iter__` method on our object.If it does not have that method, it will look for `__getitem__` next - if it's there it will create an iterator for us that will leverage `__getitem__` and the fact that sequence indices should start at 0.If neither `__iter__` nor `__getitem__` are found, then we'll get an exception such as this one:<jupyter_code>for i in 10:
    print(i)<jupyter_output><empty_output><jupyter_text>Here's how we might build an iterator using the `__getitem__` method ourselves - not that we have to do that since Python does it for us.<jupyter_code>class Squares:
    def __init__(self, n):
        self._n = n
    
    def __len__(self):
        return self._n
    
    def __getitem__(self, i):
        if i >= self._n:
            raise IndexError
        else:
            return i ** 2
class SquaresIterator:
    def __init__(self, squares):
        self._squares = squares
        self._i = 0
        
    def __iter__(self):
        return self
    
    def __next__(self):
        if self._i >= len(self._squares):
            raise StopIteration
        else:
            result = self._squares[self._i]
            self._i += 1
            return result
sq = Squares(5)
sq_iterator = SquaresIterator(sq)
type(sq_iterator)
print(next(sq_iterator))
print(next(sq_iterator))
print(next(sq_iterator))
print(next(sq_iterator))
print(next(sq_iterator))<jupyter_output>0
1
4
9
16<jupyter_text>The iterator is now exhausted, so:<jupyter_code>print(next(sq_iterator))<jupyter_output><empty_output><jupyter_text>Technically, we don't actually need to implement the `__len__` method in our sequence type, but since we are using it in our iterator, we'll have to think of something else - we can leverage the fact that the sequence will raise an IndexError if the index is out of bounds:<jupyter_code>class SquaresIterator:
    def __init__(self, squares):
        self._squares = squares
        self._i = 0
        
    def __iter__(self):
        return self
    
    def __next__(self):
        try:
            result = self._squares[self._i]
            self._i += 1
            return result
        except IndexError:
            raise StopIteration()<jupyter_output><empty_output><jupyter_text>And things will work as before:<jupyter_code>sq_iterator = SquaresIterator(sq)
for i in sq_iterator:
    print(i)<jupyter_output>0
1
4
9
16<jupyter_text>How to test if an object is iterable Basically an object is iterable if it:* implements the **iterable** protocol (`__iter__` that returns an iterator)* implements the **sequence** protocol (`__getitem__`, and `__len__`) - although `__len__` is not required for iteration Given some object, how can we test to see if it is iterable or not? The problem is that we would need to test for both `__iter__` (making sure it returns an iterator), and `__getitem__`. Far easier to do a try/except. For example, just testing that `__iter__` is defined is not sufficient:<jupyter_code>class SimpleIter:
    def __init__(self):
        pass
    
    def __iter__(self):
        return 'Nope'
s = SimpleIter()
'__iter__' in dir(s)<jupyter_output><empty_output><jupyter_text>However, if we call `iter()` on `SimpleIter`, look at what happens:<jupyter_code>iter(s)<jupyter_output><empty_output><jupyter_text>So the best way, if you have some need to detect if something is iterable or not, is the following:<jupyter_code>def is_iterable(obj):
    try:
        iter(obj)
        return True
    except TypeError:
        return False
is_iterable(SimpleIter())
is_iterable(Squares(5))<jupyter_output><empty_output><jupyter_text>That said, we'll cover exception handling in Python later in this course, but there is rarely a need to test if something is iterable, only to then go ahead and iterate over it right after that if it is.Consider the following two alternatives:<jupyter_code>obj = 100
if is_iterable(obj):
    for i in obj:
        print(i)
else:
    print('Error: obj is not iterable')<jupyter_output>Error: obj is not iterable<jupyter_text>vs<jupyter_code>obj = 100
for i in obj:
    print(i)<jupyter_output><empty_output><jupyter_text>As you can see, the error Python itself raises tells us the same thing, and provides even more information!!Instead of guarding for potential errors as we did in the first example, try doing the action you really want to do, and let Python raise the exception for you.If you want to handle the exception, wrap your action inside a try/except: So instead of writing it this way (*ask before you leap*):<jupyter_code>obj = 100
if is_iterable(obj):
    for i in obj:
        print(i)
else:
    print('Error: obj is not iterable')
    print('Taking some action as a consequence of this error')<jupyter_output>Error: obj is not iterable
Taking some action as a consequence of this error<jupyter_text>prefer writing it this way (*ask for forgiveness later*):<jupyter_code>obj = 100
try:
    for i in obj:
        print(i)
except TypeError:
    print('Error: obj is not iterable')
    print('Taking some action as a consequence of this error')<jupyter_output>Error: obj is not iterable
Taking some action as a consequence of this error
{'fraction_non_alphanumeric': 0.08069164265129683, 'fraction_numerical': 0.006977096920976793, 'mean_word_length': 3.8772189349112427, 'pattern_counts': {'":': 0, '<': 63, '<?xml version=': 0, '>': 66, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'chain_length': '17', 'dir': 'jupyter-structured-clean-dedup', 'id': '98517', 'license': 'Apache-2.0', 'n_tokens_mistral': 2099, 'n_tokens_neox': 2018, 'n_words': 766, 'path': 'python-tuts/1-intermediate/02 - Iterables and Iterators/09 - The iter() Function.ipynb', 'repo_name': 'timgates42/Artificial-Intelligence-Deep-Learning-Machine-Learning-Tutorials', 'size': '6593'}