Livecode link

Livecode link for recursion

2022 Livecode link for Tim’s prep

We finished last class with this DJData class.

class DJData:
    def __init__(self):
        self.queue = []
        self.num_callers = 0

    # Here's how to provide the length of the object, as if it were a list...
    def __len__(self):
        return len(self.queue)

    def request(self, caller: str, song: str) -> str:
        self.queue.append(song)
        self.num_callers += 1
        if self.num_callers % 1000 == 0:
            return "Congrats, " + caller + "! You get a prize!"
        else:
            return "Cool, " + caller 

The big difference compared to what we’ve done before is, the request function is bundled into the DJData class, instead of being some external thing that operates on a DJData object.

One thing that changes when we use non-dataclass classes is that we can (or even have to) implement our own versions of things like __init__. These methods need to take a self argument, but otherwise, they can do whatever we want them to do.

Requesting songs alone isn’t so interesting. We should also be able to play the songs that have been requested! Let’s extend our DJData class.

class DJData:
    def __init__(self):
        self.queue = []
        self.num_callers = 0

    def request(self, caller: str, song: str) -> str:
        self.queue.append(song)
        self.num_callers += 1
        if self.num_callers % 1000 == 0:
            return "Congrats, " + caller + "! You get a prize!"
        else:
            return "Cool, " + caller

    def play(self) -> str:
        song = self.queue[0]
        del self.queue[0]
        return song

But this isn’t good enough. What if you try to play a song when the queue is empty? Here we face a design decision, with a number of possible solutions. One possibility – a vague approximation to what a radio station might do – is to have a default song to play when nothing is on the queue. Another is to produce an error that the caller (which, in reality, is another Python program) can work with.

class DJData:
    def __init__(self):
        self.queue = []
        self.num_callers = 0        

    def request(self, caller: str, song: str) -> str:
        self.queue.append(song)
        self.num_callers += 1
        if self.num_callers % 1000 == 0:
            return "Congrats, " + caller + "! You get a prize!"
        else:
            return "Cool, " + caller 

    def play(self) -> str:
        if len(self.queue) == 0:
            return ''
        else:
            song = self.queue[0]
            del self.queue[0]
            return song

If there’s no song in the queue, the empty string is produced. How do you feel about this choice?

Think, then click!

How would we tell the difference between an actual song with no lyrics, and this special no-songs-queued situation?

In the “bad old days” when Tim was learning to program, this is what we’d have to do in many languages. We’d define some sort of sentinel value that encoded an error, and return it if that error happened. But if the return type of the function is always a string, can we ever safely encode the error as a string? (What would happen if a musician deliberately wrote a song with lyrics echoing our error?)

There are two much better alternatives. Here’s the first.

Widen The Return Type

If the error is of a type that isn’t a string at all, there’s no room for ambiguity. Python has a value, None, that’s often used for this sort of thing.

If we’re not using type-checking in Python, that’s all we have to do. If we’re using type checking in Python, this will cause an error in VSCode: None isn’t a str, and we’ve just promised to return one:

    def play(self) -> str:
        if len(self.queue) == 0:
            return None
        else:
            song = self.queue[0]
            del self.queue[0]
            return song

How can we fix this? By widening the type: we need to tell Python that we promise to return either a string or None. There are two ways to do this. The first, which only works in later versions of Python (3.10 and higher?) is to change str to str | None (the bar is for union). The second, which works in earlier versions like 3.9, is to add from typing import Union at the top of our file, and change str to Union[str, None]. And that’s it.

Raise An Exception

The second alternative is to raise an exception. The caller (again, recall this is another Python function or program) has asked to play a song that doesn’t exist! That’s an exceptional circumstance.

The key about exceptions is that they don’t get returned; they get raised. What’s the difference? It goes beyond this syntactic difference:

    def play(self) -> str:
        if len(self.queue) == 0:
            raise Exception('no song in queue')
        else:
            song = self.queue[0]
            del self.queue[0]
            return song

Suppose we had the following code calling play:

dj = DJData()
dj.request('tim', 'The Night')

print('getting song...')
song1 = dj.play()
print(song1)

print('getting song...')
song2 = dj.play()
print(song2)

print('moving on...')

When we run this, we get:

getting song...
The Night
getting song...
Traceback (most recent call last):
  File "/Users/tim/repos/cs0112/materials/Lectures/live/lecture16.py", line 31, in <module>
    song2 = dj.play()
  File "/Users/tim/repos/cs0112/materials/Lectures/live/lecture16.py", line 17, in play
    raise Exception('no song in queue')
Exception: no song in queue

Exceptions pre-empt the usual flow of control in a Python program. They demand to be handled. If we’d returned a None, we’d have seen the follow-up print statements execute. But with an exception, unless there’s something around to catch it, …

This is easier to show than to talk about.

The Debugger

Let’s return to the debugger. We saw it very briefly in class a week or so ago, but we’ll use it more today.

You can run the Python debugger via the Run menu in VSCode:

Running the debugger

This will run the program, but in a special debugging mode. Instead of just printing the exception and exiting, debugging mode will pause the program when an exception is raised and isn’t handled.

An exception being shown in the debugger

Notice also that we’ve got a bunch of new controls in debugging mode. They sort of look like the controls for playing a video.

Debugger controls in VSCode

We can move the execution of the program forward by clicking these buttons. The play button will just run, but the “step over” and “step into” buttons will move forward just one step (either passing over function calls or stepping into them, respectively). Let’s use “step into” by default.

Taking one step forward stopped the program! The unhandled exception has somehow made our program terminate right away.

Catching Exceptions

The code that calls play can explicitly handle exceptions this way:

try: 
    song2 = dj.play()
    print(song2)
except Exception:
    print('no such song!')

Now if we re-run, we won’t see the exception, even though it’s happening. To see this, we’ll add a breakpoint in the debugger. Let’s tell VSCode to pause execution just before the DJData object is created. We can do this by clicking just to the left of the line numbers in VSCode, on the red circle that appears:

A breakpoint

Now when we run the debugger, the program pauses at that point:

Pausing at the breakpoint

If we hit the step-over button, the pointer moves down one line. Pay special attention to the “Variables” window on the left, now. From there, we can see everything in the program dictionary at this point. That is, we can look at the value of dj without printing anything. If we click the > next to it, the tree expands to show us the current value of its fields. E.g., we see that the queue of this object is the empty list.

Keep clicking to move on. Eventually you’ll see the exception be thrown, but rather than crashing the program, it will fall into the except clause we wrote, and be handled gracefully:

An exception being handled

(We’ll talk more about exceptions soon.)

Objects In Memory

You can use the “Variables” tab to explore how objects are arranged in memory.

Python has four “primitive” types: integers, floats, strings, and booleans. These don’t have any fields—they aren’t “objects”. But lists, dictionaries, DJData object, etc. are objects.

(More on this in the near future.)

Revisiting Recursion

Let’s use the debugger to reinforce what’s happening when we process an HTMLTree (or, really, any recursive datatype). Recall the count_tree function we wrote last week (livecode link):

from htmltree import *

def count_tag(doc: HTMLTree, goal: str) -> int:    
    '''
    Counts the number of times the "goal" tag is present
    in the "doc" HTMLTree
    '''
    print(f'********** processing: {doc}')
    count = 0    
    for child in doc.children: 
        count = count + count_tag(child, goal)
    if goal == doc.tag:        
        count = count + 1    
    return count

tree = parse('<html><p>hello</p><strong><p>world</p></strong></html>')
count_tag(tree, 'p')

Create a breakpoint on the count_tag call, and step into it. (I’ve separated out the parse and count_tag calls into separate lines.)

Once you’ve stepped into one of the recursive count_tag calls, you might notice something in the “Call Stack” panel:

The call stack

That’s showing us the context of function calls. We were in count_tag, line 11 (where the recursive call is). But then we moved to a new call of count_tag, and are currently on line 8.

You can also see the value of various variables change between calls. If you click on a row in the stack, the values of the variables panel will change. So you can (e.g.) see that in the prior call, the value of child is the same as the value of doc in the recursive subcall.

If you’re ever confused about how exactly Python is executing, or how recursion is working for you, try out the debugger with breakpoints.

Likewise, if you’re unclear about the difference between returning and raising an exception, try out the debugger. In fact, if time permits, let’s try it out right now on the new exception we added to DJData.