B3. Lists and tuples


In this tutorial, we will explore two important data types in Python, lists and tuples. They are both sequences of objects. Just like a string is a sequence (that is, an ordered collection) of characters, lists and tuples are sequences of arbitrary objects, called items or elements. They are a way to make a single object that contains many other objects. We will start our discussion with lists.

Lists

As usual, it is easiest to explore new topics by example. We’ll start by creating a list.

List creation

We create lists by putting Python values or expressions inside square brackets, separated by commas. For example:

[1]:
my_list_1 = [1, 2, 3, 4]
type(my_list_1)
[1]:
list

We observe here that although the elements of the list are ints, the type of the list is list. Actually, any Python expression can be inside a list (including another list!):

[2]:
my_list_2 = [1, 2.4, 'a string', ['a string in another list', 5]]
my_list_2
[2]:
[1, 2.4, 'a string', ['a string in another list', 5]]
[3]:
my_list_3 = [2+3, 5*3, 4**2]
my_list_3
[3]:
[5, 15, 16]

my_list_2 contains ints, a float, a string and another list. And our third list contains expressions that get evaluated when the list as a whole gets created.

We can also create a list by type conversion. For example, we can convert a string into a list of characters.

[4]:
my_str = 'A string.'
list(my_str)
[4]:
['A', ' ', 's', 't', 'r', 'i', 'n', 'g', '.']

List operators

Operators on lists behave much like operators on strings. The + operator on lists means list concatenation.

[5]:
[1, 2, 3] + [4, 5, 6]
[5]:
[1, 2, 3, 4, 5, 6]

The * operator on lists means list replication and concatenation.

[6]:
[1, 2, 3] * 3
[6]:
[1, 2, 3, 1, 2, 3, 1, 2, 3]

Membership operators

Membership operators are used to determine if an item is in a list. The two membership operators are:

English

operator

is a member of

in

is not a member of

not in

The result of the operator is True or False. Let’s look at my_list_2 again:

[7]:
my_list_2 = [1, 2.4, 'a string', ['a string in another list', 5]]
1 in my_list_2
[7]:
True
[8]:
['a string in another list', 5] in my_list_2
[8]:
True
[9]:
'a string in another list' in my_list_2
[9]:
False
[10]:
7 not in my_list_2
[10]:
True

Importantly, we see that the string 'a string in another list' is not in my_list_2. This is because that string itself is not one of the four items of my_list_2. The string 'a string in another list' is in a list that is an item in my_list_2.

Now, these membership operators offer a great convenience for conditionals. Remember our example about stop codons?

[11]:
codon = 'UGG'

if codon == 'AUG':
    print('This codon is the start codon.')
elif codon == 'UAA' or codon == 'UAG' or codon == 'UGA':
    print('This codon is a stop codon.')
else:
    print('This codon is neither a start nor stop codon.')
This codon is neither a start nor stop codon.

We can rewrite this much more cleanly, and with a lower chance of bugs, using a list and the in operator.

[12]:
# Make a list of stop codons
stop_codons = ['UAA', 'UAG', 'UGA']

# Specify codon
codon = 'UGG'

# Check to see if it is a start or stop codon
if codon == 'AUG':
    print('This codon is the start codon.')
elif codon in stop_codons:
    print('This codon is a stop codon.')
else:
    print('This codon is neither a start nor stop codon.')
This codon is neither a start nor stop codon.

The simple expression

codon in stop_codons

replaced the more verbose

codon == 'UAA' or codon == 'UAG' or codon == 'UGA'

Much nicer!

List indexing

Imagine that we would like to access an item in a list. Because a list is ordered, we can ask for the first item, the second item, the nth item, the last item, etc. This is done using a bracket notation. We first write the name of our list and then enclosed in square brackets we write the location (index) of the desired element:

[13]:
my_list = [1, 2.4, 'a string', ['a string in another list', 5]]

my_list[1]
[13]:
2.4

Wait a minute! Shouldn’t my_list[1] give the first item in the list? It seems to give the second. This is because indexing in Python starts at zero. This is very important.

Indexing in Python starts at zero.

Now that we know that, let’s look at the items in the list.

[14]:
print(my_list[0])
print(my_list[1])
print(my_list[2])
print(my_list[3])
1
2.4
a string
['a string in another list', 5]

We can also index the list that is within my_list by adding another set of brackets.

[15]:
my_list[3][0]
[15]:
'a string in another list'

So, now we have the basics of list indexing. There are more ways to specify items in a list. We will look at some of these now, but in order to do it, it helps to have a simpler list. We will therefore create a list that goes from zero to ten.

[16]:
my_list = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

my_list[4]
[16]:
4

We already knew that would be the result. We can use negative indexing as well! This just means we start indexing from the last entry, starting at -1.

[17]:
my_list[-1]
[17]:
10
[18]:
my_list[-3]
[18]:
8

This is very convenient for indexing in reverse. Now make it more clear, here are the forward and backward indices for the list:

Values

0

1

2

3

4

5

6

7

8

9

10

Forward indices

0

1

2

3

4

5

6

7

8

9

10

Reverse indices

-11

-10

-9

-8

-7

-6

-5

-4

-3

-2

-1

List slicing

Now, what if we want to pull out multiple items in a list, called slicing? We can use colons (:) for that.

[19]:
my_list[0:5]
[19]:
[0, 1, 2, 3, 4]

We got elements 0 through 4. When using the colon indexing, my_list[i:j], we get items i through j-1. I.e., the range is inclusive of the first index and exclusive of the last. If the slice’s final index is larger than the length of the sequence, the slice ends at the last element.

[20]:
my_list[3:1000]
[20]:
[3, 4, 5, 6, 7, 8, 9, 10]

Now, we can also use negative indices with colons.

[21]:
my_list[0:-3]
[21]:
[0, 1, 2, 3, 4, 5, 6, 7]

Again, note that we only went to index -4.

We can also specify a stride. The stride comes after a second colon. For example, if we only wanted the even numbers, we could do the following.

[22]:
my_list[0::2]
[22]:
[0, 2, 4, 6, 8, 10]

Notice that we did not enter anything for the end value of the slice. If the end is left blank, the default is to include the entire string. Similarly, we can leave out the start index, as its default is zero.

[23]:
my_list[::2]
[23]:
[0, 2, 4, 6, 8, 10]

So, in general, the indexing scheme is:

my_list[start:end:stride]
  • If there are no colons, a single element is returned.

  • If there are any colons, we are slicing the list, and a list is returned.

  • If there is one colon, stride is assumed to be 1.

  • If start is not specified, it is assumed to be zero.

  • If end is not specified, the interpreted assumed you want the entire list.

  • If stride is not specified, it is assumed to be 1.

With this in hand, we do lots of crazy slicing. We can even use a negative stride, which results in reversing the list.

[24]:
my_list[::-1]
[24]:
[10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0]

Note that the meaning of the “start” and “end” index is a bit ambiguous when you have a negative stride. When the stride is negative, we still slice from start to end, but then reverse the order.

Now, let’s look at a few examples (inspired by Brett Slatkin).

[25]:
print(my_list[2::2])
print(my_list[2:-1:2])
print(my_list[-2::-2])
print(my_list[-2:2:-2])
print(my_list[2:2:-2])
[2, 4, 6, 8, 10]
[2, 4, 6, 8]
[9, 7, 5, 3, 1]
[9, 7, 5, 3]
[]

You can see that it takes a lot of thought to understand what the slices actually are. So, here is some good advice: Do not use start, end, and slice all at the same time (even though you can). Do the stride first and then the slice, on separate lines. For example, if we wanted just the even numbers, but not the first and last (this was the my_list[2:-1:2] example we just did), we would do

[26]:
# Extract evens
evens = my_list[::2]

# Cut off end values
evens_without_end_values = evens[1:-1]

evens_without_end_values
[26]:
[2, 4, 6, 8]

This is more verbose, but much easier to read and understand.

Mutability

Lists are mutable. That means that you can change their values without creating a new list. (You cannot change the data type or identity.) Let’s see this by example.

[27]:
my_list = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
my_list[3] = 'four'

my_list
[27]:
[1, 2, 3, 'four', 5, 6, 7, 8, 9, 10]

The other data types we have encountered so far, ints, floats, and strs, are immutable. You cannot change their values without reassigning them. To see this, we’ll use the id() function, which tells us where in memory that the variable is stored. (Note: this identity is unique to the Python interpreter, and should not be considered an actual physical address in memory.)

[28]:
a = 689
print(id(a))

a = 690
print(id(a))
140549740486288
140549740486160

So, we see that the identity of a, an integer, changed when we tried to change its value. So, we didn’t actually change its value; we made a new variable. With lists, though, this is not the case.

[29]:
print(id(my_list))

my_list[0] = 'zero'
print(id(my_list))
140549741826752
140549741826752

It is still the same list! This is very important to consider when we do assignments.

Pitfall: Aliasing

Aliasing is a subtle issue which can come up when assigning lists to variables. Let’s look at an example. We will make a list, then assign a new variable to the list (which we will momentarily erroneously think of as making a copy of the list) and then change a value of an entry in the “copied” list.

[30]:
my_list = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
my_list_2 = my_list     # copy of my_list?
my_list_2[0] = 'a'

my_list_2
[30]:
['a', 2, 3, 4, 5, 6, 7, 8, 9, 10]

Now, let’s look at our original list to see what it looks like.

[31]:
my_list
[31]:
['a', 2, 3, 4, 5, 6, 7, 8, 9, 10]

So we see that assigning a list to a variable does not copy the list! Instead, you just get a new reference to the same value. This has the real potential to introduce a nasty bug that will bite you!

There is a way we can avoid this problem by using list slices. If both the slice’s starting index and the slice’s ending index of a list are left out, the slice is a copy of the entire list in a new hunk of memory.

[32]:
my_list = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
my_list_2 = my_list[:]
my_list_2[0] = 'a'

my_list_2
[32]:
['a', 2, 3, 4, 5, 6, 7, 8, 9, 10]
[33]:
my_list
[33]:
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

Another option is to use a data type that is very much like a list, except it is immutable.

Tuples

A tuple is just like a list, except it is immutable (basically a read-only list). (That statement is a bit explosive, as described inthis blog post. Tuples do have many other capabilities beyond what you would expect from just bring “a read-only list,” but for now, we can think of it that way.) A tuple is created just like a list, except we use parentheses instead of brackets. The only watch-out is that a tuple with a single item needs to include a comma after the item.

[34]:
my_tuple = (0,)

not_a_tuple = (0) # this is just the number 0 (normal use of parantheses)

type(my_tuple), type(not_a_tuple)
[34]:
(tuple, int)

We can also create a tuple by doing a type conversion. We can convert our list to a tuple.

[35]:
my_list = [1, 2.4, 'a string', ['a sting in another list', 5]]

my_tuple = tuple(my_list)

my_tuple
[35]:
(1, 2.4, 'a string', ['a sting in another list', 5])

Note that the list within my_list did not get converted to a tuple. It is still a list, and it is mutable.

[36]:
my_tuple[3][0] = 'a string in a list in a tuple'

my_tuple
[36]:
(1, 2.4, 'a string', ['a string in a list in a tuple', 5])

However, if we try to change an item in a tuple, we get an error.

[37]:
my_tuple[1] = 7
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[37], line 1
----> 1 my_tuple[1] = 7

TypeError: 'tuple' object does not support item assignment

Even though the list within the tuple is mutable, we still cannot change the identity of that list.

[38]:
my_tuple[3] = ['a', 'new', 'list']
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[38], line 1
----> 1 my_tuple[3] = ['a', 'new', 'list']

TypeError: 'tuple' object does not support item assignment

Slicing of tuples

Slicing of tuples is the same as lists, except a tuple is returned from the slicing operation, not a list.

[39]:
my_tuple = (0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10)

# Reverse
my_tuple[::-1]
[39]:
(10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0)
[40]:
# Odd numbers
my_tuple[1::2]
[40]:
(1, 3, 5, 7, 9)

The + operator with tuples

As with lists we can concatenate tuples with the + operator.

[41]:
my_tuple + (11, 12, 13, 14, 15)
[41]:
(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15)

Membership operators with tuples

Membership operators work the same as with lists.

[42]:
5 in my_tuple
[42]:
True
[43]:
'LeBron James' not in my_tuple
[43]:
True

Tuple unpacking

It is like a multiple assignment statement that is best seen through example.

[44]:
my_tuple = (1, 2, 3)
(a, b, c) = my_tuple

a
[44]:
1
[45]:
b
[45]:
2
[46]:
c
[46]:
3

This is useful when we want to return more than one value from a function and further using the values as stored in different variables.

Note that the parentheses are dispensable.

[47]:
a, b, c = my_tuple

print(a, b, c)
1 2 3

Wisdom on tuples and lists

At face, tuples and lists are very similar, differing essentially only in mutability. The differences are actually more profound, as described in the aforementioned blog post. We will make extensive use of them in our programs.

“When should I use a tuple and when should I use a list?” you ask. Here is my advice.

Always use tuples instead of lists unless you need mutability.

This keeps you out of trouble. It is very easy to inadvertently change one list, and then another list (that is actually the same, but with a different variable name) gets mangled. That said, mutability is often very useful, so you can use it to make your list and adjust it as you need. However, after you have finalized your list, you should convert it to a tuple so it cannot get mangled. We’ll come back to this later.

Computing environment

[48]:
%load_ext watermark
%watermark -v -p jupyterlab
Python implementation: CPython
Python version       : 3.10.9
IPython version      : 8.10.0

jupyterlab: 3.5.3