8.5

VI From Pyret to Python

28 From Pyret to Python

    28.1 Expressions, Functions, and Types

    28.2 Returning Values from Functions

    28.3 Examples and Test Cases

    28.4 An Aside on Numbers

    28.5 Conditionals

    28.6 Creating and Processing Lists

      28.6.1 Filters, Maps, and Friends

    28.7 Data with Components

      28.7.1 Accessing Fields within Dataclasses

    28.8 Traversing Lists

      28.8.1 Introducing For Loops

        28.8.1.1 An Aside on Order of Processing List Elements

      28.8.2 Using For Loops in Functions that Produce Lists

      28.8.3 Summary: The List-Processing Template for 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:

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 following moon-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:

Do Now!

Add one more test to the Python code, corresponding to the Pyret test

pen-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:

1/2

0.5

1/3

0.3

If we type these same two numbers in a Python console, we instead get:

1/2

0.5

1/3

0.3333333333333333

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 3s.

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:

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)

We wrapped the exact answer we wanted in 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:

len(map(len,b))

TypeError: object of type 'map' has no len()

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:

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:

travel = ToDoItem("buy tickets", date(2020, 7, 30), ["vacation"])

travel.descr

"buy tickets"

28.8 Traversing Lists

28.8.1 Introducing For Loops

In Pyret, we wrote recursive functions to compute summary values over lists. As a reminder, here’s a Pyret function that sums the numbers in a list:

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

The name 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

The 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

Note that this framing refers not to the “rest of the computation”, but rather to the computation that has happened so far (the “running total”). If you happened to work through the chapter on 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

This idea that you can give a new value to an existing variable name is something we haven’t seen before. In fact, when we first saw how to name values (in The Program Directory), we explicitly said that Pyret doesn’t let you do this (at least, not with the constructs that we showed you). Python does. We’ll explore the consequences of this ability in more depth shortly (in Modifying Variables). For now, let’s just use that ability so we can learn the pattern for traversing lists. First, let’s collapse the repeated lines of code into a single use of for:

run_total = 0
for num in [5, 1, 7, 3]:
   run_total = run_total + num

This code works fine for a specific list, but our Pyret version took the list to sum as a parameter to a function. To achieve this in Python, we wrap the 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:

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

In contrast, the Pyret version unrolls as:

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

As a reminder, the Pyret version did this because the + 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)

This code follows the structure of 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 using filter. 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.