Chp 6: Lists and Tuples

More Data

As I said at the start of the last chapter, we've turned our attention from logic (functions, iteration and selection) to data. The time has now come to dig deep into the list data type. Lists are without a doubt Python's most powerful and versatile way to store multiple items of data. 

What's a List?

A list is an ordered sequence. Of what? Of anything! Ints, floats, strings, Booleans, functions. (Yes, I said functions. You'll see examples in a bit.) Other lists. That can themselves contain lists. Lists truly are the general purpose sequence type.

I don't mean that one list can contain say Booleans and another contain ints, though that's surely true. I mean more! One list can contain Booleans and ints. And floats. And lists. And whatever your want. Other languages impose the restriction that all the elements of a list must be of the same type. Python has no such restriction.

I've had students ask me if Python has a way to automate variable creation. They needed to store away multiple items of data, and they thought they needed a sequence of variables, like perhaps sqrt1, sqrt2, sqrt3, etc. I replied that what they really needed was a list. Put all those values in a list (perhaps called sqrt_list) and then extract elements as necessary.

Also know here at the start that lists are mutable, both in their contents and in their length. We can change what's in a list, and we can lengthen and shorten a list. Expect more on this later.

How Do We Make a List?

How do we create a list? With square brackets. Put objects between them. Separate those objects by commas.

>>> a_list = [1, 1.0, '1', True, [0, False]]

This list has five elements: the int 1, the float 1.0, the string '1', the Boolean True and the list [0, False].

A list can contain one or more objects (which we often call its elements). A list can also contain none. A list that has no elements is the empty list. How do we make it? Don't place anything inside the square brackets:

>>> empty_list = []

Typically we create an empty list when later we wish to perform a test (Is this int a perfect square? Does this string contain only digits?) and then add the objects that pass to the list. We can't add to a list that doesn't exist! 

Length of a List

Every list has a length, and we get that length by means of the built-in len function.

>>> L = [2, 3, 5, 7, 11]

>>> len(L)

5

>>> M = []

>>> len(M)

0

Note that the length of the empty list is 0.

Extract Element by Index

Of course lists are of little use if we can't extract elements from them. We extract by index of element, just as with strings we extract by index of character; and as with strings, indices begin at 0. To extract an element of a list by its index, we place that index in square brackets after an expression whose value is a list. Like this:

>>> a_list = [1, 1.0, '1', True, [0, False]]

>>> a_list[0]

1

>>> a_list[1]

1.0

>>> a_list[2]

'1'

>>> a_list[3]

True

>>> a_list[4]

[0, False]

>>> a_list[5]

Traceback (most recent call last):

  File "<stdin>", line 1, in <module>

IndexError: list index out of range

Be careful! If no element exists at that index, you'll get the dreaded "index out of range" error.

Length of a List and Index of Last Element

In our study of strings, we noted a perhaps unintuitive relation between the length of a string and the index of its final element. The relation was this: the index of the final element is the length minus one; or equivalently, the length of a string is the index of its final element plus one. (Why is this? Why isn't the length equal to the index of the final element? The answer is that, in cs, the index of the first element in a sequence is 0.)

The same is true of lists. The index of the final element is the length of the list minus one.

>>> L = [2, 3, 5, 7, 11]

>>> len(L)

5   

>>> L[len(L)-1] 

11

Ranges and Indices

In the list named "L" above,  the indices are its elements are 0, 1, 2, 3 and 4. The element at index 0 is 2, the element at index 1 is 3, etc.

Note that range(len(L)) thus gives us precisely the indices of its elements, for in this case range(len(L)) is equivalent to range(5) which, as we know, is the list [0, 1, 2, 3, 4].

We'll use such ranges later when we iterate through the elements of a list.

Negative Indices

We can also extract with negative indices. The last element of a list has index -1, the element immediately before has index -2, etc.

>>> a_list = [1, 1.0, '1', True, [0, False]]

>>> a_list[-1]

[0, False]

>>> a_list[-2]

True

Stacked Index Operators

Let's say we had a list of lists. Like this one:

>>> L = [[1], [2, 3], [4, 5, 6]]

This list has three elements, and each of those elements is itself a list. The first has one element, the second two and the third three.

 How could we dig out the integers? Let's say in particular that we wanted to extract the 5. Consider:

>>> L[2]

[4, 5, 6]

 So, we have the list with the 5; and the 5 is the element at index 1 in that list. So we should be able to get the 5 with another index operator after the first.

>>> L[2][1]

5

We took the element at index 1 in the element at index 2 in L.

This shouldn't come as any surprise. Our list L was two-dimensional: L has a length, and each of its elements has a length. Multi-dimensional lists required stacked index operators to dig out the elements in the inner-most lists.

Slices

When we extract by index, we get the object at that index. When we slice a list, we get a sublist. Which is a list.

How do we slice? Just as we did with strings. After an expression whose value is a list, we place square brackets; and in those brackets we place one or more integers separated by colons. The syntax here is list_expression[start:stop:step] . The sublist returned begins at the element with index start. The index of each element thereafter is step above the one before. The sublist ends with the last element whose index is less than stop. 

>>> prf_sqrs = [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]

>>> prf_sqrs[2:9:3]

[9, 36, 81]

Start was 2, stop was 9 and step was 3.  So we began at index 2, went to index 5 and stopped at index 8. 

If we provide only two integers (separated of course by a colon), these are start and stop. Step defaults to 1. So below we have a start of 2, a stop of 9 and a default step of 1.

>>> prf_sqrs[2:9]

[9, 16, 25, 36, 49, 64, 81]

If we provide only the integer after the colon, that's stop, and start and step default to 0 and 1 respectively. So below we have a slice that begins at index 0 and ends at the element with index 8.

>>> prf_sqrs[:9]

[1, 4, 9, 16, 25, 36, 49, 64, 81]

Finally, if we provide only the integer before the colon, that's start. Step of course defaults to 1. But to what does stop default? The index of the last element plus one. So we slice right through to the end of the list.

>>> prf_sqrs[3:]

[16, 25, 36, 49, 64, 81, 100]

Mysterious Slices: [:] and [::-1]

Let's consider a few useful but perhaps mysterious slices. The first is [:]. Let's puzzle out what's meant by prf_sqrs[:]. Well, since we have a colon, it's a slice. What's the start value? Since we have nothing before the colon, start defaults to 0. What's stop? Again we have no value given. So it defaults. To what? The index of the final element plus one. So prf_sqrs[:] is equivalent to prf_sqrt[0:len(prf_sqrs)]. That's just the same list again!

>>> prf_sqrs = [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]

>>> prf_sqrs[:]

[1, 4, 9, 16, 25, 36, 49, 64, 81, 100]

>>> prf_sqrs[0:len(prf_sqrs)]

[1, 4, 9, 16, 25, 36, 49, 64, 81, 100]

(We've answered the question "What does [:] do?" But we don't know why we'd want to do that. It seems pointless. We already have the list. Why make it again? That's a very good question. As we'll find in a later section, [:] does have a purpose. A most important purpose. You'll see.)

The second mysterious slice is [::-1] - colon, colon, negative one. Step always comes after the second colon, so we have a step of -1. What are start and stop? Well, we traverse the whole list. But since step is -1, we traverse from right to left. That's the whole list but backwards! So [::-1] reverses a list.

>>> prf_sqrs[::-1]

[100, 81, 64, 49, 36, 25, 16, 9, 4, 1]

The reason we'd use [:] is far from clear. The reason we'd use [::-1] is obvious.

In

Python  provides us with a handy Boolean valued function that answers the question, "Does this list contain this object". The keyword here is in. The syntax matches English; the in is placed between list and object.

>>> prf_sqrs = [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]

>>> 16 in prf_sqrs

True

>>> 91 in prf_sqrs

False

>>> 16 not in prf_sqrs

False

>>> 91 not in prf_sqrs

True

Lists Are Iterable

Lists are sequences. Sequences are iterable. Thus lists are iterable. How do we iterate through them? The for ... in ... construction of course. Let's first iterate by element.

for elem in a_lst:

    print(elem)

Here we iterate once for each element of a_lst; and each element is printed to the screen on its own line.

We can also iterate by index if we choose.

for i in range(len(a_lst)):

    print(a_lst[i])

Here we see the oh-so-popular choice of loop variable i. You do understand, don't you, why it's chosen? "i" is short for "index". As we saw above, range(len(a_lst)) is precisely the indices of the elements of a_lst. So once again each element of a_lst is printed to the screen.

Of the two code snippets above, the second is clearly more complex. Why iterate by index then? Answer: it gives us greater control of the iteration. If we iterate by element, the loop variable dutifully takes on each element from the list. None are skipped. But if we iterate by index, we can skip if we want. Like this:

prf_sqrs = [1, 4, 9, 16, 25, 36, 49, 64, 81, 100, 121, 144]

for i in range(1, len(prf_sqrs), 2):

    print(prf_sqrs[i])

Here we print only the perfect squares from the list that have an odd index; and this (since indices begin at 0) is the squares of the evens.

Put Lists to Work

We've learned enough about lists that we can now put them to work for us.  Below are some simple tasks solved by means of lists. Please read them carefully.

Our first function takes a list of numbers and returns the greatest. (The function could of course be easily modified to find the least.) Here's a puzzle: Why did I choose L[0] - that is, the value at index 0 in L - as the initial value of max?

def find_max(L):

    # Find the greatest number in list L.

    max = L[0]

    for elem in L:

        if elem > max:

            max = elem

    return max

Our second function takes a list of numbers and computes the average. Here's the length function will be quite useful. Without it, we'd have to count the number of elements in L ourselves.

def find_avg(L):

    # Find the average of the numbers in list L.

    sum = 0

    for elem in L:

        sum += elem

    return sum / len(L)

Our third function takes a list of lists of numbers - like perhaps [[1, 3, 12], [7, -2, 15, -21], [-5, 13]] - and returns the index of the sublist with the greatest average. We'll make use of the find_avg function written immediately above. Note that in this case, since we wish to return an index, we should iterate through the input list by index; that means we use range(len(L)), which gives us the list of indices of elements in L. Note too that we need two variables - one to keep track of the greatest-so-far average, and one to keep track of the index of the sublist with the greatest-so-far average.

 def find_max_avg(L):

    # L is a list of list of numbers.

    # Find the index of the sublist with the greatest average.

    # The initial value of max is the average of the initial element of L.

    max_avg = find_avg(L[0])

    index_max = 0

    for i in range(len(L)):

        curr_avg = find_avg(L[i])

        if curr_avg > max_avg:

            index_max = i

            max_avg = curr_avg

    return index_max

The L[i] in curr_avg = find_avg(L[i]) is itself a list, since L is a list of lists. Indeed it is the sublist of L at index i.

Functions as Objects

In the next section, I'll sneak in a new idea that's not list-related. I think you're ready for it and its immensely useful. What's the idea? Functions are objects too! Let me explain.

We've seen lots of object types: ints, floats, Booleans, strings, lists, etc. Another object type is the function. Copy the little function below into your code editor, run it, and then hop into the console.

def is_odd(n):

    # return True if integer n is odd, False otherwise

    return n % 2 != 0

Here's what I did first in my console:

>>> is_odd(3)

True

>>> is_odd(4) 

False

This is not new of course. I called the function twice, and it returned first True and then False.

But let's examine is_odd. I mean in particular let's ask what type it is.

>>> type(is_odd)

<class 'function'>

In one way this is no surprise at all. Of course it's a function. But in another way this is quite extraordinary. What Python really said when we asked for the type of is_odd is that it's an object in the function class. Yes, an object. Functions take objects as inputs, but they are also themselves objects; and this means that, like any object, we can pass a function to a function as the value of one of its parameters.

What would the function do that got a function as the value of a parameter? Call it of course. Look at this:

def is_odd(n):

    # return True if integer n is odd, False otherwise

    return n % 2 != 0


def has_one(L, func):

    # Take list named "L" and Boolean-value function named "func"

    # return True if func returns True for any element in L,

    # False otherwise.

    for elem in L:

        if func(elem):

            return True

    return False

Focus on the line if func(elem):. That's a name followed immediately by parentheses, and this we know is always a function call. So we treat func as a function, which indeed it will be if we send a function as the value of the parameter func.

Once you've typed is_odd and has_one into the code editor and then run,  go to the console:

>>> has_one([2, 3, 4], is_odd)

True

>>> has_one([2, 6, 4], is_odd) 

False

We call has_one twice, and in each call we sent a list and a function. We got True for the first call, since indeed [2, 3, 4] does contain an odd; and we got False for the second, since [2, 6, 3] contains no odds.

I know this will seem odd. I certainly did to me initially. Just remember: functions are objects too! (We've only barely scratched the surface here. In the Higher Order Functions chapter, we'll go into much more detail.)

Count Odds

We traverse lists so that we might process their elements in some way. Let's have more examples of that. Let's traverse a list of integers and return the number of odds within it.

As always, we should think carefully about the task and break it up into its natural sub-tasks. It seems here that we should have two sub-tasks: determine whether a given integer is odd, and count the number of elements in a list that have a certain property. So let's have two functions . The first will take an integer and return True if that integer is odd, False otherwise. The second will take a list and a Boolean-valued function and count how many elements of the list return True if passed to that function. (The second is another example of a function that takes a function.)

def is_odd(n):

    return n % 2 == 1


def count_instances(lst, func):

    # yes, the second argument will be a function

    # return number of elements of lst for which func returns True

    count = 0

    for elem in lst:

        if func(elem):

            count += 1 # if func returns True for elem, increment count by 1

    return count

This is a pretty little piece of abstraction. Note that we could write other Boolean-valued functions and pass those to count_instances.  We could for instance use count_instances to count the number of perfect squares in a list, or to count the number of strings in a list that consist only of letters.

Let's call count_instances a few times:

>>> ints = [1, 6, 7, 3, 4, 11]

>>> count_instances(ints, is_odd)

4

>>> count_instances([], is_odd)

0

[1, 6, 7, 3, 4, 11] contains 4 odds. The empty list contains none.

Concatenate and Append

We can grow lists; that is, we can add elements too them. Python gives us two ways to do this. The first way is with the + operator. A second is with the append method. Let's look at + first.

>>> a_lst = [1, 2, 3]

>>> a_lst + [4, 5]

[1, 2, 3, 4, 5]

Note that when we extend a list with +, the right operand ([4, 5] above) must be a list. Watch what happens if we forget that (as I have done many times):

>>> a_lst + 4

Traceback (most recent call last):

  File "<stdin>", line 1, in <module>

TypeError: can only concatenate list (not "int") to list

+ concatenates list to list!

Note too that the line of code a_list + [4, 5] didn't change the value of a_lst. Watch:

>>> a_lst = [1, 2, 3]

>>> a_lst + [4, 5]

[1, 2, 3, 4, 5]

>>> a_lst

[1, 2, 3]

So what happened is that the concatenation a_lst + [4,  5] created a new list. It didn't alter the [1, 2, 3] list. We say in this case that no list was mutated.

What if we wanted to update a_lst so that it did contain all of 1 through 5? We'd do this:

>>> a_lst = a_lst + [4, 5]

>>> a_lst

[1, 2, 3, 4, 5]

The syntax here is the same as when we increment a number variable. If n has the value 1, then after n = n + 1, its value is 2.

Now let's consider the append method. It's our second way to grow a list. It, unlike list concatentation, does indeed mutate.

>>> b_lst = [1, 2, 3]

>>> b_lst.append(4)

>>> b_lst

[1, 2, 3, 4]

Note that b_lst is longer than it was to begin. It has been mutated! (More on this in a moment.)

Note as well the syntax here - list name dot append, and then the object to append in parentheses. As I've noted before, a function called in this way is called a "method"; and the object whose name appears before the dot is the first argument of the method. So the append method takes two arguments - a list and an object - and adds the object to the end of the list.

Lists are Mutable

As I said, if we use + to grow a list, both operands must be lists. [1] + [2] yields [1, 2]. [1] + 2 throws an error. append however does not require that we begin with two lists. [1].append(2) works just fine.

Did you pick up on another difference between + and append?  Let's look again.

>>> a_lst = [1, 2, 3]

>>> b_lst = [4]

>>> a_lst + b_lst

[1, 2, 3, 4]

>>> a_lst

[1, 2, 3]

>>> b_lst

[4]

>>> c_lst = [1, 2, 3]

>>> c_lst.append(4)

>>> c_lst

[1, 2, 3, 4]

Remember that the console prints any return value from a function call. This means that + returns a value but append does not! Look at the line after a_lst + [4]. There's no prompt. It's the return value of the call to +. Look now at the line after c_lst.append(4). There is a prompt, so append did not return a value. (Well, actually this isn't quite the truth. Functions that don't specify a return value, like append, do in fact have a default return value. We'll come back to this in a moment.)

Note another difference. The + operator returned a new list but did not change either of its operands; a_lst and b_lst were still [1, 2, 3] and [4] respectively after the concatenation. The append method did not return a value but it did change the list on which it was called; after the call to append, c_lst had the value [1, 2, 3, 4].

Grow With a Purpose

Here's an  typical example of the use of lists to build a sequence of all objects with a given property. Let's compile a list of the first n positive integers that are divisible by all integers in a given list L.  Of course we could wrap this up in a single function. But remember that we wish to make our functions as general as possible. General functions are reusable.

I suggest we split the task into two sub-tasks, each handled by its own function. The first will take a positive integer n and a list of positive integers L, and will return True if n is divisible by every integer in L, False otherwise. The second function will take a function func, a positive integer n and a list of positive integers L, and will return the list of all positive integers less than or equal to n for which func returns True

Think of the first function as the tester function.  Think of the second as the  compiler function.  Compiler takes an int and asks tester if that int passes the test. If so, compiler adds it to the list.

def is_divisible(n, L):

    # positive integer n and list of positive integers L

    # return True if n is divisible by each L,

    # False otherwise

    for divisor in L:

        if n % divisor != 0:

            return False

    return True


def compile_list(func, n, L):

    # return list of all ints from 1 to n for which func(curr_int, L) returns True

    passed = []

    for curr_int in range(1, n + 1):

        if func(curr_int, L):

            passed.append(curr_int)

    return passed

A few test runs:

>>> compile_list(is_divisible, 100, [2, 3, 5])

[30, 60, 90]

>>> compile_list(is_divisible, 300, [2, 3, 5])

[30, 60, 90, 120, 150, 180, 210, 240, 270, 300]

Fruitful Non-Mutators, Fruitless Mutators, Fruitful Mutators

We say that a function is fruitful when it returns a value, fruitless when it does not. We say that a function is a mutator when it changes one of its inputs. So the + operator is a fruitful non-mutator and the append method is a fruitless mutator.

We've discovered then that lists are a mutable data type. We can change a list, and all names of that list will then name a list whose contents have changed. Mind you, those names will name the same list; but they will name a list that has changed. So lists are a bit like human beings. I grow a bit fatter (unfortunately). But I'm the same person I was before, and my name ("Franklin")  refers to the same but now larger person.

List is the first mutable data type we've encountered, but it will not be the last.

List Mutagens

So lists are mutable. A list can remain the list it was (and all names of that list still refer to that list) even though we change its contents.

What are the ways we can mutate a list?  Here are a few common ways. (The list is not complete. Consult Google.)


append

As we've seen, we can extend a list by means of the append method. Syntax: a_list.append(an_object).

>>> a_list = [1, 2, 3]

>>> a_list.append(4)

>>> a_lst

[1, 2, 3, 4]


insert

Syntax: a_list.insert(index, object)

The insert method takes two arguments - an index and an object - and places the object at that index. Example:

>>> b_list = ['a', 'b', 'c', 'e', 'f']

Oopsie! Forgot the 'd'. Let's insert it.

>>> b_list.insert(3, 'd')

>>> b_list

['a', 'b', 'c', 'd', 'e', 'f']

Notice that when we inserted the 'd' at index 3, the elements from index 3 got pushed one to the right. 

We can insert at the end of a list if we wish. Indeed Python is quite kind. If the index we provide exceeds the index of the final element, we'll add to the end of the list.

>>> c_list = ['w', 'x', 'y']

>>> c_list.insert(12, 'z')

>>> c_list

['w', 'x', 'y', 'z']


remove

Syntax: a_list.remove(element)

The remove() method takes a single element as an argument and removes it from the list. If more than one instance of element is found in the list, only the first is removed. If the element doesn't exist, it throws the ValueError: list.remove(x): x not in list exception.

Examples:

>>> d_list = [1, 3, 1, 5, 6]

>>> d_list.remove(1)

>>> d_lst

[3, 1, 5, 6]

>>> d_list.remove(1)

>>> d_list

[3, 5, 6]

>>> d_list.remove(1)

Traceback (most recent call last):

  File "<stdin>", line 1, in <module>

ValueError: list.remove(x): x not in list


del

Syntax: del a_list[index]

The del function deletes the element at a given index from a given list. 

>>> e_list = ['s', 'p', 'a', 'm']

>>> del e_list[1]

>>> e_list

['s', 'a', 'm']


pop

Syntax: a_list.pop() or a_list.pop(index).  Let's have a few examples to begin. We'll discuss them after.

>>> f_list = [1, 2, 3, 4, 5, 6]

>>> f_list.pop()

6

>>> f_list

[1, 2, 3, 4, 5]

>>> f_list.pop(2)

3

>>> f_list

[1, 2, 4, 5]

f_list.pop() returned a value (the 6) and it also mutated the list (it was [1, 2, 3, 4, 5] after). pop is thus a fruitful mutator. Think of it as a hybrid. It returns a list as did +, and it mutates a list as did append.

If we place an integer i inside parentheses after pop, Python removes the element with index i from the list and returns that element.


the bracket operator

We can place square brackets after a list expression that thereby modify a list. I expect you'll be able to make sense of the examples below without my help.

>>> g_list = [1, 2, 3, 4, 5, 6]

>>> g_list[0] = 7

>>> g_list

[7, 2, 3, 4, 5, 6]

>>> g_list[2:5] = [8, 9, 10]

>>> g_list

[7, 2, 8, 9, 10, 6]

>>> g_list[6] = 11

Traceback (most recent call last):

  File "<stdin>", line 1, in <module>

IndexError: list assignment index out of range

Notice that when we use square brackets in this way to mutate a list, we write over elements that were already present.

Fruit, No Fruit and None

So, some list methods bear no fruit but mutate a list instead. append, insert and remove are examples. Others both mutate a list and return a value. pop is our example of this.

But you must understand that those functions which do not specify a return value still return (the rather mysterious object) None. Indeed any function , even those you write, will return None if no return value is specified.

This can lead to mysterious bugs. Watch this:

>>> letters = ['a', 'b', 'c']

>>> letters = letters.append('d')

>>> letters

>>>

I copied this out of my console after I typed letters and then hit enter.  Python didn't print letters! What's up? The problem is on the previous line - letters = letters.append('d')append is a fruitless mutator, and as such its return value is None. Thus None was assigned to letters; and when  an expression evaluates to None, Python's repl prints nothing to the screen. (If you want to see the None, you'd need to type print(letters). Try it. You'll see the None.)

What's the fix? The second line should be simply letters.append('d'). That mutates the list so that it now has a 'd' at the end.

List Aliases

Lists are powerful, but with great power comes great danger. Let me explain.

We alias an object when we give it a second name. Below, the int 12 has been aliased.

>>> twelve = 12

>>> also_twelve = twelve

>>> twelve

12

>>> also_twelve

12

Why would we want to do that? Typically we wouldn't.  Not in that way. But we do inevitably alias when we call a function. Here's a simple function:

def is_odd(n):

    return n % 2 == 1

If we call that function, n becomes a name of the object passed to the function; and if that object had a name before it was passed, it is now aliased.

>>> twelve = 12

>>> is_odd(twelve)

False

Here the object 12 has the name twelve and (once the function is_odd has been called) also the name n.

What's the danger is this? None when an immutable object - an integer perhaps - is aliased. But when a mutable object is aliased ... that indeed can be dangerous. Here's a little function that will seem quite innocuous. It takes a list of integers and counts how many are thodd. (A thodd is an integer that leaves a remainder of 1 when divided by 3.  4 is thodd for example.)

def num_thodds(lst):

    count = 0

    while len(lst) > 0:

        last = lst.pop()

        if last % 3 == 1:

            count += 1

    return count

The idea seems sound. pop off the final element of lst (which we know returns the last element of the list and deletes that element from the list) and add 1 to count if that final element is thodd. 

>>> some_ints = [1, 2, 3, 4, 5, 6, 7]

>>> num_thodds(some_ints)

3

The 3 is right. 1, 4 and 7 are thodds.

The danger here becomes clear if we add a second function that also runs through the list. Let's add a function that counts thodders. (A thodder is a positive integer that leaves a remainder of 2 when divided by 3.)

def num_thodders(lst):

    count = 0

    while len(lst) > 0:

        last = lst.pop()

        if last % 3 == 2:

            count += 1

    return count

(Of course this isn't pretty code. Instead of write two such similar functions, we should abstract out their similarity into a function that counts the number of instances of some arbitrary property. But let's not worry about that here.)

num_thodders does seem to work.

>>> more_ints = [10, 11, 12, 13, 14, 15]

>>> num_thodders(more_ints)

2

Yep, 2 is right. 11 and 14 are thodders.

Now let's fall into a dark pit of despair. (That's hyperbolic no doubt, but it does capture how I've felt more than once in the past.)

>>> bigger_ints = [100, 101, 102, 103, 104, 105]

>>> num_thodds(bigger_ints)

2

>>> num_thodders(bigger_ints)

0

0! That's not right! 101 and 104 are thodders, so the count should be 2. What happened to num_thodders? It worked before, but now it doesn't. How in the world did it break?

The answer is that it didn't break. It did return the number of thodders in the list it was sent. But the list it was sent was empty!

>>> bigger_ints = [100, 101, 102, 103, 104, 105]

>>> num_thodds(bigger_ints)

2

>>> bigger_ints

[]

>>> num_thodders(bigger_ints)

0

How did that happen? How did a list that was populated become empty? num_thodds is the culprit here. We sent it the list named bigger_ints. num_thodds then gave that list the alias lst; and when it mutated lst with pop, that mutation was then a mutation in the list named bigger_ints. lst and bigger_ints are names of the same list, and when that list is changed, they 're now both names of a changed list.

The danger here is not simply that an object was aliased.  Instead it was that a mutable object was aliased. When we alias a mutable object and then use one of its names to mutate the object, all names of the object will then name a mutated object.

A Way Around

I'm tempted to tell you to never alias a mutable data type. If you do, you'll inevitably get one of those nasty, hard-to-diagnose bugs described above. But that's extreme. Python is built to alias mutable data types. This always happens when we call a function and send a list.

If however you wished to avoid the automatic creation of an alias when you send a function a list, you can send a copy of the list instead. Remember the easy way to copy a list: list_expression[:]. Watch this (I assume that num_thodds and num_thodders are defined):

>>> bigger_ints = [100, 101, 102, 103, 104, 105]

>>> num_thodds(bigger_ints[:])

2

>>> num_thodders(bigger_ints[:])

2

>>> bigger_ints

[100, 101, 102, 103, 104, 105]

We didn't send bigger_ints to num_thodds or num_thodders. Instead we sent a second, distinct list. A copy list. Thus when we popped, we didn't modify the original list.

Is and ==

Here's a bit of code to read. Play close attention.

>>> bigger_ints = [100, 101, 102, 103, 104, 105]

>>> alias_bigger = bigger_ints

>>> copy_bigger = bigger_ints[:]

>>> bigger_ints

[100, 101, 102, 103, 104, 105]

>>> alias_bigger

[100, 101, 102, 103, 104, 105]

>>> copy_bigger

[100, 101, 102, 103, 104, 105]

>>> alias bigger == bigger_ints

True

>>> copy_bigger == bigger_ints

True

>>> alias_bigger is bigger_ints

True

>>> copy_bigger is bigger_ints

False

You see here a new keyword, the keyword is. What does it mean? It means "is the same object as". == on the other hands means "has the same content as". copy_bigger is a second list that has the same contents as bigger_ints; they have the same elements, but they are two lists. Since they are two lists, copy_bigger is bigger_ints is False; but since they have the same elements, copy_bigger == bigger_ints is True. alias_bigger on the other hand is simply another name of bigger_ints; we have here only one list, named both alias_bigger and bigger_ints. Thus both alias_bigger is bigger_ints and alias_bigger == bigger_ints are True.

Tuples

A tuple is a list-like object. Both are ordered sequences, and we may extract elements from each by use of indices within square brackets. But they are different in two regards:

Here's a little code to read.

>>> empty_tuple = ()

>>> empty_tuple

()

>>> one_element_tuple = 1,

>>> one_element_tuple

(1,)

>>> many_elements = (1, 2, 3)

>>> many_elements

(1, 2, 3)

>>> no_parens = 4, 5, 6

>>> no_parens

(4, 5, 6)

>>> type(no_parens)

<class 'tuple'>

>>> no_parens[1]

5

>>> len(no_parens)

3

>>> no_parens[0] = 7

Traceback (most recent call last):

  File "<stdin>", line 1, in <module>

TypeError: 'tuple' object does not support item assignment

Note:

Why use tuples instead of lists? After all, lists can do everything that tuples can do. And more! I use tuples when I know that I'll never need to change the tuple once created. I figure, Why use a data type with more power, and more danger, than I need?