# Lists

In our first session, we encountered several distinct data **types**: `int` (integers), `float` (decimal point numbers), `complex` (complex numbers), `str` (strings) and `bool` (booleans). All of these types represent various categories of **individual data**: a single number, a single string of letters etc. Often, we are interested in **collections** of data, for example a set of temperatures at which a given experiment has been performed. Python accommodates this requirement by providing us with various **data structures** that allow us to collate related data into a single object. **Lists** are one of these data structures.

## List basics

A **list** is an **ordered** collection of values. The values that comprise a list are usually referred to as the **elements** or **items** in that list. To create a list, we enclose the elements (separated by commas) we wish to include in a set of **square brackets**:

```python
[1, 2, 3, 4, 5]
```

```
[1, 2, 3, 4, 5]
```

Just like the other data types we have encountered so far, we can store lists in **variables** with the **assignment** operator `=`

```python
some_numbers = [1, 2, 3, 4, 5]
print(some_numbers)
print(type(some_numbers))
```
```
[1, 2, 3, 4, 5]
list
```

where see that there is a `list` `type`.

This is an example of a `list` of `int`, but **lists** can contain any type of data

```python
some_strings = ['Welcome', 'to', 'CH12002']
print(some_strings)
```
```
['Welcome', 'to', 'CH12002']
```

We can mix **different types** together

```python
a_variety = [5, 'five', 5.0, 5.0 + 0j]
print(a_variety)
```
```
[5, 'five', 5.0, (5+0j)]
```

and we can even make a **list of lists**!

```python
some_numbers = [1, 2, 3, 4, 5]
some_strings = ['Welcome', 'to', 'CH12002']

list_of_lists = [some_numbers, some_strings]
print(list_of_lists)
```

```
[[1, 2, 3, 4, 5], ['Welcome', 'to', 'CH12002']]
```

We refer to a **list of lists** as a **nested** `list`. 

## List indexing

How can we access a single entry in a list? Consider the following example which lists the names of some first-row transition metals

```python
transition_metals = ['vanadium', 'chromium', 'iron']
```

If we want the first entry in the list we can obtain it using **list indexing** as follows

```python
transition_metals[0]
```
```
'vanadium'
```

To index a list we use square brackets `[]` immediately after the name of the list - no spaces are allowed! Inside the square brackets we put an integer corresponding to the index of the element we want. 
In programming, we refer to the "entries" of a data structure as its **elements**.

In Python, we start counting the **elements** of a data structure from the number zero. The first element of a data structure has index `0`, the second `1`, and so on. This might be a bit confusing, but you'll get used to it.

::: {.callout-note icon="false" title="Syntax"}
The general **syntax** for list indexing is

```python
name_of_list_variable[index]
```

where `index` is an `int` $\ge 0$.
:::

What happens if we try and get the 11th index of `transition_metals`? There are only three elements, so we get an error!

```python
transition_metals[10]
```
```
---------------------------------------------------------------------------
IndexError                                Traceback (most recent call last)
Cell In[11], line 1
----> 1 transition_metals[10]

IndexError: list index out of range
```

::: {.callout-tip icon='false' title='Error'}
Here we have another new type of **error**: an `IndexError`. This one is relatively straightforward, the index we have given is "out of range" and so refers to an element that does not exist. In this case, the index `10` refers to the **eleventh** element of the list, but `transition_metals` only has `3` items, so our code doesn't make any sense!
:::

We can get the length of a `list` using the `len` function

```python
len(transition_metals)
```
3

To finish off this discussion of list indexing, lets see what happens if we use a negative index.

```python
transition_metals[-1]
```
```
'iron'
```

In real life, the "minus first" element doesnt exist, but in Python this takes on a new meaning - the negative sign tells the computer to start **counting backwards from the end of the list**. So an index of `-1` refers to the **last** element, `-2` refers to the second-to-last, and so on.

To index a list of lists we use a new set of brackets `[]` for each list. Look at the following list

```python
opposites = [['proton', 'electron'], ['positive', 'negative'], ['oxidise', 'reduce']]
```

this contains three lists, each with two elements. Say I want to get the first element of the second list - this would be 

```python
opposites[1][0]
```

which tells Python to take the second list (with index `1`) and get its first element (index `0`).

## Modifying lists

### Replacing elements

List indexing can be used to modify the **elements** of a list. This is possible because `list` objects are what we call **mutable** - they can change or **mutate**. Let's replace the third element of `transition_metals` with `manganese`

```python
transition_metals = ['vanadium', 'chromium', 'iron']
print(transition_metals)
transition_metals[2] = 'manganese'
print(transition_metals)
```
```
['vanadium', 'chromium', 'iron']
['vanadium', 'chromium', 'manganese']
```

::: {.callout-tip}
As we've seen, you could use an index of `-1` instead.
:::

### Adding new elements

We can also make lists longer by **appending** values. To do this, we use the `append` **method** of the list. A **method** is a **function** which is attached to a particular object. They're used somewhat similarly, but the **syntax** differs mostly in the order in which each command is written. Let's compare `len` with `append` to see the difference

```python 
transition_metals = [
    'vanadium', 'chromium', 'managanese'
]
len(transition_metals)
transition_metals.append('iron')
print(transition_metals)
```
```
['vanadium', 'chromium', 'managanese', 'iron']
```

We use the method `append` by writing `.append` immediately after the name of the `list`, and we then feed in our inputs to `append` - here we just append the string `'iron'`.

::: {.callout-note icon="false" title="Syntax"}
The general **syntax** for a method is 

```python
name_of_object.name_of_method(input_to_method)
```
:::

As well as appending elements to the end of a list, we can also **insert** elements at **any** given index in the list using the `insert` method

```python
transition_metals.insert(0, 'titanium')
print(transition_metals)
```

```
['titanium', 'vanadium', 'chromium', 'managanese', 'iron']
```

notice that `insert` has two arguments - the first is the index where the new element will be located, and the second is the value to use for that new element. 

Both `insert` and `append` allow us to add single elements, whereas the `extend` method allows us to add mutiple elements to the end of a list


```python
more_transition_metals = ['cobalt', 'nickel']
transition_metals.extend(more_transition_metals)

print(transition_metals)
```
```
['titanium', 'vanadium', 'chromium', 'managanese', 'iron', 'cobalt', 'nickel']
```

where we have providedn a new `list` to stick onto the end of our current `list`.

Finally, now that you know about `append`, `insert`, and `extend`, its important to know that you can create an empty list

```python
empty_list = []
print(empty_list)
```
```
[]
```

and can append values to it

```python
empty_list.append('absolutely_nothing!')
print(empty_list)
```
```
['absolutely_nothing!']
```

### Exercise

That's a lot about lists, but there's more to come!

As a "break", play around with the following operations, just like you did for [strings](../session_1/types.qmd#exercise).

Create a list containing the letters a, b, and c and carry out the following operations

1. Multiply your list by the integer `3`
2. Multiply your list by the float `3.5`
3. Add the integer `5` to your list using the `+` operator
3. Add the list `['d', 'e']` to your list using the `+` operator
4. Subtract the integer `10` from your list using the `-` operator

Some of the above will give an error, but some will give results that look similar to what we've just seen for append and extend.

::: {.callout-tip title="Result" collapse="true" icon="false"}
1. Multiplying a list by an integer `N` makes a new list with `N` copies of the element - in this case `['a', 'b', 'c', 'a', 'b', 'c', 'a', 'b', 'c']`.

2. Multiplying a list by an float gives an error - this operation is not allowed.

3. Adding an integer to a list gives an error - this operation is not allowed.

4. Adding a list to a list using `+` gives the same result as `extend`.

5. Subtracting anything from a list gives an error - this operation is not allowed.
:::

### Removing elements

We now have a range of tools at our disposal for **adding** elements to a list, but how can we **remove** them instead? Well, for a start, there's the `remove` method

```python
transition_metals.remove('cobalt')
print(transition_metals)
```
```
['titanium', 'vanadium', 'chromium', 'managanese', 'iron',  'nickel']
```

Thiss removes the first element which matches the input argument from the list. 

Alternatively, there's the `pop` method, which allows you to instead specify the **index** of the element you want to remove.

```python
pop_example = transition_metals.pop(5)
print(pop_example)
print(transition_metals)
```
```
'cobalt'
['titanium', 'vanadium', 'chromium', 'managanese', 'iron',  'nickel']
```

Notice that the `pop` method also **returns** the value of the removed element. In the example above, we have **assigned** the output of the `pop` method to a variable `pop_example`, which when printed, shows us the removed element.

## List slicing

We are now familiar with accessing individual elements of a list, but what if we want to obtain **multiple** elements at once? Python facilitates this task with a process called **list slicing**.


```python
transition_metals = ['titanium', 'vanadium', 'chromium', 'managanese', 'iron', 'cobalt', 'nickel']
shorter_list = transition_metals[1:5]
print(shorter_list)
```
```
['vanadium', 'chromium', 'manganese', 'iron']
```

We've specified two indices `1` and `5` separated by a colon `:`, and Python has returned a **slice** containing four elements from our the list - the 2nd, 3rd, 4th, and 5th elements. Interestingly, specifying `5` as the second index doesnt result in the 6th element being included in the slice - to explain why take a look at the box below.

::: {.callout-note icon="false" title="Syntax"}
The general **syntax** for slicing is
```python
name_of_list[start_index : end_index]
```

where the element at `end_index` is **not** included in the slice that is returned. Think of this as saying "give me all the elements starting at `start_index` up to and **not** including `end_index`".
:::

In actual fact, we don't have to include **both** the start and end indices - have a go and see what happens using the following example.

```python
transition_metals = ['titanium', 'vanadium', 'chromium', 'managanese', 'iron', 'cobalt', 'nickel']
print(transition_metals[3:])
print(transition_metals[:6])
```

::: {.callout-tip title='Result' collapse="true" icon=false}
In the first case, we specify only a start index `3` - this tells python to give us every element between index `3` to the **end** of the list.

In the second case, we specify only an end index `6` - this tells python to give us every element from the **start** of the list up to, but **not** including, index `6`.
:::

Finally, we can also slice a list in **steps** by providing a third index which tells python the **step size**.

```python
transition_metals = ['titanium', 'vanadium', 'chromium', 'managanese', 'iron', 'cobalt', 'nickel']
transition_metals[1:5:2]
```
```
['vanadium', 'managanese']
```

which gives us every second element from the 2nd up to but **not** including the 6th - i.e. our slice will contain the 2nd and 4th elements.

::: {.callout-note icon="false" title="Test Yourself" collapse="true"}
Use list indexing with steps to return the every fourth element of the following list.

```python
d_block = ['Scandium', 'Titanium', 'Vanadium', 'Chromium', 'Manganese', 'Iron', 'Cobalt', 'Nickel', 'Copper', 'Zinc', 'Yttrium', 'Zirconium', 'Niobium', 'Molybdenum', 'Technetium', 'Ruthenium', 'Rhodium', 'Palladium', 'Silver', 'Cadmium']
```

::: {.callout-tip title="Answer" collapse="true" icon="false"}
```python
print(d_block[::4])
```
:::
:::

## Exercise

Use your knowledge of lists to solve the following problem

Copy the following block of code into a Jupyter notebook:

```python
colours = [['Red', 'Magenta'], 
           ['Cyan', 'Green'], 
           [['Yellow', 'Blue'], 'White']]
```

a) Use **list indexing** to print `Magenta` from the `colours` list.

b) Again, using **list indexing**, assign the element `Red` to a variable called `colour_1`, the element `Green` to a variable called `colour_2`, and the element `Yellow` to a variable called `colour_3`, so that the following line of code runs without error and displays a true statement:

```python
print(f'{colour_1} + {colour_2} -> {colour_3}')
```

::: {.callout-tip collapse="true"}
The solutions are **not** to just type out 

```python
print('Magenta')
print(f'Red + Green -> Yellow')
```

you need to use **list indexing**!
:::