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…

Two Possibilities

…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?

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.