Control

So far, we’ve learned to execute statements sequentially:

>>> fahrenheit = 65 # Temperature outside
>>> celcius = (fahrenheit - 32) * 5 / 9
>>> celcius
18.333333333333332

What happens when we want to execute a statement conditionally? For example, what if we only want to print the temperature when it’s hotter than 70 degrees outside?

Luckily, Python has a feature for that:

>>> fahrenheit = 75 # Temperature outside
>>> if fahrenheit > 70:
...     print('HOT')
HOT

In general, this syntax looks like:

if <condition>:
    some_code_here
elif <other condition>:
    some_other_code
elif <other condition>:
    some_other_code
...
else:
    some_other_code

Example #1: FizzBuzz

Let’s try to work through a modified version of the common problem Fizzbuzz:

  • Given some number n,
    • If a number is divisible by 3, print Fizz
    • If a number is divisible by 5, print Buzz
    • If a number is divisible by both 3 and 5, print FizzBuzz

A common (but slightly wrong) solution

Translating the problem description directly into code, we get:

if n % 3 == 0:
    print("Fizz")
elif n % 5 == 0:
    print("Buzz")
elif n % 15 == 0:
    print("FizzBuzz")

Can you spot the error above? Hint: Think about what happens when n is 15.

The correct approach

if n % 15 == 0:
    print("FizzBuzz")
elif n % 3 == 0:
    print("Fizz")
elif n % 5 == 0:
    print("Buzz")

We need to check 15 first before checking 3 or 5! To elucidate this, let’s consider the case of n = 15. In the original solution, we would hit the first case of n % 3 == 0, since n is divisible by 3. Because of this, we would never hit the 15 case. To fix this, we put the most restrictive case first. This ensures that we will always hit the 15 case before we hit any others.


Iteration

Iteration is the repetition of a process in order to generate a sequence of outcomes
– Wikipedia

Suppose we’re now tasked with computing the Celcius equivalents of a range of temperatures in Fahrenheit. Of course, we could just copy our code from earlier for each temperature. However, as CS majors, we’re lazy, so we go hunting for a better way.

In Python, there are two keywords that help us with this: while and for. Specifically, these two keywords help with the task of iteration.


For loops

For loops take the following form:

for <variable> in <iterable>:
    <suite>

Python will loop through all variables in an iterable (something we’ll get into much later in the course) until the values in the iterable are exhausted. Each time Python loops through, it will assign values sequentially to the variable from the iterable and execute the suite. For now, don’t worry about these technical terms.


While loops

while <expression>:
    <suite>

Python will continue executing the suite until the expression is False.

Here is a good way to visualize it:

  1. What is the truthiness of <expression>?
    • True: Execute <suite> and go back to (1.)
    • False: Quit the loop.

For loops? While loops? Choosing one over the other.

You’ll notice that while and for loops look very similar. How would we choose one over another, and what benefits do they give?

The general explanation is that there is a very clear answer which is better. Generally speaking, for loops are used for a fixed-length sequence whose length is already known, whereas while loops are used for a sequence whose length we don’t know.

For example:

  • Square all numbers from 1 to 10 → for loop, since we know the sequence.
  • Find the seventh prime number → while loop, since we don’t know how many integers we will need.
Is it necessary to have two kinds of loops?1

Given that the two loops are so similar, a question might naturally arise: Are both kinds of loops necessary? Well, it turns out that some programming languages decided that, no, it’s definitely unnecessary. Google’s Go is a good example of this.


Example: Range of Temperatures

Let’s go back to the example and print a range of temperatures in Celcius from Fahrenheit.

Hint: range(begin, end) is a Python built-in that returns an iterable that will give you all integers between begin and end, including begin and excluding end. It is the mathematical equivalent of [begin, end).

# Let's find the celcius representations of fahrenheit 70~74
>>> for fahrenheit in range(70, 75):
...     celcius = (fahrenheit - 32) * 5 / 9
...     print(celcius)
21.11111111111111
21.666666666666668
22.22222222222222
22.77777777777778
23.333333333333332

Of course, this can be done with a while loop as well:

# Let's find the celcius representations of fahrenheit 70~74
>>> fahrenheit = 70
>>> while fahrenheit < 75:
...     celcius = (fahrenheit - 32) * 5 / 9
...     print(celcius)
...     fahrenheit += 1
21.11111111111111
21.666666666666668
22.22222222222222
22.77777777777778
23.333333333333332

An aside: Why does range use [begin, end)?

Doesn’t this default sound insane? 1

It turns out, it’s not as insane as it sounds. A handy outcome is that you can easily compute the number of elements by simply doing end - begin. Furthermore, range provides defaults as a one-argument function, where calling it with one argument would set the end, implicitly setting begin to 0. For example, range(10) would give all elements [0, 10).

For those of you coming in with prior programming experience, this is equivalent to C-like languages’ for loop:

for (int i = begin; i < end; i++) {
...
}

Defining New Functions

Let’s put everything we’ve learned all together with a more motivating example. In the previous code we wrote for computing Fahrenheit from Celcius. Suppose instead we now want to compute this temperature from anywhere, without needing to copy over all our code again. This motivates the need for a function.

A function can be thought of as a reusable piece of code with a certain number of inputs and an output (return value). We’ve seen examples of functions already (min, max, abs, to name a few). Now, we will be learning to define our own.

The syntax is as follows:

def <name>(<formal parameters>):
    <function body>
    return <return expression>

Note: In Python, the return statement is optional. If it is omitted, the return value will be set to None (which is Python’s null type).

Here is an example of a custom definition of min:

def min(x, y):
    if x < y:
        return x
    else:
        return y

Redefining Fahrenheit to Celcius as a Function

With this, we can now redefine our Fahrenheit to Celcius function.

def f_to_c(fahrenheit):
    celcius = (fahrenheit - 32) * 5 / 9
    return celcius

and we can simplify our earlier approach to printing all Celcius temperatures in a loop:

>>> for fahrenheit in range(70, 75):
...     print(f_to_c(fahrenheit))
21.11111111111111
21.666666666666668
22.22222222222222
22.77777777777778
23.333333333333332

Higher Order Functions

Now that we’re familiar with the idea of a function that returns a value, we can extend this to functions returning other functions.

A motivating example: Suppose we want to create a function that returns an incrementer.

def add_by(number):
    """
    Returns a one-argument function that returns `number + argument`
    """
    def new_incrementer(x):
        return number + x
    return new_incrementer

This allows us to do this:

>>> add_by_one = add_by(1)
>>> add_by_one(5)
6
>>> add_by_ten = add_by(10)
>>> add_by_ten(5)
15

Lambda Functions

Lambda functions, or anonymous functions, are one-liner functions that are defined without a name. This syntax looks like:

lambda <formal parameters>: <lambda body>

In normal functions, note that we use return to indicate a value to be returned. Since lambda functions are written in one line, the last value evaluated is implicitly returned. An example is given below.


Revisiting Fahrenheit to Celcius

Let’s make a lambda function called f_to_c_lambda that returns the Celcius temperature of some temperature in Fahrenheit:

>>> f_to_c_lambda = lambda fahrenheit: (fahrenheit - 32) * 5 / 9

This allows to use the same loop from before:

>>> for fahrenheit in range(70, 75):
...     print(f_to_c_lambda(fahrenheit))
21.11111111111111
21.666666666666668
22.22222222222222
22.77777777777778
23.333333333333332

Functions? Lambdas? Choosing one over the other.

In general, lambdas are used when a simple calculation is needed (ie. a one-liner). This will arise in places such as min, which accepts a list and an optional key value:

>>> my_list = ["apple", "bear", "carrot"]
>>> min(my_list) # default is alphabetical
'apple'
>>> length = lambda x: len(x)
>>> min(my_list, key=length) # now we use the lambda function we just defined
'bear'
>>> min(my_list, key=lambda x: len(x)) # we can also write this in one line
'bear'
>>> # Of course, we don't need a lambda function for this:
>>> min(my_list, key=len) # len works as well
'bear'

The Environment

Our subset of Python is now complex enough that the meaning of programs is non-obvious. What if a formal parameter has the same name as a built-in function? Can two functions share names without confusion? To resolve such questions, we must describe environments in more detail.
– Composing Programs

We briefly introduced the topic of environments in Week 1. They’re a good way of showing the execution of a Python program, and it makes execution mechanical by assigning rules. Before we jump into execution, let’s define a few terms. Composing Programs has a concise explanation of these terms:

An environment in which an expression is evaluated consists of a sequence of frames, depicted as boxes. Each frame contains bindings, each of which associates a name with its corresponding value. There is a single global frame. Assignment and import statements add entries to the first frame of the current environment.

Now, we can look at execution:

  1. When a Python program is started, a global frame is created.
  2. When there is an assignment statement, a name is bound to a value.
  3. When there is a function definition, the name of the function is bound to the same name in the frame.

    Putting this all together, we get something like this:


Calling User-Defined Functions

Calling functions take on the form of:

<name of function>(<arguments of function)

In Python, this is the way this is evaluated (as explored in note 1)

  1. First, we evaluate the operator in the current frame.
  2. Then, we evaluate each individual operand in the current frame.
  3. Once we have both the operator and the operands, we open a new frame, and we apply the operator the operands in the new frame. The frame opened will have the parent of the frame it was defined in.
    • With lambda functions, the name of the frame is lambda, whereas with defined functions, the name of the frame is the intrinsic name of the function (the name it was defined with).
  4. The value given by the return statement is called the return value, and becomes of the value of the entire call expression. In the example below, we see that the first function call opens a frame titled lambda, whereas the second function call opens a frame titled f_to_c. Note that both functions are defined in global, so both of their parents are the global frame. The return values of both are also bound in the global frame (where the function was called).

A Note on Lookup Order

When lookup happens in a frame, this is the order:

  1. Lookup name in current frame. Return value if found.
  2. Lookup name in parent frame. Return value if found.
  3. Continuously recurse to parent frames until we hit global frame.
  4. At global, lookup name in global frame. Return value if found.
  5. Error out if not found.

Example of erroring:

Above, we open up frames a and b, and we try to look up c. Since c doesn’t exist in frame b or a or global, we error out.

Example of valid lookup:

Above, we open up frames a and b the same way. This time, when we try to look up c, it once again does not exist in frame b. We look in its parent (a), where it does exist. As a result, we return 20.


Footnotes

  1. This is outside of the scope of this class  2