More Testing, Sets vs. Lists, and Problem Solving
Logistics
The last Lab 1 Section is today from 2 to 4pm. If you havent attended, attend!
Labs will also serve as open hours, so if you have questions you can come to this session too (students participating in lab will be given priority).
Setup
Livecode link: here.
Livecode testing: here.
The goal of the second homework is reinforcing sets in Python and contrasting them against lists. We’ll reinforce that today. When you’re downloading the stencils, notice that there are two files: one for your code, and another for your tests.
These notes start by duplicating some of the content from the last class.
Reminder: Creating Lists, etc.
What do you want from this code?
ingredients = ['flour', 'salt', 'flour', 'sugar']
print(set(ingredients))
print(list(set(ingredients)))
In Python, the set
and list
functions create a new set (or list, respectively) containing the same elements as their argument. When we call set
on ingredients
, the extra flour
is lost because sets cannot contain duplicates. Even if we then return that set to list form, the loss has already happened.
Suppose we went further:
more_ingredients = list(ingredients)
more_ingredients.append('cinnamon')
print(more_ingredients)
print(ingredients)
We’ll see:
['flour', 'salt', 'flour', 'sugar', 'cinnamon']
['flour', 'salt', 'flour', 'sugar']
because list
creates a new list.
Last time, we ran this code:
recipes = {'pbj': {'peanut butter', 'jelly', 'bread'},
'smoothie': {'peanut butter', 'banana', 'oat milk'}}
chocolate_smoothie = recipes['smoothie']
chocolate_smoothie.add('cocoa powder')
and we saw that the value for recipes['smoothie']
was changed when we added to chocolate_smoothie
, because it was just another name for the same list. What if, instead, we’d run:
chocolate_smoothie = list(recipes['smoothie'])
In this case, the original recipe would stay intact! These two options…
…depend on whether we’re telling Python to make a new list or not.
These subtleties about how objects in memory (like lists and dictionaries) work can be powerful: it takes time to copy a list, and memory to hold the copy. But the fact that multiple names can refer to the same object can lead to subtle bugs.
Aside: Empty Sets
In Python, {}
represents an empty dictionary. To create an empty set, use the set
function and give it no arguments, i.e., set()
.
Testing functions that modify memory
Let’s say we have this (strange) function:
def add_len_to_list(l: list):
l.append(len(l))
How can we test it? We can’t look at what it returns, since it doesn’t return anything (technically, it returns the value None
). We might try to change the function, so that its body is return l.append(len(l))
, but it turns out that doesn’t help: the function still returns None
.
This is because the append
method itself returns None
: it’s not designed to produce a new list, but rather to modify the current list. We could work around this by adding return l
to the end of the function, but that strategy won’t always support testing more complex functions. (For example, what happens if the function needed to return something else, and the list update was just a side effect of running it? We’d still want to test the side effect as well as the output!)
So what can we do in general? We can start by creating a test list, calling add_len_to_list
on it, and then asserting something about the test list. An example might look like:
def test_add_len():
my_list = []
add_len_to_list(my_list)
assert my_list == [0]
This test checks the behavior of add_len_to_list
when it’s given an empty list.
What’s going on in memory when we run this test? Put another way, how many lists have been created by the time the assertion is checked?
Think, then click!
Two. However, only one of them is actually used for anything except expressing our expected value in the assertion. The program proceeds like this:
- Create a new, empty list in memory and remember where to find it (
l
). - Pass that list to the
add_len_to_list
function, which modifies it. The contents of the list has changed, but it’s still the same list object in memory, and we still remember how to find it (l
). - Check to see if
l
is “the same list” as the list[0]
. But, crucially, Python doesn’t interpret==
here as “the same object” but rather “contains equal elements in the same order”.
That last point is very subtle. Different objects define their own notion of ==
; I say a bit more about this at the end of these notes. The key is that there are two notions of equality here:
- “same object” (known as referential equality); and
- “same structure” (known as structural equality).
A good rule of thumb is that most built-in Python data structures have defined
==
to be structural, not referential.
If you’re even in doubt about whether two values reference the same object, you can use the id
function to produce an object identity and print it out. We’ll talk about this more later. For now, give this a try. What does it print?
print(id([1,2,3]))
print(id([1,2,3]))
A Warning
Watch out for reusing containers between tests that modify those containers. For instance, if I’d written:
def test_add_len():
l = []
add_len_to_list(l)
assert l == [0]
add_len_to_list(l)
assert l == [0]
I might have been very surprised (why?)
This is a toy example of something that can become a problem in a larger suite of tests. Be careful about changes to state in your test functions: try to keep changes isolated to a single function, and be aware of how the changes happen within it. You can avoid many (but not all) such problems by writing lots of short test functions, so that any state you created goes away between tests.
A digression on equality
Note that I said “an” empty set, not “the” empty set. It’s an important distinction, but unfortunately one that many (including me!) can miss when speaking. The difference comes down to context: do we mean to identify a list by its contents only, or are speaking of a particular object in memory? Again, it’s about referential versus structural equality. If I create an empty set twice:
s1 = set()
s2 = set()
How can you experiment to discover whether Python has made two distinct objects or not? (Hint: read the preceding section. There are also other ways.)
Analogy
Can you see the difference in intent between these two sentences?
- “To take the course, you need to have this textbook.”
- “I will loan you this textbook, but you need to return it.”
Both sentences invoke some notion of “sameness”. But in one I mean you should have an equivalent book (same author, title, etc.), and in the other I really mean the same physical book object.
Another Way: ==
vs. is
Notice what happens if we run:
list1 = [2]
list2 = [2]
print(list1 == list2)
What does this tell you about how Python compares lists? Do you think it may be comparing them according to their contents, or according to whether they are the same object in memory?
Think, then click!
It looks like it’s comparing them by their contents. We can confirm this using Python’s is
operator, which really does only ever check for object identity:
print(list1 is list2)
This prints False
.
When we get to defining our own object types, we’ll talk about how to define ==
, like Python defines it for lists.
Homework 1 Discussion
We’ll end by looking at some partial solution code for homework 1. While I won’t put it in the notes, I have no problems with showing some functions and discussing them in class. But there’s something very important we need to agree to: this code is a solution, not the solution. There’s lots of ways to do this assignment, and nothing I’m about to show you is perfect.
(See lecture capture for specifics.)