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…

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

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.)