# Iterations

```{block, type="learningObj"}
In this section we'll explore reiteration using iterable objects and iterators
```

We got already pretty far in our Python journey without looking at looping. That's not because it's not necessary or useful, but we needed to get a solid foundation on the basic concepts first. Let's take a look at iterations here.

## Iterators

The most common type of iteration is the `for loop`. Like functions indentation is with 4 spaces, not a tab.

We can iterate over elements in a list:

```{python}

# Display names in a list:
letters = ['A', 'B', 'C']
```

```{python}
for names in letters:
    print(names)
```

Over characters in a string

```{python}
# Characters in a string
for letter in 'Rick':
    print(letter)
```

Or over a sequence of numbers

```{python hello}
# over a sequence of numbers
for i in range(4):
    print(i)
```

This is because list, strings and ranges are all _iterable_ objects, but so are dictionaries and even file connections. All that means is that these objects have an associated `iter()` method.

An _iterator_ is an object that has an associated `next()` method. To create an _iterator_ from _iterable_, use the Builtin `iter()` function:

To summarize:

- An _iterable_ is an object that can return an iterator
- An _iterator_ is an object that keeps state and produces the next value when you call `next()` on it. 

```{python}
word = 'Python'

it = iter(word)

next(it)
next(it)
next(it)
next(it)
#next(it)
```

After the fourth iteration there are no more steps, we get a `StopIteration` error. 

We can print all values of an iterator in one go with the star (aka "splat") operator `*`.

```{python}
word = 'Python'

it = iter(word)

print(*it)
print(*it)
```

But you can only do it once.

Recall that dictionaries are also iterables. The `items()` method unpacks the items in a dictionary into key/value pairs. We loop over two items then, and separate them using a comma.

```{python}
people = {'Rick': 'Scavetta', 'Mary': 'Shelly'}

for key, value in people.items():
    print(key, value)

```

We can also loop over elements in files (not executed):

```{python eval = FALSE}

file = 'open.txt'

it = iter(file)

print(next(it))
print(next(it))
```

We can use the `iter()` function to get an _iterator_, as well as the `next()` function to retrieve the values one by one from the _iterator_.

Some functions can take _iterators_ and _iterables_ as arguments, like `list()` and `sum()`.

```{python}
# Create a range object: values
values = range(10, 21)
```

```{python}
# Print the range object
values
```

```{python}
# Create a list of integers: values_list
values_list = list(values)
```

```{python}
# Print values_list
values_list
```

```{python}
# Get the sum of values: values_sum
values_sum = sum(values)
```

```{python}
# Print values_sum
values_sum
```

### Common functions when working with iterables

Two functions that you'll encounter when using iterables are:

- `enumerate()`, to add a counter to any iterable, and
- `zip()`, to stitch together an arbitrary number of iterables.

For example, to add a counter:

```{python}
names = ['A', 'B', 'C']
e = enumerate(names)
print(type(e))
```

This consists of pairs containing the original values and their index position. To see what that means, use `list()` to convert this to a list of tuples.

```{python}
e_list = list(e)
e_list
```

Actually, the enumerate object is itself an iterable and we can loop over it. Notice how we use two values for looping, like we did with dictionaries.

```{python}

names = ['A', 'B', 'C', 'D', 'E', 'F']

for index, value in enumerate(names):
    print(index, value)

```

We can even control the stating of the index using the start argument:

```{python}

for index, value in enumerate(names, start = 3):
    print(index, value)

```

To summaries, `enumerate()` returns an _enumerate_ object that produces a sequence of tuples, and each of the tuples is an index/value pair.

We've already seen `zip()` in action. This function accepts an arbitrary number of _iterables_ and returns _an iterator of tuples_. For example, with two lists:

```{python}
letter = ['A', 'B', 'C', 'D', 'E', 'F']
names = ['Aardvark', 'Bear', 'Cat', 'Dog', 'Elephant', 'Fox']

z = zip(letter, names)

# type(z) 
## <class 'zip'>
z
```


```{python echo = FALSE, eval = FALSE}

#Alternatively, the splat operator:
#print(*z)

```

Alternatively:

```{python}

for z1, z2 in zip(letter, names):
    print(z1, z2)

```


```{python eval = FALSE, echo = FALSE}

# Example:
mutants = ['charles xavier', 'bobby drake', 'kurt wagner', 'max eisenhardt', 'kitty pryde']
aliases = ['prof x', 'iceman', 'nightcrawler', 'magneto', 'shadowcat', 
powers = ['telepathy', 'thermokinesis', 'teleportation', 'magnetokinesis', 'intangibility']

# Create a list of tuples: mutant_data
mutant_data = list(zip(mutants, aliases, powers))

# Print the list of tuples
print(mutant_data)

# Create a zip object using the three lists: mutant_zip
mutant_zip = zip(mutants, aliases, powers)

# Print the zip object
print(mutant_zip)

# Unpack the zip object and print the tuple values
for value1, value2, value3 in mutant_zip:
    print(value1, value2, value3)
```

```{python eval = FALSE, echo = FALSE}

# `*` unpacks an iterable such as a list or a tuple into positional arguments in a function call.
# We can use `*` in a call to zip() to unpack the tuples produced by zip().
mutants = ['charles xavier', 'bobby drake', 'kurt wagner', 'max eisenhardt', 'kitty pryde']
aliases = ['prof x', 'iceman', 'nightcrawler', 'magneto', 'shadowcat', 
powers = ['telepathy', 'thermokinesis', 'teleportation', 'magnetokinesis', 'intangibility']

# Create a zip object from mutants and powers: z1
z1 = zip(mutants, powers)

# Print the tuples in z1 by unpacking with *
print(*z1)

# Re-create a zip object from mutants and powers: z1
z1 = zip(mutants, powers)

# 'Unzip' the tuples in z1 by unpacking with * and zip(): result1, result2
result1, result2 = zip(*z1)

# Check if unpacked tuples are equivalent to original tuples
print(result1 == mutants)
print(result2 == powers)

```


```{python eval = FALSE, echo = FALSE}

# Reiterating over chunks of large files
# Define count_entries()
def count_entries(csv_file, c_size, colname):
    """Return a dictionary with counts of
    occurrences as value for each key."""
    
    # Initialize an empty dictionary: counts_dict
    counts_dict = {}

    # Iterate over the file chunk by chunk
    for chunk in pd.read_csv(csv_file, chunksize=c_size):

        # Iterate over the column in DataFrame
        for entry in chunk[colname]:
            if entry in counts_dict.keys():
                counts_dict[entry] += 1
            else:
                counts_dict[entry] = 1

    # Return counts_dict
    return counts_dict

# Call count_entries(): result_counts
result_counts = count_entries('tweets.csv', 10, 'lang')

# Print result_counts
print(result_counts)

```


```{exercise}

Get a unique list of values in the group column of plant_growth. Previously we just used the .groupby() to apply an aggregration function to the weights, as an example of doing this manually, loop through the DataFrame and calculate the mean weight according to each group.

```

## Wrap-up

```{block, type="chapRecap"}
In this section we saw how Python uses for loop and iterators to keep track of values. But don't forget the power of broadcasting that we saw with pandas DataFrames.
```