Lists and Indexes

values = [10, 10, 20,  1,  5]
#indexes:  0,  1,  2,  3,  4
#   also: -5, -4, -3, -2, -1

print("First is", values[0])
print("Third is", values[2])
print("Last is", values[-1])

So far, we have iterated through a list using the for-each syntax to get each value of the list. We have also used indexing to access specific values in a list by their individual position. In many languages, indexes are used to iterate through all the values of a list. The index of the list are the integers—starting from 0 and counting up—that uniquely identify each element. The code shown here demonstrates how we can access indexes using positive and negative integers. In this lesson, we’ll learn how we can use indexes to iterate through the list, modify elements of the list, and do more complex manipulations to the list.

Value Iteration vs. Index Iteration

names = ["Adam", "Betty", "Clarice"]

# Value style
for name in names:
    print(name)

# Index style
for index in range(len(names)):
    print(index, names[index])

The top part of the following code is the usual way to process a list in Python: by iterating through each value. However, you can also iterate by using a combination of the range and len functions as shown in the example here. Both the value style and the index style can print the values, but the index style also gives us the index.

The index style iteration will create a variable named index that stores the index for each value. So, the loop iterations will print 0 Adam, 1 Betty, and 2 Clarice. The index style relies on two helper functions that need further explanation.

Len Function

grocery_list = ['Bread', 'Ham', 'Eggs']

# len function
number_of_groceries = len(grocery_list)
print("Length of list is", number_of_groceries)
print("The length of Bread is", len(grocery_list[0]))

The len function is relatively straightforward; the function consumes an iterable (like a list) and produces an integer representing the length of the list. In other words, the number of elements in the list. The len function also works on strings, producing the number of characters in the string. In fact, the len function does all the work of the basic count pattern, although sometimes we want to extend that pattern in ways that you cannot with the len function (for example, to filter at the same time).

Range Function

numbers = range(3)
for number in numbers:
    print("Number: ", number)

# Range with Map pattern
chapters = []
for number in range(10):
    chapters.append("Chapter " + str(number))
print(chapters)

The range function consumes an integer and produces a sequence of integers from 0 up to that number (but not including that number). One use case is to iterate an explicit number of times, as in the first example, where we iterate exactly three times. A distinct use case for this is to create lists of numbers via the map pattern as shown in the second half of the code. In that example, we iterate 10 times to produce a list of 10 numbers starting from "Chapter 0" to "Chapter 9".

Generators

print(range(5))
# range(0, 5)

print(list(range(5)))
# [0, 1, 2, 3, 4]

It’s important to note that the range function does not technically return a list of integers. Instead, the generator returns a special type that is like a list, called a generator. For the most part, you will not be able to tell the difference between a range generator and a regular list. However, they are slightly different types if you inspect them closely. You can convert a generator into a list using the built-in list function, but this will usually be unnecessary. For now, do not worry about the difference.

Enumerate Function

grocery_list = ['Bread', 'Ham', 'Eggs']

for index, grocery in enumerate(grocery_list):
    print("At index", index, "there is", grocery)

As an alternative to combining range and len, we can also use the enumerate function. The enumerate function allows us to access both the values and indexes at the same time. While this is convenient, we do have a new, unusual feature to learn about. Instead of one iteration variable, we now have two iteration variables to worry about. This only works because the enumerate function returns a list of pairs of things.

Modifying Elements of a List with Indexes

names = ["Adam", "Betty", "Clarice"]

names[0] = "Alphonse"
names[-1] = "Carol"
print(names)

# 1) But indexing out of bounds will NOT work!
#names[3] = "David"
# 2) Nor will modifying a string's characters
#a_name = "Erin"
#a_name[0] = "B"

So far, we have focused on only one way to modify a list, using the append and pop methods to add and remove elements from the end of the list. But, we can also update the elements of a list using assignment statements, like how we can update fields of dataclasses. As this example code demonstrates, we just put the indexed element on the left-hand side of the assignment statement and the appropriate indexes value will be changed. Note also that the first commented-out block of code demonstrates that we cannot use this to add new elements to the list. If you attempt to update an index outside of the size of the list, you’ll get an IndexError. Note also the last example shown in the commented-out code where we try to modify the first character of a string. That does not work and will cause an error because strings are immutable.

Modifying Multiple Elements with Iteration

words = ["Dog", "Cat", "Mouse"]

for index, word in enumerate(words):
    words[index] = word + "s"

print(words)

Up until now, our for loops have always created new lists instead of directly modifying old lists. This is because modifying values makes it difficult to keep track of changes. But, sometimes, we will want to modify every element of our original list. By combining the enumerate function and the index assignment syntax we just learned, we can do so. This code will add the letter s to the end of each word in the original list and then assign to that index in the list.

Why Modify Lists?

original = [1, 2, 3]
copied = []

for index, value in enumerate(original):
    # Copy original
    copied.append(value)
    # Modify original
    original[index] = value + 1

print(original)
print(copied)

The major advantage of modifying an existing list instead of creating a new one with the map pattern is that less memory is required. After all, creating a copy of a list means creating a second list of the same size. If we were dealing with truly massive lists, we might want to avoid this. However, there are serious advantages to not messing up the original list. It makes it much easier to think about other parts of large programs if you know that a list will never be modified by a helper function. Otherwise, you may be surprised to find that a list has been changed out from under you in another part of the program. But, given the tradeoff, how do you know if a list is big enough to justify mutation instead of copying? Usually, you won’t need to know until you run out of memory, so it is not worth worrying about in advance. We rarely mutate lists in Python.

Swapping Elements

words = ["Dog", "Cat", "Mouse"]
print(words)

# Swap two elements
words[0], words[1] = [words[1], words[0]]
print(words)

Sometimes, you will need to swap the position of two elements in a list. To do so, we need to assign directly to multiple indices at once. This is also how we can assign two iteration variables at once when we use the enumerate function. Python always handles the right-hand side of the assignment statement first, so the original values are obtained and put into a new list. Then, those values are assigned one at a time to the two entries on the left-hand side, which are the first and second indexes of the list. By assigning it to the index, we update the value stored at that location.

Unpacking a List

words = ["Dog", "Cat", "Mouse"]
print(words)

# Unpack a list
first, second, third = words
print("Second animal is:", second)

In this code, we assign multiple variables at once. This is called “unpacking” and allows us to turn a list into individual variables. However, this only works if we know that there are exactly three elements in the list. We do not usually know the exact length of a list, so we will not need to take advantage of this syntax very often. Still, sometimes it is convenient to be able to assign names to the values at specific indexes in a list.

Indexes vs. Values

  1. Indexes allow more flexibility
  2. Values require fewer operations
  3. Values are less complex

So, when do you use indexes and when do you use values? Usually, you should stick to value style unless you have an explicit reason to use indexes. Indexes allow more flexibility and are necessary for certain kinds of specialized algorithms. However, they require more operations and are more complex to reason about. When you manipulate indexes, you run the risks of mutable danger, which we discussed a few chapters ago. In general, we prefer to read data and create new variables instead of modifying existing values. That way, we do not have to keep track of whether data has changed later in the program.

Summary