VI From Pyret to Python
28 From Pyret to Python
Through our work in Pyret to this point, we’ve covered several core
programming skills: how to work with tables, how to design good
examples, the basics of creating datatypes, and how to work with the
fundamental computational building blocks of functions, conditionals,
and repetition (through filter
and map
, as well as
recursion). You’ve got a solid initial toolkit, as well as a wide
world of other possible programs ahead of you!
But we’re going to shift gears for a little while and show you how to work in Python instead. Why?
Seeing how the same concepts play out in multiple languages can help you distinguish core computational ideas from the notations and idioms of specific languages. If you plan to write programs as part of your professional work, you’ll inevitably have to work in different languages at different times: we’re giving you a chance to practice that skill in a controlled and gentle setting.
Why do we call this gentle? Because the notations in Pyret were designed partly with this transition in mind. You’ll find many similarities between Pyret and Python at a notational level, yet also some interesting differences that highlight some philosophical differences that underlie languages. The next set of programs that we want to write (specifically, data-rich programs where the data must be updated and maintained over time) fit nicely with certain features of Python that you haven’t seen in Pyret. A future release will contain material that contrasts the strengths and weaknesses of the two languages.
We highlight the basic notational differences between Pyret and Python by redoing some of our earlier code examples in Python.
28.1 Expressions, Functions, and Types
Back in Functions Practice: Cost of pens, we introduced the notation for functions and types using an example of computing the cost of an order of pens. An order consisted of a number of pens and a message to be printed on the pens. Each pen cost 25 cents, plus 2 cents per character for the message. Here was the original Pyret code:
fun pen-cost(num-pens :: Number, message :: String) -> Number:
doc: ```total cost for pens, each 25 cents
plus 2 cents per message character```
num-pens * (0.25 + (string-length(message) * 0.02))
end
Here’s the corresponding Python code:
def pen_cost(num_pens: int, message: str) -> float:
"""total cost for pens, each at 25 cents plus
2 cents per message character"""
return num_pens * (0.25 + (len(message) * 0.02))
Do Now!
What notational differences do you see between the two versions?
Here’s a summary of the differences:
Python uses
def
instead offun
.Python uses underscores in names (like
pen_cost
) instead of hyphens as in Pyret.The type names are written differently: Python uses
str
andint
instead ofString
andNumber
. In addition, Python uses only a single colon before the type whereas Pyret uses a double colon.Python has different types for different kinds of numbers:
int
is for integers, whilefloat
is for decimals. Pyret just used a single type (Number
) for all numbers.Python doesn’t label the documentation string (as Pyret does with
doc:
).There is no
end
annotation in Python. Instead, Python uses indentation to locate the end of an if/else statement, function, or other multi-line construct finishes.Python labels the outputs of functions with
return
.
These are minor differences in notation, which you will get used to as you write more programs in Python.
There are differences beyond the notational ones. One that arises with this sample program arises around how the language uses types. In Pyret, if you put a type annotation on a parameter then pass it a value of a different type, you’ll get an error message. Python ignores the type annotations (unless you bring in additional tools for checking types). Python types are like notes for programmers, but they aren’t enforced when programs run.
Exercise
Convert the followingmoon-weight
function from Functions Practice: Moon Weight into Python:fun moon-weight(earth-weight :: Number) -> Number: doc:" Compute weight on moon from weight on earth" earth-weight * 1/6 end
28.2 Returning Values from Functions
In Pyret, a function body consisted of optional statements to name intermediate values, followed by a single expression. The value of that single expression is the result of calling the function. In Pyret, every function produces a result, so there is no need to label where the result comes from.
As we will see, Python is different: not all “functions” return
results (note the name change from fun
to
def
).In mathematics, functions have results by
definition. Programmers sometimes distinguish between the terms “function”
and “procedure”: both refer to parameterized computations, but only
the former returns a result to the surrounding computation. Some
programmers and languages do, however, use the term “function” more
loosely to cover both kinds of parameterized computations. Moreover,
the result isn’t necessarily the last expression of the
def
. In Python, the keyword return
explicitly labels
the expression whose value serves as the result of the function.
Do Now!
Put these two definitions in a Python file.def add1v1(x: int) -> int: return x + 1 def add1v2(x: int) -> int: x + 1
At the Python prompt, call each function in turn. What do you notice about the result from using each function?
Hopefully, you noticed that using add1v1
displays an answer
after the prompt, while using add1v2
does not. This
difference has consequences for composing functions.
Do Now!
Try evaluating the following two expressions at the Python prompt: what happens in each case?
3 * add1v1(4)
3 * add1v2(4)
This example illustrates why return
is essential in Python:
without it, no value is returned, which means you can’t use the result
of a function within another expression. So what use is
add1v2
then? Hold that question; we’ll return to it in Modifying Variables.
28.3 Examples and Test Cases
In Pyret, we included examples with every function using where:
blocks. We also had the ability to write check:
blocks for more
extensive tests. As a reminder, here was the pen-cost
code
including a where:
block:
fun pen-cost(num-pens :: Number, message :: String) -> Number:
doc: ```total cost for pens, each 25 cents
plus 2 cents per message character```
num-pens * (0.25 + (string-length(message) * 0.02))
where:
pen-cost(1, "hi") is 0.29
pen-cost(10, "smile") is 3.50
end
Python does not have a notion of where:
blocks, or a
distinction between examples and tests. There are a couple of
different testing packages for Python; here we will use pytest
,
a standard lightweight framework that resembles the form of testing
that we did in Pyret.How you set up pytest and your test
file contents will vary according to your Python IDE. We assume
instructors will provide separate instructions that align with their
tool choices. To use pytest
, we put both examples
and tests in a separate function. Here’s an example of this for the
pen_cost
function:
import pytest
def pen_cost(num_pens: int, message: str) -> float:
"""total cost for pens, each at 25 cents plus
2 cents per message character"""
return num_pens * (0.25 + (len(message) * 0.02))
def test_pens():
assert pen_cost(1, "hi") == 0.29
assert pen_cost(10, "smile") == 3.50
Things to note about this code:
We’ve imported
pytest
, the lightweight Python testing library.The examples have moved into a function (here
test_pens
) that takes no inputs. Note that the names of functions that contain test cases must have names that start withtest_
in order forpytest
to find them.- In Python, individual tests have the form
assert EXPRESSION == EXPECTED_ANS
rather than theis
form from Pyret.
Do Now!
Add one more test to the Python code, corresponding to the Pyret testpen-cost(3, "wow") is 0.93
Make sure to run the test.
Do Now!
Did you actually try to run the test?
Whoa! Something weird happened: the test failed. Stop and think about that: the same test that worked in Pyret failed in Python. How can that be?
28.4 An Aside on Numbers
It turns out that different programming languages make different
decisions about how to represent and manage real (non-integer)
numbers. Sometimes, differences in these representations lead to
subtle quantitative differences in computed values. As a simple
example, let’s look at two seemingly simple real numbers 1/2
and
1/3
. Here’s what we get when we type these two numbers at a
Pyret prompt:
| |
|
| |
|
If we type these same two numbers in a Python console, we instead get:
| |
|
| |
|
Notice that the answers look different for 1/3
. As you may (or
may not!) recall from an earlier math class, 1/3
is an example of a
non-terminating, repeating decimal. In plain terms, if we tried to
write out the exact value of 1/3
in decimal form, we would need
to write an infinite sequence of 3
. Mathematicians denote this
by putting a horizontal bar over the 3
. This is the notation we
see in Pyret. Python, in contrast, writes out a partial sequence of
3
s.
Underneath this distinction lies some interesting details about representing numbers in computers. Computers don’t have infinite space to store numbers (or anything else, for that matter): when a program needs to work with a non-terminating decimal, the underlying language can either:
approximate the number (by chopping off the infinite sequence of digits at some point), then work only with the approximated value going forward, or
store additional information about the number that may enable doing more precise computation with it later (though there are always some numbers that cannot be represented exactly in finite space).
Python takes the first approach. As a result, computations with the
approximated values sometimes yield approximated results. This is what
happens with our new pen_cost
test case. While
mathematically, the computation should result in 0.93
, the
approximations yield 0.9299999999999999
instead.
So how do we write tests in this situation? We need to tell Python
that the answer should be “close” to 0.93
, within the error
range of approximations. Here’s what that looks like:
assert pen_cost(3, "wow") == pytest.approx(0.93)
pytest.approx
, to
indicate that we’ll accept any answer that is nearly the value we
specified. You can control the number of decimal points of precision
if you want to, but the default of ± 2.3e-06
often suffices.28.5 Conditionals
Continuing with our original pen_cost
example, here’s the
Python version of the function that computed shipping costs on an
order:
def add_shipping(order_amt: float) -> float:
"""increase order price by costs for shipping"""
if order_amt == 0:
return 0
elif order_amt <= 10:
return order_amt + 4
elif (order_amt > 10) and (order_amt < 30):
return order_amt + 8
else:
return order_amt + 12
The main difference to notice here is that else if
is written
as the single-word elif
in Python. We use return
to
mark the function’s results in each branch of the conditional.
Otherwise, the conditional constructs are quite similar across the
two languages.
You may have noticed that Python does not require an explicit
end
annotation on if
-expressions or functions. Instead,
Python looks at the indentation of your code to determine when a
construct has ended. For example, in the code sample for
pen_cost
and test_pens
, Python determines that the
pen_cost
function has ended because it detects a new
definition (for test_pens
) at the left edge of the program
text. The same principle holds for ending conditionals.
We’ll return to this point about indentation, and see more examples, as we work more with Python.
28.6 Creating and Processing Lists
As an example of lists, let’s assume we’ve been playing a game that involves making words out of a collection of letters. In Pyret, we could have written a sample word list as follows:
words = [list: "banana", "bean", "falafel", "leaf"]
In Python, this definition would look like:
words = ["banana", "bean", "falafel", "leaf"]
The only difference here is that Python does not use the list:
label that is needed in Pyret.
28.6.1 Filters, Maps, and Friends
When we first learned about lists in Pyret, we started with common
built-in functions such as filter
, map
, member
and length
. We also saw the use of lambda
to help us use
some of these functions concisely. These same functions, including
lambda
, also exist in Python. Here are some samples:
words = ["banana", "bean", "falafel", "leaf"]
# filter and member
words_with_b = list(filter(lambda wd: "b" in wd, words))
# filter and length
short_words = list(filter(lambda wd: len(wd) < 5, words))
# map and length
word_lengths = list(map(len, words))
Note that you have to wrap calls to filter
(and map
)
with a use of list()
. Internally, Python has these functions
return a type of data that we haven’t yet discussed (and don’t
need). Using list
converts the returned data into a list. If
you omit the list
, you won’t be able to chain certain
functions together. For example, if we tried to compute the length of
the result of a map
without first converting to a
list
, we’d get an error:
| |
|
Don’t worry if this error message makes no sense at the moment (we
haven’t yet learned what an “object” is). The point is that if you see
an error like this while using the result of filter
or
map
, you likely forgot to wrap the result in list
.
Exercise
Practice Python’s list functions by writing expressions for the following problems. Use only the list functions we have shown you so far.
Given a list of numbers, convert it to a list of strings
"pos"
,"neg"
,"zero"
, based on the sign of each number.Given a list of strings, is the length of any string equal to 5?
Given a list of numbers, produce a list of the even numbers between 10 and 20 from that list.
We’re intentionally focusing on computations that use Python’s built-in functions for processing lists, rather than showing you how to write you own (as we did with recursion in Pyret). While you can write recursive functions to process lists in Pyret, a different style of program is more conventional for that purpose. We’ll look at that in the chapter on Modifying Variables.
28.7 Data with Components
An analog to a Pyret data definition (without variants) is called a dataclass in Python.Those experienced with Python may wonder why we are using dataclasses instead of dictionaries or raw classes. Compared to dictionaries, dataclasses allow the use of type hints and capture that our data has a fixed collection of fields. Compared to raw classes, dataclasses generate a lot of boilerplate code that makes them much lighterweight than raw classes. Here’s an example of a todo-list datatype in Pyret and its corresponding Python code:
# a todo item in Pyret
data ToDoItemData:
| todoItem(descr :: String,
due :: Date,
tags :: List[String]
end
------------------------------------------
# the same todo item in Python
# to allow use of dataclasses
from dataclasses import dataclass
# to allow dates as a type (in the ToDoItem)
from datetime import date
@dataclass
class ToDoItem:
descr: str
due: date
tags: list
# a sample list of ToDoItem
MyTD = [ToDoItem("buy milk", date(2020, 7, 27), ["shopping", "home"]),
ToDoItem("grade hwk", date(2020, 7, 27), ["teaching"]),
ToDoItem("meet students", date(2020, 7, 26), ["research"])
]
Things to note:
There is a single name for the type and the constructor, rather than separate names as we had in Pyret.
There are no commas between field names (but each has to be on its own line in Python)
There is no way to specify the type of the contents of the list in Python (at least, not without using more advance packages for writing types)
The
@dataclass
annotation is needed beforeclass
.Dataclasses don’t support creating datatypes with multiple variants, like we did frequently in Pyret. Doing that needs more advanced concepts than we will cover in this book.
28.7.1 Accessing Fields within Dataclasses
In Pyret, we extracted a field from structured data by using a dot (period) to “dig into” the datum and access the field. The same notation works in Python:
|
| |
|
28.8 Traversing Lists
28.8.1 Introducing For
Loops
fun sum-list(numlist :: List[Number]) -> Number:
cases (List) numlist:
| empty => 0
| link(fst, rst) => fst + sum-list(rst)
end
end
In Python, it is unusual to break a list into its first and rest
components and process the rest recursively. Instead, we use a construct called a for
to
visit each element of a list in turn. Here’s the form of for
,
using a concrete (example) list of odd numbers:
for num in [5, 1, 7, 3]:
// do something with num
num
here is of our choosing, just as with the names
of parameters to a function in Pyret. When a for
loop
evaluates, each item in the list is referred to as num
in
turn. Thus, this for
example is equivalent to writing the
following:
// do something with 5
// do something with 1
// do something with 7
// do something with 3
for
construct saves us from writing the common code
multiple times, and also handles the fact that the lists we are
processing can be of arbitrary length (so we can’t predict how many
times to write the common code).Let’s now use for
to compute the running sum of a list. We’ll
start by figuring out the repeated computation with our concrete list
again. At first, let’s express the repeated computation just in
prose. In Pyret, our repeated computation was along the lines of “add
the first item to the sum of the rest of the items”. We’ve already
said that we cannot easily access the “rest of the items” in Python,
so we need to rephrase this. Here’s an alternative:
// set a running total to 0
// add 5 to the running total
// add 1 to the running total
// add 7 to the running total
// add 3 to the running total
my-running-sum
: Examples and Code, this framing might be familiar.Let’s convert this prose sketch to code by replacing each line of the
sketch with concrete code. We do this by setting up a variable named
run_total
and updating its value for each element.
run_total = 0
run_total = run_total + 5
run_total = run_total + 1
run_total = run_total + 7
run_total = run_total + 3
for
:run_total = 0
for num in [5, 1, 7, 3]:
run_total = run_total + num
for
in a function as we have done for
other examples earlier in this chapter. This is the final version.def sum_list(numlist : list) -> float:
"""sum a list of numbers"""
run_total = 0
for num in numlist:
run_total = run_total + num
return(run_total)
Do Now!
Write a set of tests for
sum_list
(the Python version).
Now that the Python version is done, let’s compare it to the original Pyret version:
fun sum-list(numlist :: List[Number]) -> Number:
cases (List) numlist:
| empty => 0
| link(fst, rst) => fst + sum-list(rst)
end
end
Here are some things to notice about the two pieces of code:
The Python version needs a variable (here
run_total
) to hold the result of the computation as we build it up while traversing (working through) the list.The initial value of that variable is the answer we returned in the
empty
case in Pyret.The computation in the
link
case of the Pyret function is used to update that variable in the body of thefor
.After the
for
has finished processing all items in the list, the Python version returns the value in the variable as the result of the function.
28.8.1.1 An Aside on Order of Processing List Elements
There’s another subtlety here if we consider how the two programs run:
the Python version sums the elements from left to right, whereas
the Pyret version sums them right to left. Concretely, the sequence of
values of run_total
are computed as:
run_total = 0
run_total = 0 + 5
run_total = 5 + 1
run_total = 6 + 7
run_total = 13 + 3
sum_list([list: 5, 1, 7, 3])
5 + sum_list([list: 1, 7, 3])
5 + 1 + sum_list([list: 7, 3])
5 + 1 + 7 + sum_list([list: 3])
5 + 1 + 7 + 3 + sum_list([list:])
5 + 1 + 7 + 3 + 0
5 + 1 + 7 + 3
5 + 1 + 10
5 + 11
16
+
in the
link
case can only reduce to an answer once the sum of the rest
of the list has been computed. Even though we as humans see the chain
of +
operations in each line of the Pyret unrolling, Pyret sees
only the expression fst + sum-list(rst)
, which requires the
function call to finish before the +
executes.In the case of summing a list, we don’t notice the difference between the two versions because the sum is the same whether we compute it left-to-right or right-to-left. In other functions we write, this difference may start to matter.
28.8.2 Using For
Loops in Functions that Produce Lists
Let’s practice using for
loops on another function that
traverses lists, this time one that produces a list. Specifically,
let’s write a program that takes a list of strings and produces a list
of words within that list that contain the letter "z"
.
As in our sum_list
function, we will need a variable to store
the resulting list as we build it up. The following code calls this
zlist
. The code also shows how to use in
to check
whether a character is in a string (it also works for checking whether
an item is in a list) and how to add an element to the end of a list
(append
).
def all_z_words(wordlist : list) -> list:
"""produce list of words from the input that contain z"""
zlist = [] // start with an empty list
for wd in wordlist:
if "z" in wd:
zlist = [wd] + zlist
return(zlist)
sum_list
, in that we
update the value of zlist
using an expression similar to what
we would have used in Pyret. For those with prior Python
experience who would have used zlist.append
here, hold that
thought. We will get there in Sharing List Updates.Exercise
Write tests for
all_z_words
.
Exercise
Write a second version of
all_z_words
usingfilter
. Be sure to write tests for it!
Exercise
Contrast these two versions and the corresponding tests. Did you notice anything interesting?
28.8.3 Summary: The List-Processing Template for Python
Just as we had a template for writing list-processing functions in
Pyret, there is a corresponding template in Python based on
for
loops. As a reminder, that pattern is as follow:
def func(lst: list):
result = ... # what to return if the input list is empty
for item in lst:
# combine item with the result so far
result = ... item ... result
return result
Keep this template in mind as you learn to write functions over lists in Python.