ICP22 lecture notes

Thursday, September 22 (happy first day of Fall!)

topics for the day:


quick quiz

Consider the following function:

def mystery(m):
    n = 0
    d = 1
    for _ in range(m):
        if m % d == 0:
            n = n + 1
        d = d + 1
    return n
  1. What would be the result of evaluating these expressions:

    >>> mystery(4)

    answer:

    >>> mystery(5)

    answer:


  2. Briefly(!), what does the function compute?

    answer: Returns the number of divisors of m. (Equivalent: Returns the number of factors of m.)


range revisited

we can use a variable in a for ... in range(...):

for i in range(5):
    ...

is equivalent to:

i = 0
for _ in range(5):
    ...
    i = i + 1

In other words, i takes on the values from 0 up to but not including 5.

We can also tell the range to begin somewhere other than 0:

for i in range(start, stop):
    ...

is equivalent to:

i = start
for _ in range(stop - start):   # start - start is how many times to repeat the loop
    ...
    i = i + 1

Remember range(start, stop) means begin at start, go up by one each time, but for the loop to end just before we get to stop:

>>> for x in range(3, 7):
        print(x)

    3
    4
    5
    6

So here is the quiz code renamed:

def count_factors(m):
    n = 0
    d = 1
    for _ in range(m):
        if m % d == 0:
            n = n + 1
        d = d + 1
    return n

This leverages range variables and start/stop version or range:

def count_factors(m):
    n = 2   # because 1 and m will always be factors
    for d in range(2, m):   # start at 2, go up to, but do not include m itself
        if m % d == 0:
            n = n + 1
    return n

This corrects above so that it works when m is 1:

def count_factors(m):
    n = 1   # because 1 and m will always be factors
    for d in range(2, m+1):   # start at 2, go up to and *include* m itself
        if m % d == 0:
            n = n + 1
    return n

nested if statements to choose between more than two options

def calculator(x, y, op):
    if op == '+':
        z = x + y
    else:
        if op == '-':
            z = x - y
        else:   # assuming anything else for op means "multiplication"
            z = x * y
    return z

In this way we can select from one of three (or more) cases. It assumes that op is either '+', '-' or '*' but it behaves so that anything other than '+' or '-' leads to multiplication.

Here is a further nested version to handle a fourth and final “error” case; it assumes op is '+', '*', '-', but, if not, it returns -1 to signal an error. (In class, we discussed how the problem with using a string result like 'error' instead of a number such as -1 is that it can lead to confusion over the type of the variable z - we would like to abide by the idea that within a single function a variable is assigned one and only one type. But returning -1 or any number is a bad idea. Later in the semester - time permitting - we will say a superior approach to error handling.)

def calculator(x, y, op):
    if op == '+':
        z = x + y
    else:
        if op == '-':
            z = x - y
        else:
            if op == '*':
                z = x * y
            else: # if not any of +, - or * then indicate an error
                z = -1
    return z

With each additional case we wish to add, we need to indent further. That cascading indentation is sometimes referred to as “the pyramid of doom” - it makes code quite difficult to read! Luckily, there is a better way.

Before we get to it, let’s consider what happens if we just use separate if statements:

def calculator(x, y, op):
    if op == '+':
        z = x + y
    if op == '-':
        z = x - y
    if op == '*':
        z = x * y
    else: # if not any of +, - or * then indicate an error
        z = -1
    return z

This looks cleaner - no pyramid of doom! - and it is fairly easy for the reader of the code to get the gist. However, it is wrong! If op is anything other than '*' then the final else branch is executed and the result is -1:

>>> calculator(9, 4, '*')   # this is fine!
    36

>>> calculator(9, 4, '+')   # watch out!
    -1

multiway if statement

Instead, we introduce the elif reserved word that is used to make a multiway conditional statement:

def calculator(x, y, op):
    if op == '+':
        z = x + y
    elif op == '-':
        z = x - y
    elif op == '*':
        z = x * y
    else: # if not any of +, - or * then indicate an error
        z = -1
    return z

This has the benefit of making the code easy to read (each case is at the same level of indentation), is correct (vastly more important!), and (since we are impatient computer scientists), once one of the if/elif test expressions evaluates to True and its corresponding branch is executed, the program skips to just past the last elif/else clause of the multiway branch.


Combining Boolean expressions with and, or,not`

Suppose we wish to write a function that takes a string as input and returns a similar string except all but the uppercase letters filtered out, like this:

>>> uppers_only('Computers Only Offer Loops!')
    'COOL'

Here is a way to do that with what we have studied so far:

def uppers_only(s):
    t = ''
    for c in s:
        if ord(c) >= ord('A'):
            if ord(c) <= ord('Z'):
                t = t + c
    return t

Makes sense. But seems complicated. The nested if is unsatisfying since it we are trying to convey that a character’s ASCII code is in a range (any of the codes that correspond to uppercase letters). Not surprisingly we can do better, by using a Boolean connective in this case and:

          ord(c) >= ord ('A') and ord(c) <= ord('Z')

Before we rewrite our function, let’s step back and introduce the three standard Boolean connectives in Python. (Bear in mind that in Python they are conveniently named, not so in many other PLs.)

We have already seen the “not equal” operator as in:

   <exp> != <exp>

that is really shorthand for:

   not (<exp> == <exp>)       # parentheses not necessary - included here for clarity

not is a reserved word in Python that operates on an expression to its right that is expected have been evaluated to a Boolean value and then it inverts the value:

>>> not False
    True
>>> not True
    false

and is a reserved word in Python that operates on two Boolean expressions, one to its left and one to its right. (So and is much like binary arithmetic operators, but it takes two Booleans and produces a Boolean.) Here is the “truth table” for and:

>>> False and False
    False
>>> False and True
    False
>>> True and False
    False
>>> True and True
    True

In other words (and not surprisingly!) the “and” of two Boolean values is true only if both its operands are true.

or behaves comparably: the “or” of two Boolean values is true if either of its operands are true:

>>> False or False
    False
>>> False or True
    True
>>> True or False
    True
>>> True or True
    True

We can use and to encode the idea of testing if a number is within a range of values:

def between(low, x, high):
    b = low <= x and x < high
    # Python allows low <= x < high  <--- BAD IDEA! do not use in our class!
    return b

earlier we used ASCII codes to check if one character is “less than” another:

def char_lt(c, d):
    b = ord(c) < ord(d)
    return b

Python does that automatically:

   'x' < 'y'

is translated (“under the hood”) to:

   ord('x') < ord ('y')

so we can use <, <=, >, >= directly on characters (and, a little later, we will see that we can use them on strings more generally):

def is_upper_char(ch):
    b = len(ch) == 1 and 'A' <= ch and ch <= 'Z'
    return b

Observe that we check that ch is a string of length 1.