# ---
# jupyter:
#   jupytext:
#     text_representation:
#       extension: .py
#       format_name: light
#       format_version: '1.5'
#       jupytext_version: 1.14.4
#   kernelspec:
#     display_name: Python 3
#     language: python
#     name: python3
# ---

# # Iterators
#
# Often an important piece of data analysis is repeating a similar calculation, over and over, in an automated fashion. For example, you may have a table of a names that you'd like to split into first and last, or perhaps of dates that you'd like to convert to some standard format. 
#
# One of Python's answers to this is the iterator syntax.
#
# We've seen this already with the range iterator:

for i in range(10):
    print(i, end=' ')

# Here we're going to dig a bit deeper.
# It turns out that in Python 3, range is not a list, but is something called an iterator, and learning how it works is key to understanding a wide class of very useful Python functionality.

# ## Iterating over lists
#
# Iterators are perhaps most easily understood in the concrete case of iterating through a **list**.
#
# Consider the following:

for value in [2, 4, 6, 8, 10]:
    # do some operation
    print(value + 1, end=' ')

# The familiar "for x in y" syntax allows us to repeat some operation for each value in the list.
# The fact that the syntax of the code is so close to its English description ("for [each] value in [the] list") is just one of the syntactic choices that makes Python such an intuitive language to learn and use.
#
# But the face-value behavior is not what's really happening. When you write something like "for val in L", the Python interpreter checks whether it has an iterator interface, which you can check yourself with the built-in iter function:

iter([2, 4, 6, 8, 10])

# It is this iterator object that provides the functionality required by the for loop. The iter object is a container that gives you access to the next object for as long as it's valid, which can be seen with the built-in function next:

I = iter([2, 4, 6, 8, 10])

print(next(I))
print(next(I))
print(next(I))
print(next(I))


# What is the purpose of this level of indirection? Well, it turns out this is incredibly useful, because it allows Python to treat things as lists that are not actually lists.

# # Create an Iterator
#
# To create an object/class as an iterator you have to implement the methods **\__iter__()** and **\__next__()** to your object.
#
# As you have learned in the Python Classes/Objects chapter, all classes have a function called **\__init__()**, which allows you do some initializing when the object is being created.
#
# The **\__iter__()** method acts similar, you can do operations (initializing etc.), but must always return the iterator object itself.
#
# The **\__next__()** method also allows you to do operations, and must return the next item in the sequence.

# +
class MyNumbers:
  def __iter__(self):
    self.a = 1
    return self

  def __next__(self):
    x = self.a
    self.a += 1
    return x

myclass = MyNumbers()
myiter = iter(myclass)

print(next(myiter))
print(next(myiter))
print(next(myiter))
print(next(myiter))
print(next(myiter)) 


# -

# # StopIteration
#
# The example above would continue forever if you had enough next() statements, or if it was used in a for loop.
#
# To prevent the iteration to go on forever, we can use the **StopIteration** statement.
#
# In the \__next__() method, we can add a terminating condition to raise an error if the iteration is done a specified number of times:

# +
class MyNumbers:
  def __iter__(self):
    self.a = 1
    return self

  def __next__(self):
    if self.a <= 20:
      x = self.a
      self.a += 1
      return x
    else:
        raise StopIteration

myclass = MyNumbers()
myiter = iter(myclass)

for x in myiter:
  print(x)
# -

# # Further resources
#
# 1. [Iterator Objects](https://www.w3schools.com/python/python_iterators.asp)
# 2. [W3 Python Iterators](https://www.w3schools.com/python/python_classes.asp)

StarcoderdataJupyterScriptsDedupFiltered