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.