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
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.
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))
What notational differences do you see between the two versions?
Here’s a summary of the differences:
Python uses underscores in names (like
pen_cost) instead of hyphens as in Pyret.
The type names are written differently: Python uses
Number. 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:
intis for integers, while
floatis for decimals. Pyret just used a single type (
Number) for all numbers.
Python doesn’t label the documentation string (as Pyret does with
There is no
endannotation 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
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.
ExerciseConvert the following
moon-weightfunction 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
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
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.
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.
In Pyret, we included examples with every function using
blocks. We also had the ability to write
check: blocks for more
extensive tests. As a reminder, here was the
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
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
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:
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 with
test_in order for
pytestto find them.
- In Python, individual tests have the form
assert EXPRESSION == EXPECTED_ANSrather than the
isform from Pyret.
Do Now!Add one more test to the Python code, corresponding to the Pyret test
pen-cost(3, "wow") is 0.93Make sure to run the test.
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?
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/3. Here’s what we get when we type these two numbers at a
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
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
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-06often suffices.
Continuing with our original
pen_cost example, here’s the
Python version of the function that computed shipping costs on an
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
mark the function’s results in each branch of the conditional.
Otherwise, the conditional constructs are quite similar across the
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
test_pens, Python determines that the
pen_cost function has ended because it detects a new
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.
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
label that is needed in Pyret.
When we first learned about lists in Pyret, we started with common
built-in functions such as
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
with a use of
list(). Internally, Python has these functions
return a type of data that we haven’t yet discussed (and don’t
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
map, you likely forgot to wrap the result in
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
"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.
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)
@dataclassannotation is needed before
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.
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:
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
visit each element of a list in turn. Here’s the form of
using a concrete (example) list of odd numbers:
for num in [5, 1, 7, 3]: // do something with num
numhere is of our choosing, just as with the names of parameters to a function in Pyret. When a
forloop evaluates, each item in the list is referred to as
numin turn. Thus, this
forexample is equivalent to writing the following:
// do something with 5 // do something with 1 // do something with 7 // do something with 3
forconstruct 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
run_total = 0 for num in [5, 1, 7, 3]: run_total = run_total + num
forin 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)
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
emptycase in Pyret.
The computation in the
linkcase of the Pyret function is used to update that variable in the body of the
forhas finished processing all items in the list, the Python version returns the value in the variable as the result of the function.
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
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
linkcase 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
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.
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
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
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
zlistusing an expression similar to what we would have used in Pyret. For those with prior Python experience who would have used
zlist.appendhere, hold that thought. We will get there in Sharing List Updates.
Write tests for
Write a second version of
filter. Be sure to write tests for it!
Contrast these two versions and the corresponding tests. Did you notice anything interesting?
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.