Week 2 - A Deeper Dive Into Python
Table of Contents
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
- If a number is divisible by 3, print
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:
- 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)
?
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:
- When a Python program is started, a global frame is created.
- When there is an assignment statement, a name is bound to a value.
-
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)
- First, we evaluate the operator in the current frame.
- Then, we evaluate each individual operand in the current frame.
- 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).
- 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 inglobal
, 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:
- Lookup name in current frame. Return value if found.
- Lookup name in parent frame. Return value if found.
- Continuously recurse to parent frames until we hit global frame.
- At global, lookup name in global frame. Return value if found.
- 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
.