Session 12
Recursive Programs and Programming Language
Opening Exercise
This is an easy problem to visualize.
Suppose you have a list of at least two numbers. For example:
(6 1 2 -3 8 4 -1 2 9 1 2 4)
Your task:
Write a function (2nd-max lon)
that returns the
value of the second-largest item in the list of numbers
lon
.
For example:
> (2nd-max '(6 1 2 -3 8 4 -1 2 9 1 2 4)) 8
You may solve this problem with or without recursion.
Without recursion, you will need higher-order functions.
With recursion, follow the data structure!
If you would like to use a function but aren't sure whether it is a Racket primitive, ask.
Bonus points to the shortest solution and to the most efficient solution!
Surveying Solutions
How many of you wrote recursive solutions? Functional ones? How short? How efficient? What ideas did you try?
Functional Solutions
What functions can help us?
If run-time efficiency is no concern, we can solve this in almost no code: sort the array from largest to smallest, then return the second element!
Racket has a primitive sort
function that takes two
arguments: a list of values and a function for comparing the items
in the list. sort
can handle a list of any type as
long as the comparator works on the values in the list.
With a list of numbers, demo with these comparators:With a list of strings, demo with these comparators:
<
(lambda (m n) (> (abs m) (abs n)))
string<?
(lambda (m n) (> (string-length m) (string-length n)))
With sort
in hand, our solution is easy to write:
(define 2nd-max (lambda (lon) (second (sort lon >))))
We can make the solution even shorter by using syntactic sugar
for the definition: (define (2nd-max lon) ...)
.
If the thought of sorting the entire list to find two items makes you uneasy, then we might try another approach:
- Find the largest value in the list.
- Remove that item from the list.
- Find the largest value in the remaining list.
That may sound like it requires looping, but think back to the functional style of programming we saw in Session 6 when we computed total fuel for a set of space modules and in Session 7 when we computed total margin of victory for a set of games. In both cases, we used function calls and higher-order functions to replace loops.
We can use the Racket primitives apply
and
max
to do Steps 1 and 3. For Step 2, we need a
function that removes an item from a list. We wrote such a
function for lists of symbols in Session 9, called
remove-first
.
It turns out that Racket has a primitive function named
remove
that does the same thing, but for any type of list. Let's use it!
All we need to do is invoke this sequence functionally:
(define 2nd-max (lambda (lon) (apply max ; step 3 (remove ; step 2 (apply max lon) ; step 1 lon)))) ; step 2
How do these compare to other solutions the class offered?
The first solution is O(n log n). The latter is O(n), but makes three passes down the list. Can we do better?
Recursive Solutions
As we know, a list of numbers such as:
(6 1 2 -3 9 4 -1 2 8 1 2 4)
has an inductive definition:
<list-of-numbers> ::= () | (<number> . <list-of-numbers>)
We know that the lists given to 2nd-max
have at
least two items, so maybe we need to define a new data type.
Let's think about the problem a bit first.
How might we approach this problem if we could write a loop? We would probably...
-
Create two local variables,
largest
and2nd-largest
. - Initialize the variables using the first two items in the list.
- Look at each remaining item in the list to see if it is greater than either of the two variables and, if so, update the variables.
This seems easy enough, though we have to think about several cases as we initialize and update the variables. Notice, though, that after we peel the first two numbers off the front of the list, we have a good old list of numbers, empty or a pair. Our existing data type is what we need after all.
We can use structural recursion to start building a helper function
that works this way. This function will process the list of numbers
after the first two are removed, and it will receive the local
variables largest
and 2nd-largest
. So:
(define 2nd-max-tr (lambda (largest 2nd-largest lon) (if (null? lon) ; return the answer ; handle a pair )))
Can you imagine why I used the suffix "-tr" in the name?
The then clause deals with the case where we have seen all
the numbers in the list. At this point, 2nd-largest
holds the second largest value in the list, so we return it:
(define 2nd-max-tr (lambda (largest 2nd-largest lon) (if (null? lon) 2nd-largest ; handle a pair )))
The else clause handles a pair. It can do what our loop
would do in an imperative program: compare the next item in the
list, (first lon)
, to largest
and
2nd-largest
and update them accordingly.
(define 2nd-max-tr (lambda (largest 2nd-largest lon) (if (null? lon) 2nd-largest (cond ((> (first lon) largest) (2nd-max-tr (first lon) largest (rest lon))) ((> (first lon) 2nd-largest) (2nd-max-tr largest (first lon) (rest lon))) (else (2nd-max-tr largest 2nd-largest (rest lon)))))))
Now all we have to do is write 2nd-max
as
an interface procedure
that kicks off the computation, initializing the variables with
the values of the first two items in the list:
(define 2nd-max (lambda (lon) (2nd-max-tr (max (first lon) (second lon)) (min (first lon) (second lon)) (rest (rest lon)))))
The result is a function that examines each item in the list exactly once and finds the second-largest item. Our recursive solution is longer but more efficient: it makes only one pass over the list. We have traded programmer time for execution time.
Unfortunately, the helper function we wrote is repetitive and ugly.
We handle all three possibilities for (first lon)
— largest, second largest, and neither — with a
cond
expression. This requires us to repeat the
recursive call in all three cases. We can do better!
Let's use our interface procedure as an inspiration for something simpler:
(define 2nd-max-tr (lambda (largest 2nd-largest lon) (if (null? lon) 2nd-largest (2nd-max-tr new value of largest ; -- handle (first lon) new value of second ; -- in these expressions new value of lon ; handle (rest lon) ))))
In an imperative solution, we would assign new values to the two variables and branch to the top of the loop, which updates the position in the array for the next pass. Doing the same thing here, on the recursive call, simplifies the helper function. After a little work (... think of it as a tournament with three teams ...), we arrive at:
(define 2nd-max-tr (lambda (largest 2nd-largest lon) (if (null? lon) 2nd-largest (2nd-max-tr (max largest (first lon)) (max 2nd-largest (min largest (first lon))) (rest lon)))))
Notice that 2nd-max-tr
is tail recursive,
an idea we learned about
last time.
It looks a bit like a loop — and acts like one, too! Not
only does this solution make only pass over the loop, it uses one
stack frame, no matter how long the list is. And it's shorter than
our first recursive function (which was also tail recursive: check
out that cond
expression!).
I don't know about you, but I think this is a dandy little solution.
Comparing Our Solutions
Note: This section is just an outline of the things we talked about in class. I'll fill it in as time permits.
How might we compare these solutions? Here are some of the dimensions we might consider:
- length of the code
- complexity of the code
- space used at run-time
- time used at run-time
- ...
- complexity of the code
- ...
- familiarity
It's worth taking some time to think about which of these dimensions are objective, or relatively so, and which are subjective. Subjective evaluations can change with time, learning, and experience. You may have already begun to think differently about Racket and functional programming than you did at the beginning of the course.
What happens if we are not given the precondition that
2nd-max
always receives a list with at least two
items? How should our function act if it receives fewer than two
items?
- We can guard against the "error" case in all solutions.
-
When we have an interface procedure, we can guard against
the error once, and after that recur on the full definition
of a list of numbers. There is no need to test for
(rest (rest lon))
ever again!
A few passing thoughts...
- These solutions remind us that there is a difference between reading code and writing it.
- It is usually better to get a solution down in code and then refactor it into something better than to try to write a perfect solution from the outset.
- Local variables might be nice in some of our more complex functions. They are coming soon! But notice how we use functions and parameter passing to simulate local variables. That's a hint for how local variables work.
A Transition in the Course
First, we learned a new language, using that process to learn a new way to think about languages: in terms of primitives, means of combination, and means of abstraction.
Then we used the new language to learn a new style of programming: functional programming. It uses functions, both ordinary and higher-order, to solve problems.
Then we used the new language and style to learn patterns of recursive programming. These patterns are especially useful for processing inductive data types.
Now we are ready to begin moving into a new phase of the course, in which we apply our new skills to learn how our programming languages work.
Next session, and for most of the rest of the course, we will use what we have learned thus far about functional and recursive programming to explore issues in programming languages and their paradigms. That is a big space to work in. Let's start in one small corner of the programming languages world: where variables get their values from.
Static Properties of Variables
A property is static when its value can be determined by looking at the text of a program. A property is dynamic when the program must be executed in order to determine the property. We can use static properties of a program to detect errors and to improve program performance at interpretation or compile time.
We have already discussed one property of variables this semester: their data type. In Ada, the data type of a variable is static. In Java, it is static, with a twist allowed by the substitutability of objects of different classes. In Racket, the data type of a variable is dynamic — though we typically write our code with a specific set of values and operations in mind.
In this session, we will begin to explore some of the static properties that variables can have.
A Little Language
How can recursive programming help us explore language features? It turns out that the syntax of a programming language is defined inductively!
The definitions of Python, Java, and Racket are quite large. Many of the features of these languages are syntactic sugar, added to make programmers' lives easier. But we don't need all of those features to understand how the languages work.
Much of our work the rest of the semester will use small languages with essential features as a way to study how languages work. Today we introduce such a language. We will use a family of similar languages to study different language features throughout the course.
If our goal is to explore where variables get their values from, what features does out little language need? Think about how function calls work in Racket and other languages. They require:
- a way to define a function that has a formal parameter
- a way to call a function, which binds a value to the formal parameter
- a way to use a variable in an expression
Today's little language has just these features:
<exp> ::= <varref> | (lambda (<var>) <exp>) | (<exp> <exp>)
This little language provides the bare minimum: an expression in the language is either a variable reference, a function of one parameter, or an application of a function to one argument. This is all we need to study the topic du jour, which is the static property of free and bound variables.
Quick Exercise: Earlier, we discussed the three things that every programming language has. Which of these features does our little language have? Which features is it missing?
Quick Answer: This language does not have primitive
values of all sorts you are used to; its only primitives are
variable references. Its means of combination is a subset of
Racket's parenthesized operations. The language has procedural
abstraction via lambda
. It has naming abstractions,
too: a value is associated with a formal parameter whenever we
call a function.
This language is Racket-like in its use of parentheses and its
use of lambda
to define a nameless function, but it
is universal in the features it provides. All languages you
know and love have the same three features, plus many more. We
will add more features to this language as time goes by, always
with an eye to how our language processors would manipulate
programs written in the language.
Free and Bound Variables
As you know, a variable can have a value or not have a value.
Of course, in some languages, such as Java, every variable has a
value, even if the program does not initialize it. For example,
a Java integer defaults to 0, and object variables default to
null
.
Unfortunately, null
isn't really a value at all. Many
of you encounter "null pointer exceptions" at run-time as you learn
how to write Java programs. These exceptions point out that the
variable in question doesn't really have a value, and as
a result the program cannot proceed.
On top of this idea of a variable having a value is the idea of how the variable receives its value. If a variable has the same name as the formal parameter on a method, then we know that the variable is "bound" to that declaration, and that the value of the variable will be the value passed as the corresponding argument to the function.
int sumOfSquares( int m, int n ) { // m and n are bound to formal parameters return m*m + n*n; }
Let's define "boundedness" and some related ideas that apply to all programming language:
- A variable is bound or occurs bound in an expression if it refers to the formal parameter in the expression.
- A variable is free or occurs free in an expression if it is not bound.
- A variable that is not bound must be bound to something dynamically, that is, at run time, in order to be used. We say that such variables are globally bound. In Racket, all language primitives are bound dynamically.
- A function definition that contains no free variables is called a combinator. A combinator does not depend on any system primitives.
Two quick items:
- You will sometimes see the terms lexically bound or statically bound used to describe a variable that is bound by a formal parameter or other declaration.
-
Can a variable occur in an expression and not be used?
In Session 13, we consider this possibility.
In Racket and our little language,
above, lambda
expressions define functions and so are
the source of boundedness.
Examples
Now let's see some examples from our little language that illustrate these concepts:
In this expression,
(lambda (z) x)
x
occurs free in the body of the lambda
.
z
is a parameter, not a variable reference.
z
does not occur in the body, so it is neither
bound nor free.
In this expression,
((lambda (x) x) y)
x
is bound and y
is free.
We can capture that y
by enclosing the
expression in another lambda
expression that has
y
as its formal parameter:
(lambda (y) ((lambda (x) x) y) )
The expression:
(lambda (x) x)
always has the same meaning: it returns the value of its argument unchanged. This function is called the identity function. It is also a combinator. It does not depend on any free variables, so its meaning is independent of any run-time concerns.
You may ask, Of what use is the identity function? In order to discover the answer, ask yourself: Where else we have used identity values in our programs?
This expression:
(lambda (f x) (f (f x)))
is also a combinator. It does not have the same value in all
contexts, because it uses a function passed as an argument,
f
, to determine its value. Yet it is entirely
independent of any run-time concerns, as it makes no reference to
any system primitives. This sort of function is an important tool
in functional programs, as it uses higher-order function to build
programs.
We often write combinators in Racket. For example, the
compose
function,
which implements the sort of function composition you learn
about in algebra, is a combinator:
(define compose (lambda (f g) (lambda (x) (f (g x)))))
However, some of the functions you may think of as having only bound variables have free variables. This Racket function is not a combinator:
(define sum-of-applications (lambda (f x y) (+ (f x) (f y))))
Why? Because +
is a free variable! Remember: Racket
functions are bound to names in just the same way as any other
value. You can verify this for yourself by evaluating:
> (let ((+ *)) ((lambda (f x y) (+ (f x) (f y))) add1 1 4)) 10
One note before we proceed. We will be writing programs to
process programs our little language. The data type we will be
processing is the grammar of the little language. That grammar
uses Racket list notation, but that is an implementation detail.
Calls to car
, cdr
, and cons
will litter our code with implementation details. They will also
obscure what our code means.
Soon we will write functions to access the parts of the expressions in our data type. These functions will allow us to:
- test whether an expression is a variable reference, a function, or an application, and
- extract the parts of functions and applications.
The former are called type predicates, and the latter are called access procedures.
We will read more about these next week.
Wrap Up
-
Reading
- Study these notes, especially the sections beginning at the static properties of variables. We will review a couple of the ideas in that section next time and move on to writing programs that use them.
-
Homework
- Homework 5 is available and due on Monday.
-
Quiz
- Quiz 2 will be two weeks from today, on March 7.