# Indexing and Slicing

Using indexing notation with sequences (such as tuples) is very helpful. You can use indexing to retrieve individual items from sequences, or you can get a subsequence from a starting index to an ending index.

## Notation

### Retrieving individual items

The notation for individual item retrieval is `tup[i]`, where `i` is the value of the index. Remember, Python begins indexing at 0, up until `len(seq) - 1`.

Note: You MUST use `int`s as indices — using something like `float` will raise a `TypeError`.

``````>>> tup = (1, 2, 3, 4, 5)
>>> tup   # indices begin at 0
1
>>> tup   # indices end at len(tup) - 1
5
>>> tup   # improperly large indices raise IndexErrors
IndexError
>>> tup
3``````

Positive indexing gives us the (i-1)th element from the beginning. What if we want to get the ith element from the end? Solution: use negative indices!

``````>>> tup              # using the same tuple as before
(1, 2, 3, 4, 5)
>>> tup[-1]          # get last element
5
>>> tup[-3]          # get the third element from the end
3
>>> tup[-len(tup)]   # improperly large index
IndexError``````

### Subsequences

You can use slicing notation to retrieve subsequences from existing tuples. The resulting subsequence also a tuple.

``````>>> tup              # using the same tuple as before
(1, 2, 3, 4, 5)
>>> tup[1:3]         # starts at index 1 up to but not including index 3
(2, 3)
>>> tup[0:len(tup)]  # one (not so good) way to get the whole tuple
(1, 2, 3, 4, 5)
>>> tup[-4:]         # start: 4th item from end, to the very end
(2, 3, 4, 5)``````

Slicing notation has a nice shorthand notations:

``````>>> tup[:3]    # ommiting the left-hand index starts from the very beginning
(1, 2, 3)
>>> tup[3:]    # ommiting the right-hand index goes to the very end
(3, 4, 5)
>>> tup[:]     # dupliates the entire tuple
(1, 2, 3, 4, 5)``````

When slicing, you can use indices that exceed the length of the tuple (although that's not good style). Python is smart enough to correct the mistake.

``````>>> tup[2:100]     # 100 > len(tup)
(3, 4, 5)
>>> tup[3:2]       # if left - index > right - index, return empty tuple
()
>>> tup[-100:]     # abs(-100) > len(tup)
(1, 2, 3, 4, 5)``````

You can also specify the increment step-size for slicing. The notation is `tup[start:end:step]`.

``````>>> tup[1:4:2]   # subsequence from index 1 up to index 4, but only getting every other item
(2, 4)
>>> tup[0:4:3]   # subsequence from index 0 up to index 4, but only getting every third item
(1, 4)
>>> tup[:4:2]    # subsequence from index 0 up to index 4, but only getting every other item
(1, 3)
>>> tup[1::2]    # subsequence from index 1 up to end, but only getting every other item
(2, 4)
>>> tup[::2]     # subsequence from beginning up to end, but only getting every other item
(1, 3, 5)
>>> tup[::-1]    # get the entire tuple but in reverse.
(5, 4, 3, 2, 1)``````

### Slicing other things

Slicing notation also works on `strings` (and other built-in data structures, like `list`s, but you haven't learned about those yet).

``````>>> 'This works too!'
>>> s        # get the character at index 5
(5, 4, 3, 2, 1)
>>> s[5:10]     # goes up to but not including index 5
'works'
>>> s[:-1]      # everything up till the last character, '!'
'this works too'``````

## Generator expressions

Python has a short hand for generating large iterables in a single line. The syntax for a generator expression is:

``<expression> for <elem> in <iterable> if <boolean>``

This will return a "generator object," which you can then convert to a tuple.

``````>>> tuple(i**2 for i in (1, 2, 3, 4, 5) if i % 2 == 0)
(4, 16)
>>> f = lambda x: x / 2
>>> tuple(f(elem) for elem in (1, 2, 3, 4)) # the if is optional
(0.5, 1.0, 1.5, 2.0)``````

## `map`, `filter`, and other functions

In lecture, you learned about `map`, `filter`, and `reduce`. Here are some extra details about them.

### `map`

In lecture, you saw that `map` can take two arguments: a function; and an iterable (e.g. a tuple). The function is applied to each item in the iterable. `map` returns a "map object," which can be converted to tuples.

``````>>> tuple(map(lambda x: 2*x, (1, 2, 3, 4)))
(2, 4, 6, 8)
>>> tuple(map(lambda x: x**2, (1, 2, 3, 4)))
(1, 4, 9, 16)``````

There is an extended form of `map`, which takes in three or more arguments.

``map(function, iterable1, iterable2, ...)``

`function` will be applied in parallel to all the iterables (all the items, then all the second, then all the third). The `function` must take as many arguments as there are iterables. For example, if there are three iterables, `function` must take three arguments.

``````>>> tuple(map(lambda x, y: x + y, (1, 2, 3, 4), (4, 3, 2, 1)))
(5, 5, 5, 5)    # (1 + 4, 2 + 3, 3 + 2, 4 + 1)
>>> tuple(map(lambda x, y, z: x + y + z, (1, 2), (3, 4), (5, 6)))
(9, 12)``````

Note: if the iterables are not of the same length, map will only go until the shortest iterable runs out.

### `filter`

`filter` just returns a new iterable, whose items are items in the original iterable that passed the `filter` function. The format of a filter call is

``filter(pred, iterable)``

Here are some examples:

``````>>> tuple(filter(lambda x: x % 2 == 0, (1, 2, 3, 4, 5)))
(2, 4)                  # keeps even numbers
>>> tuple(filter(lambda s: len(s) > 3, ('hi', 'hello', 'fooply')))
('hello', 'fooply')     # keep words whose lengths exceed 3``````

### `reduce`

`reduce` takes a iterable and uses a function to "combine" all the items in the iterable. The result is usually an int. The format is

``reduce(function, iterable)``

`function` must take in two arguments. Here are some examples.

``````>>> reduce(add, (1, 2, 3, 4, 5)) # sums numbers 1 through 5
15
>>> reduce(lambda a, b: a*b, (1, 2, 3)) # multiplies numbers 1 through 3
6``````

`reduce` can also take an optional 3rd argument, a starting point.

``````>>> reduce(add, (1, 2, 3, 4, 5), 10) # sums numbers 1 through 5
25``````

### `enumerate`

The `enumerate` function can be applied in a `for` loop to get the index of an item along with the item itself.

``````>>> tup = ('a', 'b', 'c')
>>> for i, item in enumerate(tup):
...     print(i, item)
0 a
1 b
2 c``````

This is useful when you want both the index and the item in a loop (no need to initialize an index variable before the loop).

### `zip`

The `zip` function takes a number of iterables, and pairs together the items in parallel. For example, it pairs together the first items, then the second, then the third items. `zip` returns a 'zip object', which you can convert into a tuple.

``````>>> a = (1, 2, 3, 4)
>>> b = (5, 6, 7, 8):
>>> tuple(zip(a, b))
((1, 5), (2, 6), (3, 7), (4, 8))``````