More Testing, Sets vs. Lists, and Problem Solving
Livecode link: here.
Livecode testing: here.
Homework 2 is out! The goal of the assignment is reinforcing sets in Python and contrasting them against lists. So that’s where we’ll start today. When you’re downloading the stencils, notice that there are two files: one for your code, and another for your tests.
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 in more complex functions.
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 are there in the program 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. 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[0]
. 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.
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?
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.
Try it in Python
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.