---
jupytext:
  formats: content_md///md:myst,content///ipynb
  text_representation:
    extension: .md
    format_name: myst
    format_version: 0.13
    jupytext_version: 1.13.8
kernelspec:
  display_name: Python 3 (ipykernel)
  language: python
  name: python3
---

(python:lists-friends:iterable-objects)=
# Iterable Objects

Tuples, lists, and dictionaries are examples of iterable objects. These are objects which allow for consecutive evalution of their items. There exist more types of iterable objects in Python and we may define new iterable objects by implementing suitable dunder methods.

## For loops

We briefly mentioned for loops in the [](python:crash). Now we add the details.

### Basic Iteration

For loops allow to iterate through iterable objects of any kind, especially tuples, lists and dictionaries.

```{code-cell} ipython3
colors = ('red', 'green', 'blue', 'yellow')

for color in colors:
    print(color)
```

The indented code below the `for` keyword is executed as long as there are remaining items in the iterable object. In the example above the name `color` first points to `colors[0]`, then, in the second run, to `colors[1]`, and so on.

This index-free iteration is considered *pythonic*: it's much more readable than indexing syntax and directly fulfills the purpose of iteration, while indexing in most cases is useless additional effort.

```{note}
The `range` built-in function we saw in the crash course is not part of the for loop's syntax. Instead, it's a built-in function returning an iterable object which contains a series of integers as specified in the arguments passed to the `range` function. The returned object is of type `range`.

The `range` function takes up to three arguments: the starting index, the stopping index plus 1, and the step size. Step size defaults to 1 if omitted. If only one argument is provided, it's interpreted as stopping index (plus 1) and start index is set to 0.
```

### Iteration With Indices

If next to the items themselve also their index is needed inside the loop, use the built-in function `enumerate` and pass the iterable object to it. The `enumerate` function will return an iterable object which yields a 2-tuple in each iteration. The first item of each tuple is the index, the second one is the corresponding object.

```{code-cell} ipython3
colors = ('red', 'green', 'blue', 'yellow')

for index, color in enumerate(colors):
    print(str(index) + ': ' + color)
```

### Iterating Over Multiple Lists in Parallel

Being new to Python one is tempted to indexing for iteration over multiple lists in parallel:

```{code-cell} ipython3
# non-pythonic!

names = ['John', 'Max', 'Lisa']
surnames = ['Doe', 'Muller', 'Lang']

for i in range(len(names)):
    print(names[i] + ' ' + surnames[i])
```

For iterating over multiple lists at the same time pass them to `zip`. This built-in function returns an iterable objects which yields tuples. The first tuple consists of the first elements of all lists, the second of the second elements of all lists and so on. The returned iterable object has as many items as the shortest list.

```{code-cell} ipython3
names = ['John', 'Max', 'Lisa']
surnames = ['Doe', 'Muller', 'Lang']

for name, surname in zip(names, surnames):
    print(name + ' ' + surname)
```

## Comprehensions

Applying some operation to each item of an iterable object can be done via for loops. But there are handy short-hands known as *list comprehensions* and *dictionary comprehensions*.

### List Comprehensions

General syntax is

```python
[new_item for item in some_list]
```

The following code snipped generates a list of squares from a list of numbers.

```{code-cell} ipython3
some_numbers = [2, 4, 6, 8]

squares = [x * x for x in some_numbers]

print(squares)
```

Like in for loops also multiple lists are possible:

```{code-cell} ipython3
some_numbers = [2, 4, 6, 8]
more_numbers = [1, 2, 3, 4]

products = [x * y for x, y in zip(some_numbers, more_numbers)]

print(products)
```

Nested for loops work, too:

```python
[new_item for item_a in list_a for item_b in list_b]
```

Same principles (zipping and nesting) work for more than two lists, too.

```{note}
List comprehensions have two advantages compared to for loops:
* For small loops a one-liner is more readable than several lines.
* In most cases list comprehensions are faster.
```

### Dictionary Comprehensions

Dict comprehensions look very similar to list comprehensions. General syntax:

```python
{new_key: new_value for some_item in some_iterable}
```

Example modifying values only:

```{code-cell} ipython3
person = {'name': 'John', 'surname': 'Doe', 'eyes': 'brown'}

# enclose all values with star symbols
stars = {key: '*' + value + '*' for key, value in person.items()}

print(stars)
```

Example modifying keys and values of a dictionary:

```{code-cell} ipython3
person = {'name': 'John', 'surname': 'Doe', 'eyes': 'brown'}

# enclose keys and values with star symbols
stars = {'*' + key + '*': '*' + value + '*' for key, value in person.items()}

print(stars)
```

```{note}
Dictionary comprehensions may be used to create new dictionaries from arbitrary iterable objects. For instance we could loop over the `zip`ped lists, one holding the keys and one holding the values.
```

### Conditional Comprehensions

List and dictionary comprehensions can be extended by a condition: simply append `if some_condition` to the comprehension.

```{code-cell} ipython3
some_numbers = [2, 4, 6, 8]
more_numbers = [1, 0, 2, 3]

quotients = [x / y for x, y in zip(some_numbers, more_numbers) if y != 0]

print(quotients)
```

The list (or dictionary) comprehension drops all items not satisfying the condition.

## Manual iteration

Python has a built-in function `next` which allows to iterate through iterable objects step by step. For this purpose we first have to create an iterator object from our iterable object. This is done by the built-in function `iter`. Then the iterator object is passed to `next`. The iterator object takes care about what the next item is.

```{code-cell} ipython3
a = iter([3, 2, 5])

print(next(a))
print(next(a))
print(next(a))
```

Creation of the intermediate iterator object is done automatically if iterable objects are used in for loops and comprehensions.

## The `in` keyword

To test whether an object is contained in an iterable object, we my use the `in` keyword.

```{code-cell} ipython3
a = [1, 5, 6, 8]
print(1 in a)
print(10 in a)
```

```{code-cell} ipython3
a = {'name': 'Jon', 'age': 42}
print('name' in a)
print('Jon' in a)
print('Jon' in a.values())
```

The counterpart to `in` is `not in`.

```{note}
Conditions `a not in b` and `not a in b` are equivalent. The first uses the `not in` operator, while the second uses `in` and then applies the logical operator `not` to the result.
```
