# Loops ## `for` loops The first **control flow** statement we'll look at is called a `for` **loop**. Run the following code and see what happens ```python example_list = ['a', 'b', 'c', 'd', 'e'] for each_lettter in example_list: print(each_lettter) ``` ::: {.callout-tip title='Result' collapse="true"} You should see that the **elements** of the **list** are printed, one by one. ::: The above `for` **loop** *loops* over every **element** of the list and does something `for` each one - in our case it prints a value the screen. ::: {.callout-note icon="false" title="Syntax"} The general syntax of a `for` **loop** is ```python for item in iterable: do_something_with_each_item do_something_else and_so_on... ``` where the code within the loop is **indented** by one tab (or four spaces), just like it was for **functions**. An **iterable** is a Python object that can be iterated over, i.e. it contains a sequence of items that can be accessed one after another. So far in this course, the **iterables** we've seen are `dict`, `list`, `tuple`, and `str`. ::: ::: {.callout-note icon="false" title='Test Yourself' collapse="true"} Use a `for` loop to print the wavelengths of the first ten lines in the Lyman series of the hydrogen atomic emission spectrum. Use the Rydberg equation as implemented in the function below, noting that $n_f=1$ for the Lyman series. ```python from scipy import constants def calc_rydberg_wavelength(n_i, n_f): ''' Calculates wavelength of hydrogen emission signal corresponding to a transition between two energy levels with quantum numbers n_i and n_f using the Rydberg equation. λ = (R (1/n_f^2 - 1/n_i^2)) Arguments --------- n_i: int Quantum number of intial state (n_i>n_f) n_f: int Quantum number of final state (n_i>n_f) Returns ------- float Wavelength of transition in nm ''' iwavelength = constants.Rydberg * (1/n_f**2 - 1/n_i**2) wavelength = 1 / iwavelength wavelength *= 10**9 return wavelength ``` ::: {.callout-tip title='Result' collapse="true" icon="false"} Only the last block of code needs to change, i.e. ```python print('Lyman series of Hydrogen') n_f = 1 n_i_values = [2, 3, 4, 5, 6, 7, 8, 9, 10] for n_i in n_i_values: wl = calc_rydberg_wavelength(n_i, n_f) print(f'n={n_i:d}-->n={n_f:d} λ={wl:.2f} nm') ``` ::: ::: ## Iterations As we **loop over** the elements of an interable, it might be useful to keep a track of how many times the loop has run. We call this the **number of iterations**. The simplest way would be to use a variable that keeps track of the iterations ```python some_numbers = (5, 10, 15, 20, 25) iteration_count = 1 for number in some_numbers: print(f'This is iteration #{iteration_count}') print(number) iteration_count = iteration_count + 1 ``` ``` This is iteration #1 5 This is iteration #2 10 This is iteration #3 15 This is iteration #4 20 This is iteration #5 25 ``` We start with a variable `iteration_count=1`, and every time the loop runs this variable is printed, and then its value is increased or **incremented** by `1`. This means that `iteration_count` then tracks which iteration the loop is currently on. This works, but it's messy. Luckily, Python gives us a built-in function which does this for us - `enumerate`. ```python some_numbers = (5, 10, 15, 20, 25) for iteration_count, number in enumerate(some_numbers): print(f'This is iteration #{iteration_count:d}') print(number) ``` ``` This is iteration #0 5 This is iteration #1 10 This is iteration #2 15 This is iteration #3 20 This is iteration #4 25 ``` This gives us almost exactly the same result as before, but with one key difference - here the iterations are counted starting from **zero**, just like when indexing a `list` or `tuple`. ::: {.callout-note icon="false" title="Test Yourself" collapse="true"} Modify the `print` call in the above `for` loop so that the iteration counter starts at `1` instead of `0` ::: {.callout-tip title="Answer" collapse="true" icon="false"} ```python print(f'This is iteration #{iteration_count+1:d}') ``` ::: ::: The `enumerate` function pairs each value of a given iterable with an index and provides a way to keep track of the iteration count. ::: {.callout-note icon="false" title="Syntax"} The `enumerate` function can be used in a loop as follows ``` for counter, item in enumerate(iterable): do_something_with_each_item_or_counter do_something_else and_so_on... ``` where `counter` indicates the iteration number of the loop, and `item` is an element of `iterable`. ::: ## Ranges Sometimes we want to loop over a simple sequence of integers. We could do this, as in our previous examples, like so ```python integers = [0, 1, 2, 3, 4] for integer in integers: print(integer) ``` ``` 0 1 2 3 4 ``` On the other hand, as is probably becoming familiar to you at this point, Python provides us with a shortcut via a built-in function. This time, we are interested in the `range` function ```python for integer in range(0, 5): print(integer) ``` ``` 0 1 2 3 4 ``` Which gives us exactly the same result, with one less line of code. The range function takes two **arguments**, an integer from which to start the sequence, and an integer at which to terminate the sequence. Note that, much like **slicing** lists, the latter is **not included** in the final sequence (here we supply `5` as the second argument, but the sequence terminates at `4`). We can actually get away with shortening our code slightly more ```python for integer in range(5): print(integer) ``` ``` 0 1 2 3 4 ``` If the `range` function is only given one input, then Python assumes that you want the sequence to start from **zero** (again much like slicing lists). On the other end of the spectrum, we can also provide three arguments to `range` ```python for integer in range(0, 5, 2): print(integer) ``` ``` 0 2 4 ``` This third argument, once more just like list slicing, acts as a **step size**. Here we set it to `2`, so we get only the even-numbered integers. We can use a **negative** step size to loop through numbers in **reverse order** (from bigger to smaller) ```python for integer in range(5, 0, -1): print(integer) ``` ``` 5 4 3 2 1 ``` ## Multiple iterables How might we write a `for` loop to calculate the element-wise product of the following two lists? ```python evens = [1, 3, 5, 7, 9] odds = [0, 2, 4, 6, 8] ``` Python provides us with a function that takes in two or more iterables and returns the corresponding number of values in each iteration of a loop. This function is called `zip`, and can be used to solve the above problem. ```python for even, odd in zip(evens, odds): print(even * odd) ``` ``` 0 6 20 42 72 ``` ::: {.callout-note icon="false" title="Syntax"} The `zip` function can be used in a loop as follows ```python for value_1, value_2, ... in enumerate(iterable_1, iterable_2, ...): do_something_with_each_item_or_counter do_something_else and_so_on... ``` where `value_1` is an element of `iterable_1` and so on. ::: ## Nested loops Just as we saw that we can have **nested** lists (e.g. a `list` in a `list`) we can also have **nested loops**. Run the following code and see what happens ```python list_1 = [1, 2, 3] list_2 = ['a', 'b', 'c'] for item_i in list_1: for item_j in list_2: print(item_i, item_j) ``` ::: {.callout-tip title="Result" collapse="true" icon="false"} You should see that the elements of `list_1` and `list_2` are printed with a very particular order. ``` 1 a 1 b 1 c 2 a 2 b 2 c 3 a 3 b 3 c ``` ::: To understand what's going on inside of these loops, let's use enumerate and an additional print statement. ```python list_1 = [1, 2, 3] list_2 = ['a', 'b', 'c'] for iteration_count, item_i in enumerate(list_1): print(f'Outer loop iteration #{iteration_count}') for item_j in list_2: print(item_i, item_j) ``` ``` Outer loop iteration #0 1 a 1 b 1 c Outer loop iteration #1 2 a 2 b 2 c Outer loop iteration #2 3 a 3 b 3 c ``` We're now printing every time there is an iteration of the **outer loop**. So the **outer loop** iterates three times, and inside each three lines of data are printed. Let's add another `enumerate` function call and `print` statement, but this time to the inner loop. ```python list_1 = [1, 2, 3] list_2 = ['a', 'b', 'c'] for iteration_count_i, item_i in enumerate(list_1): print(f'Outer loop iteration #{iteration_count_i}') for iteration_count_j, item_j in enumerate(list_2): print(f'Inner loop iteration #{iteration_count_j}') print(item_i, item_j) ``` ``` Outer loop iteration #0 Inner loop iteration #0 1 a Inner loop iteration #1 1 b Inner loop iteration #2 1 c Outer loop iteration #1 Inner loop iteration #0 2 a Inner loop iteration #1 2 b Inner loop iteration #2 2 c Outer loop iteration #2 Inner loop iteration #0 3 a Inner loop iteration #1 3 b Inner loop iteration #2 3 c ``` This might look a little bit confusing, but breaking it down step by step we see that 1. The **outer loop** starts, printing its iteration number and values from `list_1`. 2. Then the **inner loop** runs three times, each time printing its iteration number and values from `list_2`. 3. The **outer loop** iterates again, and so on. For every iteration of the **outer loop**, the **inner loop** iterates through the entirety of `list_2`. So the **inner loop** does all of its iterations before the **outer loop** iterates again. ## Exercises **1a.** Using a `for` loop, generate and `print` the first 20 square numbers. ::: {.callout-tip} You can generate integers from 1 to 20 with the `range` function. ::: **1b.** Modify your loop to keep track of the current iteration and print this along with each square number. **2.** The Fibonacci sequence is defined by the *recursion* relation $$F_{n} = F_{n - 1} + F_{n - 2},$$ i.e. each term is simply the sum of the previous two terms. Starting with the following list: ```python fib = [0, 1] ``` **2a.** Using list indexing, calculate the next term in the sequence and append this to `fib`. ::: {.callout-tip} Remember that you can use **negative** indices to access elements in a list in **reverse order**. ::: **2b.** Write a loop to calculate the next 10 terms in the sequence and add these to `fib`.