Lecture notes: Functions

How Pyret evaluates expressions

Imagine that you had the following contents in the definitions window:

include image

SCOOP-SIZE = 15
cone = flip-vertical(triangle(SCOOP-SIZE * 2, "solid", "tan"))
overlay-xy(circle(SCOOP-SIZE, "solid", "pink"),
  0, 25,
  overlay-xy(circle(SCOOP-SIZE, "solid", "green"),
    0, 25,
    cone))

How will Pyret evaluate this program?

Pyret processes the expressions one at a time from top to bottom.

When Pyret encounters something of the form name = expr, it creates an entry in an internal dictionary. The dictionary maps names to values. Thus, Pyret evaluates the expression on the right of the =, then makes a dictionary entry to associate the name with that value.

When Pyret encounters an expression that has other expressions nested within it, it evaluates the sub-expressions from left-to-right and innermost to outermost. Thus, in the flip-vertical expression (that creates the cone), the first expression to evaluate is SCOOP-SIZE (which Pyret looks up in the dictionary), then Pyret computes 15 * 2, then it creates the triangle, then flips the triangle.

In the longer overlay-xy expression, the pink circle gets created before the green circle (by the left-to-right rule).

You may have noticed that SCOOP-SIZE is all in caps, while cone is in lowercase. Conceptually, SCOOP-SIZE is a key concept in the program (which we plan to use multiple times), whereas cone is just naming an intermediate computation for readability. We use this naming convention to help us remember the respective roles of the names in the program. What happens if we change SCOOP-SIZE?

What does the “Run” button do?

When you press the Run button, Pyret erases the dictionary, then re-processes the definitions window from the beginning. Any dictionary entries that you made only in the interactions (right) window disappear.

Expressions and statements

We’ve already seen that there’s a difference between

SCOOP-SIZE = 15

and

SCOOP-SIZE * 2

and their impact on the interactions window: the first adds to the dictionary, while the second displays a value. A piece of a program that changes how future expressions will evaluate is called a statement. Creating a name meets this definition, since expressions will yield errors or not depending on whether a name appears in the dictionary. Expressions perform computations without changing the information that Pyret maintains for running future expressions.

This distinction will become more meaningful later in the course, but we’ll use the terminology to get used to it now.

Functions

Consider programs to draw a couple of slightly different striped snakes.

pattern1 = frame(beside(rectangle(30, 100, "solid", "dark-green"),
    beside(rectangle(30, 100, "solid", "black"),
      rectangle(30, 100, "solid", "white"))))

pattern2 = frame(beside(rectangle(30, 100, "solid", "dark-red"),
    beside(rectangle(30, 100, "solid", "black"),
      rectangle(30, 100, "solid", "blue"))))

These snakes have some things in common! It might be nice to be able to avoid writing very similar code twice. We could try writing it just once, then changing the colors for each snake. In order to do that, we can write a function.

What we’re going to do is to find the places that the two snakes differ, then give names to those places. Here, the left and right colors are different, but everything else stays the same. Because there might end up being other snakes with similar patterns, we can assume that the middle color might change as well. Let’s call these colors left, middle, and right. We’re going to use these names to create a new operator, called stripe-pattern. We do that like this:

fun stripe-pattern(left, middle, right):
...
end

stripe-pattern is the name of the function. We call its inputs (left, middle, and right) its parameters.

What replaces the ...? We can copy the expression from pattern1, but replace the colors with the names we came up with:

fun stripe-pattern(left, middle, right):
frame(beside(rectangle(30, 100, "solid", left),
    beside(rectangle(30, 100, "solid", middle),
      rectangle(30, 100, "solid", right))))
end

The expression inside the function is called the function’s body.

We can call it just like any other operator:

pattern1 = stripe-pattern("dark-green", "black", "white")
pattern2 = stripe-pattern("dark-red", "black", "blue")

What if we make a mistake, and try to use the function as follows:

> stripe-pattern(50, "black", "white")

What do you think should happen? 50 is not a string (much less a string naming a color). Pyret will give an error on one of the rectangle commands (we’ll see which one in particular in a little while). The point is that the error comes up inside the computation that creates the pattern.

It would be much more helpful if we got the error when we tried to use stripe-pattern. We can annotate the inputs with types telling Pyret what kind of data the function expects to receive:

fun stripe-pattern(left :: String, middle :: String, right :: String):
  frame(beside(rectangle(30, 100, "solid", left),
      beside(rectangle(30, 100, "solid", middle),
        rectangle(30, 100, "solid", right))))
end

If you try the erroneous call again, you’ll see that the location where the error is reported has changed.

We can also add a type to the output of the function. This will tell anyone calling the function about the contexts in which they can use it. Pyret will complain if the function’s body has the wrong type.

fun stripe-pattern(left :: String, middle :: String, right :: String)
  -> Image:
  frame(beside(rectangle(30, 100, "solid", left),
      beside(rectangle(30, 100, "solid", middle),
        rectangle(30, 100, "solid", right))))
end

More Functions Practice: Cost of pens

Imagine that you are trying to compute the total cost of an order of pens with slogans (or messages) printed on them. Each pen costs 25 cents plus an additional 2 cents per character in the message (count spaces as characters for now).

First, let’s write two expressions that do this computation, just to make sure we understand how it should work:

# if ordering 3 pens that say "wow"
> 3 * (0.25 + (string-length("wow") * 0.02))
# if ordering 10 pens that say "smile"
> 10 * (0.25 + (string-length("smile") * 0.02))

Having a clear idea of the computation you want to do makes it much easier to write a function to do it. How do we figure out a function that would correspond to these two expressions? We find the places where the two functions are different, circle them, and label each of them with a different unique name. These names become the parameters of the function.

fun pen-cost(num-pens :: Number, message :: String) -> Number:
  num-pens * (0.25 + (string-length(message) * 0.02))
end

The initial expressions we developed give rise to our examples. When you write the expected results of the examples, you can either write the final answer or the expression that computed it (each can be useful in certain cases). Here’s the final function:

fun pen-cost(num-pens :: Number, message :: String) -> Number:
   num-pens * (0.25 + (string-length(message) * 0.02))
where:
  pen-cost(3, "wow") is 0.87
  pen-cost(10, "smile") is 10 * (0.25 + (string-length("smile") * 0.02))
end

What are other good cases to check here? Ordering 0 pens and pens with empty messages are both good cases to check. You don’t have to check negative numbers of pens or messages that aren’t strings though – those fall outside the expected “contract” of the function (we’ll discuss how to properly handle the negatives later).

Once you are comfortable with the idea of writing functions, you would skip the circle-and-label step, and just jump directly to writing the expression with the parameters in place. Use the circle-and-label technique as needed to get yourself to that point.