Chapter 2 — Loops, Lists, and Input/Output

2.0. For Loops

In programming languages, a for loop is used to repeatedly execute a sequence of code a specified number of times. In Python, the way this works is that when you type a for loop command, you must also specify some kind of sequence. The for loop uses that sequence to know how many times to loop, executing a number of times that matches the length of the sequence. In the example below, we use the only sequence we’ve focused on so far, strings, as an example.

favorite_food = "pizza"
for letter in favorite_food:
    print(letter)

The syntax of a for loop in Python has four elements:

  1. for
  2. a variable (or set of variables) used to hold each element in the sequence (“letter” in the example above) — this is also called an iterable, and usually (but not always!) takes the form of a collection (e.g., a string, list, set, or dictionary)
  3. in
  4. the sequence being iterated over (the string “pizza” in the example above)

Here’s another example. Imagine we want to convert all the vowels in a string to upper case, without changing the other letters. Think through what we would need to do logically. We’d need to go through each letter in the string one at a time, check to see if it is a vowel, and convert it to upper case if it is. There are many ways one could accomplish this, but if we want to use a for a loop, we can start like this:

hebb_quote = "cells active together will tend to become associated; activity in one facilitates activity in the other."

for letter in hebb_quote:
    pass

Remember that pass is a Python command we can use to tell a block of code to do nothing, and it can be useful to fill it in while we consider what we want to do. So what do we need to do inside the for loop? We need to check each letter to see if it is a vowel. How can we do that?

Recall that we can check whether a character is “in” a string by using the in keyword from Section 1.6. (You may also recall from Section 1.3 that .find() returns the numeric position of a character in a string, or -1 if it isn’t found, but here it would be cleaner to use in instead.) With this knowledge, we can now accomplish our goal:

hebb_quote = "cells active together will tend to become associated; activity in one facilitates activity in the other."
vowels = 'aeiou'

for letter in hebb_quote:
    is_vowel = letter in vowels
    if is_vowel:
        letter = letter.upper()
    print(letter)

In the code above, the first line after the for statement searches for the current letter in the vowels string and sets is_vowel to True if it finds letter in that string; it sets is_vowel to False if it doesn’t find it. Next, we have a simple if statement that sets what is stored in letter to an upper case version of the same letter if it is a vowel, and leaves it alone otherwise. Next, we print out the letter. As written, this would create output that looked like this:

c
E
l
l
s

A
c
t
I
v
E

But what if we want this?

cElls ActIvE tOgEthEr wIll tEnd tO bEcOmE AssOcIAtEd; ActIvIty In OnE fAcIlItAtEs ActIvIty In thE OthEr.

If we first create an empty string (using two quotes with nothing between them, not even a space), then we can do some simple string concatenation inside the loop, adding each letter one at a time to the new string, in either upper or lower case as appropriate:

hebb_quote = "cells active together will tend to become associated; activity in one facilitates activity in the other."
vowels = 'aeiou'
new_hebb_quote = ""  # this is an empty string, since it is just two quotes with nothing between them

for letter in hebb_quote:
    if letter in vowels:
        new_hebb_quote += letter.upper()
    else:
        new_hebb_quote += letter
    print(new_hebb_quote)
Note A more Pythonic approach?

If you are already somewhat familiar with Python, you may be thinking that we could have done this in a more idiomatic (aka Pythonic) way. I promise we will get to that later; for now, we are focusing on the basics.

Exercise: For Loops (with Strings + Conditionals)

For each of the following code blocks, predict what will be printed. Make your predictions before running the code. Compare your predictions to the actual results you observe when you run the code — what, if anything, did you get wrong? Can you trace the loop logic to figure out why?

# A
s = "neuron"
i = 0
for char in s:
    if i % 2 == 0:
        print(char)
    else:
        print(char.upper())
    i += 1


# B
s = "cells, that fire"
n = 0
for char in s:
    if char != " " and char != ",":
        n += 1
print(n)


# C
s = "AaEeIiOoUu!"
t = ""
for char in s:
    if char.lower() in "aeiou":
        t += "*"
    else:
        t += char
print(t)


# D
s = "synapse"
vowels = "aeiou"
t = ""
for char in s:
    if char in vowels:
        t = t + char
    else:
        t = char + t
print(t)


# E
s = "cOgSci"
pos = None
i = 0
for char in s:
    if pos is None and char.isupper():
        pos = i
    i += 1
print(pos)
Exercise Solution — expand only when you’re ready to check your answers
# A
# Output:
# n
# E
# u
# R
# o
# N


# B
# Output:
# 13


# C
# Output:
# **********!


# D
# Output:
# spnysae


# E
# Output:
# 1

2.1. range() and enumerate()

In addition to strings, Python has many other types of sequences that can be used as iterators for loops: these include lists, tuples, dictionaries, and sets. We will cover how to use a for loop to iterate over these sequences when we learn about those data types.

Note On Iterators (Advanced/Optional)

You can think of an iterator as a collection of values that you can traverse one at a time. More concretely, an iterator is an object that implements the __iter__() and __next__() methods. The __iter__() method returns the iterator object itself, and the __next__() method returns the next value in the sequence, raising a StopIteration exception when the sequence is exhausted.

For now, there are two other kinds of sequences we want to introduce.

The range() Function

The range() function gives you an object that is a sequence of numbers in a given range.

x = range(5)
y = len(x)
print(x)  # result is range(0, 5), which can also be thought of as the sequence: 0,1,2,3,4
print(y)  # result would be 5, the length of the sequence

The code above creates a range object and stores it in the variable x. If you print x, you will see that it contains two pieces of information: the number that starts the sequence and the number that ends the sequence. This end number can be a bit confusing at first. It is not the last number in the sequence, but the number after the last number. A range object’s ending number works just like the number we used to end a string slice in the previous section. The final number is the stopping point, but is not actually one of the numbers in the sequence. This means that range(5) is actually the sequence (0, 1, 2, 3, 4) — notice that 5 is not in there! One way to think about it is that the number entered into the range function is the length of the sequence (5), and it starts at 0, so it must end at 4. You can verify the size of the sequence in range() data type by using len() as was done above.

The range() function is useful if you want to specify a loop to run a specific number of times. The loop below sums all of the numbers from 1 to x. So if we want to sum the numbers 1 to 5, then we need the loop to run 5 times. So we just need a range object that is 5 elements long.

# code that sums all the numbers from 1 to x, in this case x = 5, and so the sum = 1+2+3+4+5 = 15
x = 5
the_sum = 0
for i in range(x):
    the_sum += i + 1
print(the_sum)  # the result would be 15

When a range object is created, it actually takes three parameters (pieces of information the function uses to create the object):

  1. the starting point of the sequence (which defaults to 0 if it is not specified)
  2. the stopping point of the sequence (the number we entered and which must be entered)
  3. the step size (or increment size, which defaults to 1 if it is not specified)

So when we typed range(5), that was actually the same as if we had typed range(0, 5, 1). It’s just that the 0 and 1 are the default. But if, for some reason, you want to specify a more complex range with different starting points, or different step sizes, you can. Say you wanted to sum all odd numbers from 1 to 21 (so 1, 3, 5, etc.):

the_sum = 0
for i in range(1, 22, 2): # go from 1 to 22 (but not including 22) in steps of 2
    the_sum += i
print(the_sum)  # the result is 1+3+5+7+9+11+13+15+17+19+21=132
Tip Tip

Summing a sequence of numbers The above example shows how to sum a sequence of numbers from first principles using a for loop. However, in practice you should actually use the built-in sum() function instead, like so: sum(range(1, 22, 2)) This is more concise, declarative, and computationally efficient.

The enumerate() Function

If we use a for loop to iterate over a string, we get each element in the string one at a time. If we iterate using the range() function, we get a counter variable at each iteration, and we could use that as an index to access a sequence like a list. This gives us two different ways we can iterate through a sequence and also have a index variable that keeps track of where we are in the sequence:

favorite_food = "pizza"

index = 0
for letter in favorite_food:
    print(index, letter)
    index += 1

for index in range(len(favorite_food)):
    print(index, favorite_food[index])

Both of these produce the same output:

0 p
1 i
2 z
3 z
4 a

Both also, however, require us to do some extra work. In the first example, we have to create an index variable and increment it each pass through the list. In the second example, the loop does this for us, but then we have to use the loop’s index variable to access the letter in the string. These both work fine, and this pattern is common in many programming languages. However, Python gives us an automatic way to do both at the same time: the enumerate() function. enumerate() must be given a sequence just like the range() function, but it automatically gives us an object that contains both the individual elements of the sequence and the index.

favorite_food = "pizza"
for index, letter in enumerate(favorite_food):
    print(index, letter)

Exercise: range() and enumerate()

For each of the following code blocks, predict what will be printed. Make your predictions before running the code. Compare your predictions to the actual results you observe when you run the code — what, if anything, did you get wrong? Can you trace the logic of the code to figure out why?

# A
n = 5
total = 0
for i in range(n):
    if i % 2 == 0:
        total += i
    else:
        total -= i
print(total)


# B
for i in range(2, 10, 3):
    print(i)


# C
for i in range(7, 0, -2):
    print(i)


# D
s = "pizza"
for i, char in enumerate(s):
    if i < 2:
        print(char.upper())
    else:
        print(i, char)


# E
s = "cognitive"
out = ""
for i in range(len(s)):
    if i % 3 == 0:
        out += s[i].upper()
    else:
        out += s[i]
print(out)
Exercise Solution — expand only when you’re ready to check your answers
# A
# range(5) produces: 0, 1, 2, 3, 4
# total = +0 -1 +2 -3 +4 = 2
# Output:
# 2


# B
# Start at 2, add 3 each time, stop before 10:
# 2, 5, 8
# Output:
# 2
# 5
# 8


# C
# Start at 7, subtract 2 each time, stop once we'd go to 0 or below (0 is not included):
# 7, 5, 3, 1
# Output:
# 7
# 5
# 3
# 1


# D
# enumerate(s) produces pairs (index, character):
# (0,'p') (1,'i') (2,'z') (3,'z') (4,'a')
# the first two characters do not have their indices printed
# and are shown in uppercase because i < 2
# Output:
# P
# I
# 2 z
# 3 z
# 4 a


# E
# Indices 0, 3, and 6 are uppercased (because i % 3 == 0)
# Output:
# CogNitIve

2.2. While Loops

Loops are blocks of code that iterate for a specified number of times (the length of the iterator object that is specified in the for loop). In contrast, a while loop is a loop that iterates continuously while certain conditions are true. The example below is a while loop that will iterate until x reaches a certain value.

x = 0

while x < 5:
    print(x)
    x += 1

In the example above, there is not really a difference between a while loop and a for loop. The while loop is necessarily going to iterate 5 times, and so a for loop that iterates over a range of 5 (or a collection of 5 elements) would do the same thing. But there are many cases where we want code to continue doing something until a condition is reached — something other than just iterating over the whole sequence. Most of the obvious examples involve lists, which we will go over in the next section. But we can give a simple example with a string. Imagine we want to go through a sequence of numbers but stop when their sum reaches a certain value:

number_sequence_string = "161272346154"
sequence_sum = 0
stop_value = 30
i = 0

while sequence_sum < stop_value:
    sequence_sum += int(number_sequence_string[i])
    i += 1
    print(i, sequence_sum)

The result of the code above is:

1 1
2 7
3 8
4 10
5 17
6 19
7 22
8 26
9 32

Now, you might spot a potential problem with the logic above. What happens if the sum never gets over 30? What if, for example, the sequence was “0000010100”. In this case, I would eventually reach 10 and try to access location 10 in the string, but there is no location 10 (the string’s length is 10, so the last spot is 9). This would cause the program to throw an error. But you could protect against that problem with a slightly more complex while statement:

number_sequence_string = "0000010100"
sequence_sum = 0
stop_value = 30
i = 0
sequence_length = len(number_sequence_string)

while (sequence_sum < stop_value) and (i < sequence_length - 1):
    print(i, sequence_sum, sequence_sum < stop_value, i < sequence_length - 1)
    sequence_sum += int(number_sequence_string[i])
    i += 1

With the output:

0 0 True True
1 0 True True
2 0 True True
3 0 True True
4 0 True True
5 0 True True
6 1 True True
7 1 True True
8 2 True True

This time, in the while loop, we have two conditions and require them both to be True for the while loop to continue. For clarity, we also printed out both inside the while loop.

Tip Tip

## VERY IMPORTANT ADVICE Make use of ubiquitous print statements to see the values of your variables. It will help you detect errors, and make sure your code is doing what it is supposed to be doing. When you become more advanced along your journey, you will be able to do more sophisticated debugging and logging, but print statements will remain your best friend, potentially indefinitely.

Infinite While Loops

Another risk of while loops is that you could create a situation where they run an infinite number of times because the stop condition is never reached:

x = 1

while x > 0:
    print(x)
    x += 1

If you were to run the previous example (PLEASE DO NOT RUN IT), it would freeze your terminal application because the loop would never end. The variable x starts greater than 1 and just keeps getting bigger. If this happens, you will get the spinning frozen icon on your computer, and your only recourse will be to force quit the terminal application (using CMD+Option+Escape on a Mac, or CTRL+Alt+Delete on Windows.) If this happens, it is not a serious problem, but you will need to restart the terminal and go back to the folder and all that. In the next section, we will show you a handy way you can catch and exit from an infinite while loop within your program so you don’t have to force quit the terminal.

Exercise: While Loops

For each of the following code blocks, predict what will be printed. Make your predictions before running the code. Compare your predictions to the actual results you observe when you run the code — what, if anything, did you get wrong? Can you trace the logic of the code to figure out why?

# A
i = 0
out = ""
while i < 5:
    out += str(i)
    i += 2
    print(out)


# B
s = "161272346154"
total = 0
i = 0
while total < 12 and i < len(s):
    total += int(s[i])
    i += 1
    print(i, total)


# C
s = "abcd"
i = 0
out = ""
while i < len(s) - 1:
    out = s[i] + out
    i += 1
    print(out)


# D
done = False
i = 0
total = 0
while not done:
    total += i
    if total > 3:
        done = True
    i += 1
    print(i, total, done)


# E
x = 8
while x > 0:
    if x % 3 == 0:
        x -= 2
    else:
        x -= 3
    print(x)
Exercise Solution — expand only when you’re ready to check your answers
# A
0
02
024

# B
1 1
2 7
3 8
4 10
5 17

# C
a
ba
cba

# D
1 0 False
2 1 False
3 3 False
4 6 True

# E
5
2
-1

2.3. continue and break

The Python commands continue and break are two ways to exert control over loops.

The continue command allows you to skip the current iteration of the loop and move on to the next iteration. The break command can be used to exit the loop altogether. A common use case of continue and break is at the beginning of a loop to check for special conditions that make you not want to be in the loop anymore.

For example, here’s how we can use continue in a for loop:

increment_amount = 2
for i in range(0, 10, increment_amount):
    if i == 5:
        continue
    print(i)

This code prints every number from 0 to 9 in steps of 2, except 5. So, in this case, 0,2,4,6,8. With increment_amount set to 2, 5 would not have been in the range object, so it wouldn’t have printed regardless. But if increment_amount were 1 or 5, it would be in the range object and would be skipped. When Python sees the continue statement, it immediately stops the current iteration of the loop and goes on to the next one, skipping all other code in the loop.

Here is an example of break, this time in a while loop:

x = 0
increment_amount = 1
while x < 100:
    if x < 0:
        break
    print(x)
    x += increment_amount

The code above prints every number from 0 to 99 in increments of whatever increment_amount is set to. If that is 1 it will be 0,1,2,3,4,…99. If it is 10, it will be 0,10,20,30,…,90. But what happens if increment_amount is set to a negative number? Then we would have an infinite while loop because x would never become > 100. The if statement checks to see if x goes negative and exits the loop if it does, avoiding the infinite loop.

Now, you might wonder why do it that way instead of:

x = 0
increment_amount = 1
while (x < 100) and (increment_amount > 0):
    print(x)
    x += increment_amount

In the situation above, both blocks of code work the same way. Some people do argue that most cases of continue and break can be solved in other ways and are better solved in other ways. But there are times when they really are the easiest way to solve a problem, so it is good to know they exist.

But whether you use a break statement or add an extra condition to a while loop definition, it can be a useful way to check for an infinite loop:

while_loop_counter = 0
while_loop_max = 1000000

while i < 100:
    print(i)
    while_loop_counter += 1
    if while_loop_counter > while_loop_max:
        print(f"WARNING: while loop iterated {while_loop_max} times without ending")
        break

while (i < 100) and (while_loop_counter < while_loop_max):
    print(i)
    while_loop_counter += 1
    if while_loop_counter > while_loop_max:
        print(f"WARNING: while loop iterated {while_loop_max} times without ending")
        break

The while loops above never change i, the variable is used to decide when the while loops should end, so they will loop forever. If you are someone who worries about infinite while loops, you can get in the habit of always adding a counter and max variable to every while loop you create to catch infinite loops. You can use this trick either with a break statement as in the first example or just including it in a condition in the while loop declaration.

Using sentinel values

Sometimes we intentionally want to create an infinite loop, but only until some condition is met. We can accomplish this using a sentinel value. A sentinel value is a special value used to indicate the termination of a loop when a specific condition is met. Unlike a loop with a predefined number of iterations (such as for i in range(n):), a loop using a sentinel value continues running until it encounters a specific value that signals it should stop. This is particularly useful when processing user input, reading files, making games, or any scenario where it isn’t clear how many iterations you’ll need in advance.

Handling user input

Imagine that we are writing a program that sums numbers that the user inputs. The program will repeatedly ask the user for a number and sum them all up. However, the user can enter -1 to indicate they are done with entering numbers. So, in this case we use -1 as the sentinel value:

running_total = 0 # the current sum of the numbers entered
while True:
    try:
        user_input = int(input("Enter a number (-1 to quit): "))
        if user_input == -1:
            break  # Sentinel value detected, exit loop
        running_total += user_input
    except ValueError:
        print("Please enter an integer.")
print(f"Total sum: {running_total}")

Here, the loop continues presumably until the user enters -1, at which point the break statement is executed, terminating the loop. (The program also makes sure that the user enters an integer, otherwise it will print an error message and ask the user to enter an integer again.)

Reading files

A common use case for sentinel values is when reading data from a file. Suppose we are processing a file where each line contains data, and a special marker (such as the word END) signals the end of relevant data:

with open("data.txt", "r") as file:
    for line in file:
        line = line.strip()
        if line == "END":
            break  # Stop processing when the sentinel value is found
        print(f"Processing: {line}")

Here, the loop iterates through the file line by line and stops when it encounters the sentinel value "END".

Boolean sentinels

Despite its utility, I actually don’t like using break statements. Instead, I find boolean sentinels to be more expressive and easier to work with. For example, we can create a boolean variable, keep_going, to control the loop flow from our first example of reading user input:

running_total = 0 # the current sum of the numbers entered
keep_going = True
while keep_going:
    try:
        user_input = int(input("Enter a number (-1 to quit): "))
        if user_input == -1:
            keep_going = False  # Sentinel value detected, exit loop!
        else:
            running_total += user_input
    except ValueError:
        print("Please enter an integer.")

print(f"Total sum: {running_total}")

Here, keep_going is initially True, allowing the loop to run. When the user enters -1, keep_going is set to False, naturally ending the loop without using break. However, note that in this case, we had to put the running_total += user_input statement inside of a new else block to ensure that the loop doesn’t actually add -1 to the running total.

I like this approach because it makes the loop logic more explicit and easier to reason through, but using break statements is also a fine way to handle such cases.

2.4. Lists

We have mentioned lists a few times; now it’s time to go over them in detail. In Python, a list is another sequence data type, like strings and range() objects. Like strings and range() objects, they have an order to the sequence. However, lists are special in a number of ways, the most important being that you can put any other Python data type inside a list. Lists in Python are declared with square brackets and then with commas separating the list elements.

list1 = ['dog', 'cat', 'mouse']  # a list of strings
list2 = [1, 5, 23, 32]  # a list of ints
list3 = [42.2, 21.0, 82.2321, 254.21]  # a list of floats
list4 = [True, False, False, True]
list5 = ['dog', 23, 'pizza', 45.0, None, True, list3]  # a list mixing data types

print(list1)  # prints all of list1

Lists in Python can mix different data types, as you can see in list5. You can even put lists inside of other lists!

List indexing and slicing

Lists are ordered, and each element of the string can be accessed the same way as strings by using brackets and a number to retrieve a single item from the list. Lists can also be sliced just like a string. If you have a list inside a list like in list5, and you want to access a member of the inner list, you use two sets of square brackets:

x = list3[2]  # stores the 3rd element of list3 (82.2321) in x
x = list1[-1] # stores the last element of list1 ('mouse') in x
x = list1[1:] # stores everything from element 2 to the end of list 1 (['cat', 'mouse']) in x
x = list5[6][1] # stores 21.0 in x, because list3 is the 6th element of list5, and 21.0 is the 1st element of list3
               # remember that list indices start at 0, not 1!
x_reversed = list1[::-1] # reverses the list

Lists are mutable

One big difference between lists and strings is that you can modify part of a list but not part of a string. Strings are what are called immutable objects. You can change a whole list but not part of it.

x = "pizza"
y = "sandwich"
x[4] = "A"  # this line would generate an error; strings are immutable and their parts cannot be changed
x = "broccoli"  # isn't changing the string that was in x, actually replacing it entirely
x = x + y  # appends sandwich to broccoli, but isn't changing a part, is again wholly replacing what is stored in x

list1 = ['dog', 'cat', 'mouse']
list1[2] = 'rat'  # this is allowed, because unlike strings, lists are mutable objects
list1[1] = 5.5  # python doesn't care what kind of object was in a list position, you can replace it with another type
list1[2] = "dog"  # python lists are also allowed to have duplicates.

Searching for list elements

Some of the other commands we learned with strings work with lists too.

list1 = ['dog', 'cat', 'mouse']

search_token = 'dog'
if search_token in list1:
    print(f"Found {search_token}!")
else:
    print(f"Didn't find {search_token}!")

search_token = 'o'
if search_token in list1:
    print(f"Found {search_token}!")
else:
    print(f"Didn't find {search_token}!")

The if x in y command works for searching for something in a list, just like it did in a string. But note that in the first example, the if statement would be True (“dog” is in the list). But the second if statement would be False. There is an ‘o’ in the list (two, in fact). But when you search for something in a list, it searches for a match for
each entire element of the sequence, not all the sub-elements of the sequence.

There is something else important to point out in the example above. Note that we used a variable to designate what we wanted to search for. We could have “hard-coded” dog in the first example:

if 'dog' in list1:
    print("Found dog!")
else:
    print("Didn't find dog!")

if 'o' in list1:
    print("Found o!")
else:
    print("Didn't find o!")

But if we had done it that way, we would have had to change three things if we wanted to change what we were searching for the if statement itself and both print statements. But because we used a variable, we didn’t have to change any of the code at all, except for whatever was saved in search_token. One important thing to learn in programming is to make use of variables whenever possible, as it will make your code much more reusable later. An easy way to think of it is that code you write should be DRY: Don’t Repeat Yourself. If you need to do something more than once, odds are that you can figure out some kind of abstraction (higher level function, method, or operator, or organizing principle) to do it.

Passing lists to built-in Python functions

Just like strings, lists can be passed to many built-in functions.

list1 = [1, 65, 23, 77, 23]

list_length = len(list1)  # stores the length of the list (5)
list_sum = sum(list1)  # stores the sum of the list (189)
list_max = max(list1)  # returns the max value in the list (77)
list_min = min(list1)  # returns the min value in the list (1)

Passing iterators to list()

In addition to declaring a list the way we described in the last section, you can also create a list using the list() function. The list function requires you to pass it an iterator object, like a range() object:

list1 = list(range(5))  # creates the list [0, 1, 2, 3, 4]

Another useful function that creates an iterator object is reversed(). The reversed() function can be passed any ordered sequence, and it will return an iterator object of the items in the reversed order. Note that if you print this out, you don’t see the sequence in reverse; you just see the iterator object.

list1 = [1, 65, 23, 77, 23]
print(reversed(list1))
<reversed object at 0x10f1e9840>

But you can pass the reversed iterator object into the list() function to convert it into a list.

list2 = list(reversed(list1))  # stores [32, 77, 23, 65, 1] in list2
Tip Tip

Lazy evaluation

The reason that functions like reversed() or range() return an iterator object is because they are using a technique called lazy evaluation. In the case of reversed() this means that it is not actually reversing the list right away. It is deferring the work until it is needed. This can save memory if the list is very large. For our toy examples, it doesn’t make much difference, but it is a good thing to be aware of.

2.5. List methods

List methods

Like the other data types (str, int, float, etc.), lists are an object, which means they have functions (called methods) that is a built-in part of the list and that you can call using the .method_name() procedure. For example:

list1 = [1, 65, 23, 77, 23]

list1.append(31)  # adds a single item to the end of a list: list1 = list2 = [1, 65, 23, 77, 23, 31]

list1.clear()  # removes all items from a list, resulting in an empty list: list1 = []

# let's recreate list1
list1 = [1, 65, 23, 77, 23]
list2 = list1.copy()  # creates a copy of the list: list2 = [1, 65, 23, 77, 23]

x = list1.count(23)  #  tells you how many times a particular element occurs in a list: x = 2

list2 = ['dog', 'cat']
list1.extend(list2) # concatenates a list onto the end of a list: list1 = [1, 65, 34, 23, 77, 23, 'dog', 'cat']

x = list1.index(23)  # tells you the first index where the element is found: x = 2. will cause error if not in list

list1.insert(2, 34)  # inserts a 34 in the 3rd position, moving others back: list1 = [1, 65, 34, 23, 77, 23, 'dog', 'cat']

x = list1.pop(3)  # removes element at specified position: x = 23, list1 = [1, 65, 34, 77, 23, 'dog', 'cat']
x = list1.pop()  # default for pop, if not specified, is -1, the last element in the list: x = 'cat', list1 = [1, 65, 34, 77, 23, 'dog']

list1.remove(23)  # removes the first occurrence of the specified value from the list: list1 = [1, 65, 34, 77, 'dog']

# again, let's start over with the original list
list1 = [1, 65, 23, 77, 23]

list1.reverse()  # reversed the order of the list: list1 = [23, 77, 23, 65, 1]

list1.sort()  #  sorts the list. list1 = [1, 23, 23, 65, 77]
list1.sort(reverse=True)  # sorts a list in largest to smallest order: list1 = [77, 65, 23, 23, 1]

# let's change the list up a bit
list1 = [1, 6, 3, "dog", "cat"]
list1.sort()  #  ERROR: you can't sort a list that mixes different types of data

# Let's sort lists of tuples
mixed_list = [("a", 6), ("c", 2), ("b", 4)]
mixed_list.sort(key=lambda x: x[1])  # sorts by the second element of each tuple
print(mixed_list)  # prints out: [('c', 2), ('b', 4), ('a', 6)]

# sort by the first element of each tuple
mixed_list.sort(key=lambda x: x[0])  # sorts by the first element of each tuple
print(mixed_list)  # prints out: [('a', 6), ('b', 4), ('c', 2)]

# sort by the first element of each tuple, but in reverse order
mixed_list.sort(key=lambda x: x[0], reverse=True)
print(mixed_list)  # prints out: [('c', 2), ('b', 4), ('a', 6)]

# prioritize sorting by the first element, then the second
mixed_list.sort(key=lambda x: (x[0], x[1]))
print(mixed_list)  # prints out: [('a', 6), ('b', 4), ('c', 2)]

List copying: Python list assignments are copies by reference!

You may have noticed that one of the methods for a list is .copy(). Why is this necessary? Can’t we make a copy by doing the following?

list1 = [1,2,3,4,5]
list2 = list1

That works for strings, ints, floats, and booleans — in some other languages, this would work fine for lists, too (e.g., MATLAB). But remember how we introduced the idea of copying by reference vs. copying by value in Chapter 1.1. Variables? Copying by value means creating a copy of the value stored in the variable and assigning it to the new variable. That’s what happens for strings, ints, floats, and booleans. But in Python, lists are different; they copy by reference. So in the example above, what happens is that the variable list2 is actually “pointing to” the exact same variable as list1.

What does this mean, and why does it matter? Consider the following code:

list1 = [1,2,3,4,5]
list2 = list1

list2[0] = -1
list1[-1] = -1

print(list1)
print(list2)

the output here is:

[-1, 2, 3, 4, -1]
[-1, 2, 3, 4, -1]

Notice that both lists are the same! When list2 is created and set equal to list1, you can think of that as two different variables pointing to the same data. So a command using either name to change its data changes the underlying data for both.

This is where the .copy() method is useful. If you actually want to create a copy so that you can change one without changing the other, you have to use .copy().

list1 = [1,2,3,4,5]
list2 = list1.copy()

list2[0] = -1
list1[-1] = -1

print(list1)
print(list2)

Which results in:

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

The .copy() method for a list creates what is called a shallow copy. This means that the top-level variable (the list) itself is copied. It is then filled with all the same variables that were in the list. So if these are integers like in the example above, you get a pure copy. However, if one of the items in the list is another list, then you still only get a reference to that list in the new list.

list1 = [1, 2, 3]
list2 = ["a", "b", "c"]
list3 = [list1, list2]

list4 = list3.copy()
list3[0] = "apple"
list2[0] = "A"

print(list3)
print(list4)

Which results in:

['apple', ['A', 'b', 'c']]
[[1, 2, 3], ['A', 'b', 'c']]

The string “apple” replaces the first element in list3 as expected, but list4 remains unchanged because list4 is a shallow copy, which means its top-level elements were copied separately. However, both list3 and list4 still hold references to the same list2 object. So, when we modify list2 by changing “a” to “A”, the change is reflected in both list3 and list4 because they both reference the same list2. In contrast, replacing list1 with “apple” in list3 only affects list3, since the reference to list1 in list4 remains intact.

If you want to copy everything in a list so you can change everything inside of it, you need to use a function called deepcopy().

import copy

list1 = [1, 2, 3]
list2 = ["a", "b", "c"]
list3 = [list1, list2]
list4 = copy.deepcopy(list3)

2.6. Tuples

Tuples are the final data structure we need to deal with in this chapter. Tuples are just like lists, except that they are immutable. This, you might remember, means that they cannot be changed. (“Mutable” means the opposite — a mutable object can be changed.) Tuples are created using parentheses, as compared to the square brackets we used for lists.

some_list = [1, 2, 3, 4]
some_tuple = (1, 2, 3, 4)

x = some_list[2]  # x = 3
y = some_tuple[2]  # y = 3

some_list[3] = -4  # changes list to [1, 2, 3, -4]
some_tuple[3] = -4  # ERROR: this will generate a syntax error because tuples are immutable

Why have an immutable version of a list? There are a couple of reasons. First, there may be some times you want to create a data structure that you want to protect so that it cannot change. Making something a tuple is a good way to guarantee that the data won’t change.

A second reason, which we will learn more about later, is that certain other more complex data types require the use of immutable data types. For example, we will learn later about sets. Sets are like lists that can’t have duplicates. Sets also require the data in them to be immutable, like strings, ints, floats, or tuples; you can’t create a set of lists.

A third reason is that tuples are faster and more memory-efficient than lists. This is because tuples are immutable, so Python can optimize them for speed.

Finally, a fourth reason is related to convention. A list is often thought of as a collection of different items (e.g., a list of different names of people) while a tuple is often used to represent a single item with multiple properties (e.g., a tuple representing a person, along with their name, age, and home town).

Converting between tuples and lists

Now, let’s say you find yourself with a tuple that you do want to change. You can easily convert a list to a tuple and vice versa.

some_tuple = (1, 2, 3, 4)
some_list = list(some_tuple)

another_tuple = tuple(some_list)
another_list = list(another_tuple)

some_list[0] = -1
print(some_tuple)
print(some_list)
print(another_tuple)
print(another_list)

This creates the output:

(1, 2, 3, 4)
[-1, 2, 3, 4]
(1, 2, 3, 4)
[1, 2, 3, 4]

Notice that the first list was changed as expected, but the second list was not. When a list is converted to a tuple and then back to a list, a copy is made; it is not another “copy by reference” situation. It is an entirely new list.

Tuple methods

Because tuples are immutable, they don’t have most of the methods that lists have, like .append(), .remove(), .insert(), etc. There are only two methods for tuples:

some_tuple = (1, 2, 3, 4)
x = some_tuple.count(3)  # x = 1, the number of times that 2 occurs in the tuple
x = some_tuple.index(3)  # x = 2, the index of the location of the first occurrence of 3 in the tuple

Tuple unpacking

Tuple unpacking is a convenient way of assigning values from a tuple to multiple variables. It’s called “tuple unpacking”, but it can be used with any iterable in Python, including lists, strings, and even dictionaries (more on that later; dictionaries work a little differently).

some_tuple = (1, 2, 3)
a, b, c = some_tuple
print(a, b, c)  # prints 1 2 3

You can also use the * operator to unpack parts of the tuple into a list. This can be a good way to get the last few elements or the middle elements of a tuple. (You can use the * operator to unpack any iterable, not just tuples.)

some_tuple = (1, 2, 3, 4, 5)

# Unpack the first element and the last element into a and c, and the rest into b
a, *b, c = some_tuple
print(a, b, c)  # prints 1 [2, 3, 4] 5

# Unpack the first two elements into a and b, and the rest into c
a, b, *c = some_tuple
print(a, b, c)  # prints 1 2 [3, 4, 5]

Named tuples

Named tuples are a special type of tuple where each element has a “name”, making the code more readable and self-documenting. They’re available in the collections module (which comes with Python) and they combine the benefits of tuples (immutability) with the ability to access elements by name instead of just by index.

from collections import namedtuple

# Create a named tuple class called 'Point' with fields 'x' and 'y'
Point = namedtuple('Point', ['x', 'y'])

# Create a new Point instance called 'p'
p = Point(x=10, y=20)

# Access values using names
print(p.x)    # prints 10
print(p.y)    # prints 20

# Access values using indices (still works like a regular tuple!)
print(p[0])   # prints 10
print(p[1])   # prints 20

# Convert to regular tuple if needed
regular_tuple = tuple(p)  # (10, 20)

# Unpack like a regular tuple
x, y = p      # x = 10, y = 20

Named tuples are particularly useful when working with data that has a clear structure, like coordinates, RGB colors, or database records. They provide better code readability compared to regular tuples while maintaining the benefits of immutability. (However, in practice, and as you will learn in later chapters, we often use dictionaries, classes, or dataclasses instead of named tuples for this purpose, as they are more flexible and easier to work with.)

# Example with multiple named tuples
Person = namedtuple('Person', ['name', 'age', 'city'])
Color = namedtuple('Color', ['red', 'green', 'blue'])

alice = Person('Alice', 30, 'New York') # could also have used: Person(name='Alice', age=30, city='New York')
red = Color(255, 0, 0)

print(alice.name)     # prints 'Alice'
print(red.blue)      # prints 0

2.7. Looping over lists

Looping over lists and tuples works the same way it does for strings or any other sequence or iterator. You can use the loop to get each item, one at a time; you can use a range() object to get a sequence of index values the length of the list; or you can use enumerate() to do both.

some_list = ["lions", "tigers", "bears", "oh my!"]

for some_item in some_list:
    new_item = some_item.upper()
    print(new_item)

for i in range(len(some_list)):
    print(i, some_list[i])

for i, some_item in enumerate(some_list):
    new_item = some_item.upper()
    some_list[i] = new_item
    print(f"Changed {some_item} to {new_item}")

The above code outputs:

LIONS
TIGERS
BEARS
OH MY!

0 lions
1 tigers
2 bears
3 oh my!

Changed lions to LIONS
Changed tigers to TIGERS
Changed bears to BEARS
Changed oh my! to OH MY!

One of the main situations where using the index comes in handy is when you have two lists that are the same length, and you want to iterate over them at the same time:

pre_test_score_list = [92, 88, 77, 65, 92]
post_test_score_list = [96, 94, 74, 77, 95]

improvement_list = []

if len(pre_test_score_list) == len(post_test_score_list):
    for i in range(len(pre_test_score_list)):
        improvement = post_test_score_list[i] - pre_test_score_list[i]
        improvement_list.append(improvement)

average_improvement = sum(improvement_list) / len(improvement_list)
print(f"The average improvement was {average_improvement:0.2f}")

Which outputs:

The average improvement was 4.40

However, there is another, Pythonic way to accomplish the same thing using the zip() function.

pre_test_score_list = [92, 88, 77, 65, 92]
post_test_score_list = [96, 94, 74, 77, 95]

improvement_list = []

for pre, post in zip(pre_test_score_list, post_test_score_list):
    improvement = post - pre
    improvement_list.append(improvement)

average_improvement = sum(improvement_list) / len(improvement_list)
print(f"The average improvement was {average_improvement:0.2f}")

Which again outputs:

The average improvement was 4.40

2.8 Terminal input and output

Terminal output

So far, we’ve covered some basic screen output using print(). But there are some more details worth knowing. One is that the print() statement can take some optional additional arguments. The sep argument (short for separator) specifies what separator you want to use if you put multiple items in a print statement. The default is a space, which is what happens if you don’t use the separator argument.


x = "dog"
y = "cat"
print(x, y, sep=',')  # output will be "dog,cat" instead of "dog cat"

The end argument specifies what you want to do at the end of a line when using a print statement. The default is that a new line is appended to whatever is on your print statement, which is why every print statement generates an output on a different line. But you can change this if you want.

x = "dog"
y = "cat"
print(x, end="")
print(y)
# the result of this will be a single line of output, dogcat

One thing to note is that when the default "\n" is used in a print statement, this forces the print statement to occur immediately by your operating system. But if you change this, you can sometimes see weird behavior, like even having print statements occurring out of order, because your operating system decides on its own when to execute them. You can force it to print right away using the flush=True keyword argument. If you are changing the end argument in a print statement, it is good to use flush at the same time.

x = "dog"
y = "cat"
print(x, end="", flush=True)
print(y)
# the result of this will be a single line of output, dogcat

Terminal input

You can get input from the terminal, too, using the input() function. Inside the input() statement, you put whatever text prompt you want to print to the screen and then assign the results to a variable. Whatever is typed into the terminal before hitting Enter will get saved in the variable.

favorite_food = input("Please tell me your favorite food!")
output_string = f"Your favorite food is {favorite_food}? How interesting!"
print(output_string)

Two things to note about the input() function. The first is that no matter what is typed as input, it is stored as a string. So if you want to have numbers as input and use them as numbers, you need to convert them to an int or a float. The second is that the terminal output that is entered into the input() statement, unlike a print() statement, has no terminating new lines or space. So in the example below, a colon and space have been added to the end so that it looks nicer in the terminal when someone types a number.

first_number = input("Please type a number: ")
second_number = input("Please type a number: ")
the_sum = float(first_number) + float(second_number)  # without the float conversion, this would concatenate strings
print(the_sum)

Deciding between multiple ways of doing the same thing

“Make it work, make it right, make it fast.” — Kent Beck

Note the difference between the print() statements below. There are six different ways to do the same thing: print the two strings with a space in between.

a = "dog"
b = "cat"
c = "dog" + " " + "cat"
d = "{} {}".format(a, b)
e = f"{a} {b}"

print(a, b)
print(a + " " + b)
print(c)
print("{} {}".format(a, b))
print(d)
print(e)

Is one way of doing it better than the others? One of the most general rules of programming is there is no universal “right” way to do something because their all sorts of circumstances that can make what is usually a less efficient way better in a particular situation. And so how should you choose? There are some general points to keep in mind.

When you are writing a program, there are a couple of goals you are trying to balance. The first and most obvious is that you are trying to create a tool that solves a problem, and usually, you want to solve that problem as quickly and as easily as you can. But there are also some other factors to consider. Let’s use another example to make the point:

a = 45
b = 32
c = -2
x = 5
y = 2

print(a * x ** 2 + b * y + c)

z = a * x ** 2 + b * y + c
print(z)

In this example, both print statements do the same thing. Is there a reason to prefer one over the other? In situations like this, it might seem like the first option is preferred because it seems more simple, and is shorter, one line instead of two. But in fact, most experienced software designers would suggest you do it the second way.

One general rule of good programming practice, good software engineering, is to make different pieces of code that do different things independently. This makes code easier to read, understand, debug, modify, and reuse. In this case, we are doing two operations: some math to calculate z and printing z. Having these be separate lines theoretically makes it easier to modify our code later, or find an error, because the two operations are independent. Similarly, what if we decide we want to do something with z other than print it? If we decide that, then we need to go back and convert it into the second option anyway. As a general rule, keeping code modular, meaning keeping different functions and operations independent and having each do only one thing, is a good rule to follow.

But this “keep things independent” rule is often at odds with writing code quickly. And it can also be at odds with writing code clearly, writing code that executes fast or uses less memory. This is why the real rule is “there is no one correct way”. In general, as you get better at programming, you will learn to spot when these trade-offs are in conflict and what the right choice is. But for now, it is useful to know about the “keep things independent” principle and to follow it when you can.

2.9. File input and output

Terminal output is very useful for getting information about how our program is working and for debugging. Terminal input is occasionally useful but is not used often. In the vast majority of Python programs, the way we typically get input and output is by using data files.

Consider homework 1, for example. It would be really painful to have to manually type in the data from an experiment, either into the terminal or into the Python script itself. What we want instead is a program that reads a data file, does the calculations, and outputs data to another file. In this section, we will start with learning how to create, modify, and write files.

File input

Opening files

In Python, before we can read from or write to a file, we need to open the file. What that means in Python is effectively telling Python where the file is (or where it should be created if it doesn’t exist) and then telling python to create a “file object” variable, which includes information like the file’s name and location, both in the terms we are used to reading (like /Users/my_name/Desktop/my_file.txt) and also in terms of its memory location address in terms the computer understands (those hex code numbers we talked about on day 1).

In Python, these file object data types are typically referred to as “file handles,” and so it is common to name the variable where we are storing that object something that has that in it. It is variable, so you can name it anything you want (as long as it follows the Python rules for variables), but it is a convention to include file_handle in the variable name (sometimes abbreviated as fh). You want to make sure you remember that the file_handle (the file itself) might be a separate variable from a string variable where you are keeping track of the file’s name or location.

file_path = "~/Desktop/"
file_name = "test_file.txt"
file_handle = open(file_path + file_name)
Tip Superior pathing with pathlib

You can use pathlib to create file paths. These are often easier to work with than strings representing file paths, and they are cross-platform (meaning they will work on Windows, Mac, and Linux — yes, with forward slashes).

from pathlib import Path

file_path = Path.home() / "Desktop"
file_name = "test_file.txt"
file_handle = open(file_path / file_name)

Paths come with a number of handy properties, such as name, stem, parent, and suffix.

The code above would try to open a file called “test_file.txt” that is on the Desktop. If that file does not exist, this line will get an error. In a few weeks, we will learn a handy trick to test to see if a file exists before trying to open it so you don’t get this error.

The open() function can take a number of other arguments that affect the file object that is being created. If no arguments are specified, then the file object being created is a “read” object — a file that you are trying to read. This is why you get an error if the file doesn’t exist. But what if you want to create a new file and write to it? Then you just need to supply an additional argument to the open function like this:

file_handle = open(file_name, "w")

The “w” tells Python that this is a file you want to write. Python will therefore create a file with that name. Two things to remember:

  • if you only specify a name (as in the example immediately above), and do not supply a full path (as in the first example), then the file will be created in the same location as the Python script that is running that line of code.
  • the “w” command creates and writes a file with that name, even if there is already a file with that name. It will overwrite any existing file with that name. So be careful not to accidentally overwrite a file you don’t want to erase! There are some alternatives to avoid this, like “x” and “a”:
file_handle = open(file_name, "r")  # opens a file for reading, the default if no option is specified
file_handle = open(file_name, "x")  # opens a file only if the file does not exist and gives an error if it does
file_handle = open(file_name, "a")  # Creates new file if none exists; opens existing file to append to it if one does

Writing to files

Writing to files is a lot like using the print() statement, except the output ends up in the file instead of in the terminal.

favorite_food = "pizza"
file_name = "favorite_food.txt"
file_handle = open(file_name, "w")
file_handle.write(favorite_food)
file_handle.close()

There are a couple of differences between the .write() method and the print() function. Remember that print() automatically inserts a “” at the end of whatever it is told to print. The .write() method doesn’t do that. So if you want to write multiple lines to a file, you need to insert those line breaks yourself. In the code below, the output to the terminal and in the file would look the same.

favorite_food_list = ["pizza", "ice cream", "strawberries"]
file_handle = open("favorite_foods.txt", "w")
for food in favorite_food_list:
    print(food)
    file_handle.write(food + "\n")
file_handle.close()

Other than that, print() and .write() are pretty much the same. You can do all the same string formatting that you might do with print() using .write().

number_list = [3.2332523, 5.1231241, 6.1241]
food_list = ["pizza", "ice cream", "strawberries"]
file_handle = open("favorite_foods.txt", "w")
for i in range(len(food_list)):
    file_handle.write(f"{food_list[i]}: {number_list[i]:0.3f}\n")
file_handle.close()

Of course, if we follow our “keep different operations separate” principle, we may want to split that written statement into two lines:

number_list = [3.2332523, 5.1231241, 6.1241]
food_list = ["pizza", "ice cream", "strawberries"]
file_handle = open("favorite_foods.txt", "w")
for i in range(len(food_list)):
    output_string = f"{food_list[i]}: {number_list[i]:0.3f}\n"
    file_handle.write(output_string)
file_handle.close()

Closing a file

When you are done working with a file, you need to close the file. This is especially important if you are going to be working with multiple files and re-using the file_handle variable name (like in a loop). If you don’t, you might end up accidentally writing to the wrong file. And if your program tries to open a file that you have already opened, you will get an error.

favorite_food = "pizza"
file_name = "favorite_food.txt"
file_handle = open(file_name, "w")
file_handle.write(favorite_food)
file_handle.close()

Closing files automatically with the with statement

Because of the need to remember to close files after opening them, a useful trick is to use the with statement. with allows us to open the file and automatically close it when the block of code is finished. This is very useful because it frees us from having to remember to close the file, which is a common source of errors. (This is also known as a “context manager”, and we will learn more about them in Chapter 8.2. Idiomatic Python.)

animal_file_name = "animals.txt"
animal_list = []

with open(animal_file_name) as animal_file_handle:
    for line in animal_file_handle:
        animal = line.strip('\n')
        animal_list.append(animal)

Reading from files

Reading input from files is also easy. Let’s imagine that in the same directory, we are running our program; we have a text file called “animals.txt” with the following data:

dog
cat
mouse
lion
tiger
elephant
cow
horse
pig

Imagine that we want to open this file, read the data, and store it in a list. That’s very simple, though there a a couple of different ways that we can do it.

The .read() method

The first is the .read() method. To understand the .read() method, you have to imagine that there is a cursor in the file, and it starts at the very beginning of the file when you use the open() function. So when you then use the .read() method, it reads in the number of specified characters and then moves the cursor to that position. If no number is specified, it reads everything until the end of the file.

So if we use .read() with no number, we get the whole file stored as one big string. So if we want the data in a list, we’re going to need to parse that string. Remember that because in the file, each animal was on its own line, so in reality, the string looks like this: “dog”. There is no after pig because there is no new line after pig, which you can tell because there isn’t a blank line at the end of the file.

So how do we convert this to a list? It’s easy because we have the same character between each of our words: . So we can just split using that character.

animal_file_name = "animals.txt"
with open(animal_file_name) as animal_file_handle:
    data = animal_file_handle.read()

animal_list = data.split('\n')

If we had passed a number argument to .read(), then the cursor is in that position, and so the next time .read() is used, it will start there. Using read with a number is actually pretty rare since we rarely have a file organized in terms of precise numbers of characters. But .read() without any numbers can be useful if we want to just store the data as a string for now and worry about how to parse it later. Sometimes this can be an issue if the file is really big (like gigabytes big), and you end up with more data than the amount of RAM you have on your computer. If this happens, your computer will start running very, very slow because it will be using your hard drive memory instead of your RAM memory to store the data, and accessing your hard drive is much much slower than accessing RAM.

The .readline() and .readlines() methods

The .readline() function reads one line at a time. Basically, it goes from wherever the cursor is (which starts at the beginning of the file) until it encounters a and stores all the characters in between into a string. You can use it more than once to get more than one line. You could use .read() to read in our file and create a list. But you would need to use the .readline() method nine times. That wouldn’t be very pretty, and it would rely on you knowing it needed to happen nine times.

The .readlines() method works similarly to .readline(), except that it reads multiple lines and stores the result in a list. This is very close to what we need. The only issue is that the file has no characters in it, so these will end up in the list, which would make your list look like this: [‘dog’, ‘cat’, ‘mouse’, …]. To fix this, we could then use a loop to remove those characters.

animal_file_name = "animals.txt"
with open(animal_file_name) as animal_file_handle:
    animal_list = animal_file_handle.readlines()

for i in range(len(animal_list)):
    animal_list[i] = animal_list[i].strip('\n')

Looping through files

Another method, and a very useful one, is to loop through a file using the file object itself as the sequence. Remember that a for loop must use a sequence in the definition of a for loop, and Python lets you treat the file as a sequence, with each line being a sequence element.

animal_file_name = "animals.txt"
animal_list = []

with open(animal_file_name) as animal_file_handle:
    for line in animal_file_handle:
        animal = line.strip("\n")
        animal_list.append(animal)

2.10. Try and except

When writing Python code, errors (also called exceptions) can occur for many reasons: trying to divide by zero, accessing a list index that doesn’t exist, or attempting to convert an invalid string to a number (e.g., “123” is valid, but “123a” is not). Instead of letting these errors crash our program, we can handle them gracefully using try and except blocks.

Basic try-except structure

Here’s the basic structure of a try-except block. The code in the try block is the code that is “risky” in some sense: it might raise an exception. The code in the except block is the code that runs if an exception occurs.

try:
    # Code that might raise an exception (in this case it definitely will)
    result = 10 / 0
except:
    # Code that runs if an exception occurs
    print("An error occurred!")

The above code will print “An error occurred!” because we wrote code that results in a classic “division by zero” error.

But let’s look at a more practical example. Imagine we want to convert user input to a number, but the user enters something that isn’t (and could never be considered) a number.

try:
    user_input = input("Enter a number: ")
    number = int(user_input)
    print(f"Your number doubled is: {number * 2}")
except:
    print("That wasn't a valid number!")

If the user enters something like “hello”, the program will not crash because we have a try and except block. Instead, the program will print “That wasn’t a valid number!” because we wrote code that results in a “value error” — the user entered a string when we were expecting an integer.

Note that if the user entered a decimal number, the program would still crash because it is represented as a string at first. (We could use the float() function to convert the string to a decimal number first before trying to convert it to an integer.)

Catching specific exceptions with multiple except clauses

While catching all exceptions with a bare except clause works, it’s generally better to handle each type of exception separately. This allows us to handle different types of errors differently depending on the context.

try:
    number = int(input("Enter a number: "))
    result = 10 / number
except ValueError: # will occur if, for example, the user enters a string
    print("That's not a valid number!")
except ZeroDivisionError: # will occur if the user enters 0
    print("You can't divide by zero!")

You can have as many except clauses as you need to handle different types of errors.

The else and finally clauses

Try-except blocks can also include else and finally clauses:

  • else runs if no exception occurs — think of it as “if no error, then do this”
  • finally runs whether an exception occurs or not — think of it as “do this no matter what at the end”
try:
    number = int(input("Enter a positive number: "))
    if number < 0:
        raise ValueError("Number must be positive")
except ValueError as e:
    print(f"Error: {e}")
else:
    print(f"You entered the valid number: {number}")
finally:
    print("Thank you for using this program!")

Using try-except wisely

While try-except blocks are undoubtedly powerful, they should be used judiciously. Here are some rules of thumb:

  1. Only catch exceptions you can handle meaningfully; don’t use it as a crutch to handle normal flow control
  2. Avoid using bare except clauses (without specifying the exception type) — explicit is better than implicit, after all.
  3. Keep the code in the try block focused on the operations that might raise exceptions

2.11. Lab 2

"""
This lab is a Python file and should run and execute like a Python file.
Your TA will grade the file by running it and reading your answers.
So please make sure that the file runs without getting an error and prints out the correct answers.

Some questions will be like question 1, asking you to explain something. Make sure your answer prints out when you run
the program.

Sometimes the question may ask you to modify or write some code that executes and perhaps to explain the code.

Make sure that the file you turn in runs without errors. If the script cannot run, your TA won't grade the assignment
and you will get a 0.
"""

print("\n########## Question 1 ##########\n")
"""
1) What is the difference between a for loop and a while loop?
Output your answer, in your own words, using a print statement.
"""
# YOUR CODE FOR 1) GOES HERE


print("\n########## Question 2 ##########\n")
"""
2) Explain the following code. Then explain how it would behave differently if you changed continue to break.
    Output your answer, in your own words, using a print statement.
"""
the_sum = 0
i = 0
while i < 10:
    i += 1
    if i % 2 != 0:
        continue
    the_sum += i
print(the_sum)
# YOUR CODE FOR 2) GOES HERE


print("\n########## Question 3 ##########\n")
"""
3) What is an "infinite" while loop, and why does it happen?
    Output your answer, in your own words, using a print statement.
"""
# YOUR CODE FOR 3) GOES HERE


print("\n########## Question 4 ##########\n")
"""
Split the string below into a list of tokens (in this context, a list of words).
Then use two for loops (one embedded in the other) to loop over the list of tokens,
and within each token, loop over the characters and print them out
so that it looks like the output below. Your output should look like this
(but for all the words, not just two; I've added ellipses to show that the output should continue.)
Note that there should be a blank line between each word. Make sure not to add an extra blank line
at the end!

    T
    h
    e

    u
    n
    e
    x
    a
    m
    i
    n
    e
    d
    .
    .
    .
"""
socrates = "The unexamined life is not worth living"
# YOUR CODE FOR 4) GOES HERE


print("\n########## Question 5 ##########\n")
"""
5) The reading for this week described three ways to use a for loop iterate over a sequence:
    - getting an element of the sequence each iteration through the loop
    - getting an index counter each iteration through the loop
    - getting both at the same time

    Below, create a tuple of strings of your five favorite foods, and then iterate through the tuple using each of these methods,
    each time printing out a counter showing how many times you have iterated and the food at that
    place in the tuple. The output should look like the following (but with *your* favorite foods, not mine):

    Method 1:
    0 chicken vindaloo
    1 espresso ice cream
    2 lasagna
    3 tiramisu
    4 ramen

    Method 2:
    0 chicken vindaloo
    ...

    And so on for each method. The above should print out a total of three times:
    once for each method. Don't forget to start counting at 0.
"""
# YOUR CODE FOR 5) GOES HERE


print("\n########## Question 6 ##########\n")
"""
6) In the code below, "giraffe" is added to list2 but not to list1. But nonetheless, "giraffe" is found in list1.
    Explain why this happens using a print statement.
"""
list1 = ["dog", "cat", "mouse"]
list2 = list1
list2.append("giraffe")
if "giraffe" in list1:
    print("Found giraffe!")
else:
    print("Didn't find giraffe!")
# YOUR CODE FOR 6) GOES HERE



print("\n########## Question 7 ##########\n")
"""
7) In the string below:
    - use list methods to split the string into a list of tokens (sequences of characters separated by spaces).
    - then, loop through that list and, for each token, if it contains a "d", add it to d_list.
    - print d_list
    - print out how many characters were in the string, how many tokens were in the string, and how many tokens
        had a d. You must use code to get these numbers, not count them yourself. Your output should look like:
        ['mind', 'furnished', 'ideas']
        Characters: 52   Tokens: 9   d-Tokens: 3
"""
# YOUR CODE FOR 7) GOES HERE
locke = "The mind is furnished with ideas by experience alone"
d_list = []



print("\n########## Question 8 ##########\n")
"""
8) For the following two strings, combine the strings and then output a descending-order list of each word
    and how frequently it occurred in both strings.

    But before using code to count these numbers, lowercase all characters and remove all punctuation
    (in this case, just commas and periods).

    For this problem, you must use only loops, if-else statements, and string and list methods we have learned
    in class so far.

    No dict() or set() allowed!

    If the starting strings had been the following, your output should look like this:
    string1 = "The dog watched the cat."
    string2 = "The cat chased the mouse.
    Output:
    the, 4
    cat, 2
    dog, 1
    mouse, 1
    watched, 1
    chased, 1
"""
descartes1 = "If you would be a real seeker after truth, it is necessary that at least once in your life you doubt, as far as possible, all things"
descartes2 = "I think, therefore I am."
# YOUR CODE FOR 8) GOES HERE


print("\n########## Question 9 ##########\n")
"""
9) Below are two lists. Turn it into a single list, with each element in the list being a tuple containing one
element from each list, keeping them matched in order. Then print the resulting list. The output should look like this:
[("a", 0), ("e", 4), ("d", 3), ("b", 1), ("c", 2)]
"""
letter_list = ["a", "e", "d", "b", "c"]
number_list = [0, 4, 3, 1, 2]
# YOUR CODE FOR 9) GOES HERE


print("\n########## Question 10 ##########\n")
"""
10) Using your answer from question 9 above, in which you have a list of tuples,
sort that list based on either the letter or number in each tuple
(your choice). The output should look like this:
[("a", 0), ("b", 1), ("c", 2), ("d", 3), ("e", 4)]

Note: Do not sort the original lists separately — you must sort the combined tuples!
"""
# YOUR CODE FOR 10) GOES HERE