Last time we introduced some equality predicates at the end of class:
equal, equalp, eq, and eql.
In addition, there's yet another useful equality predicate,
which is simply =. The = predicate takes only numeric arguments;
anything else will cause an error. It works on numbers of different
type, so that
(= 4 4) returns T, and
(= 4 4.0) also returns T.
Which equality predicate does LISP's "member" function actually use?
It's EQL, not EQUAL. In fact, EQL is the default equality predicate
for LISP functions. So "my-member" would more accurately mimic the
behavior of "member" if it were written like this:
(defun my-member (the-item the-list)
(cond ((null the-list) nil)
((eql the-item (first the-list)) the-list)
(T (my-member the-item (rest the-list)))))
There's actually "keyword arguments" that allow you to change what
function is used for the equality test, but describing those is getting
ahead of ourselves. You may have come across this in the book already.
Other predicates
While we're on the topic of predicates, remember that a predicate
is a function that returns a "yes" or "no" (non-nil or nil) value.
There are other useful predicates that you should know about.
For example:
(atom *expr*) returns non-nil if *expr* is an atom,
nil if *expr* is not an atom
(numberp *expr*) returns non-nil if *expr* is a number,
nil if *expr* is not a number
(listp *expr*) returns non-nil if *expr* is a list,
nil if *expr* is not a list
(symbolp *expr*) returns non-nil if *expr* is a symbol,
nil if *expr* is not a symbol
(consp *expr*) returns non-nil if *expr* is a cons,
nil if *expr* is not a cons
Recall again that nil is both an atom and a list. Every list other than
the empty list is a cons, so we might define
(defun my-listp (expr)
(or (null expr) (consp expr)))
and
(defun my-atom (expr)
(not (consp expr)))
Historically, many functions designed to work as predicates
(i.e., returning true/false values) have had the letter "p"
appended to their names, hence "numberp" and "listp".
Obviously, folks haven't been too consistent in this, since
"atom" is not "atomp". It's quaint idiosyncrasies like this
that give any language some personality, no? Sometimes, this
sort of stuff filters into everyday language use. For
example, I've a few friends I play bridge (the card game) with a lot,
and when we want to ask one another to play, we say "bridgep?"
And if someone can't play they'll respond "nil". No, really.
Last time we wrote our own recursive "member" function which tests to
see if some element is a member of a list. Another useful function
computes the length of a list; its name is (very cleverly) "length". We
could write our own version as:
(defun my-length (the-list)
(cond ((null the-list) 0)
(t (+ 1 (my-length (rest the-list))))))
which would work like
(my-length '(a b c d)) => 4
This is just another quick example of easy functions with recursion.
Recursion
As noted earlier, y'all got that "my-member" function to
search successive elements of a list through the application of
an important computing concept called "recursion". Recursion
essentially means defining something in terms of itself. A
function is recursive if it (directly or indirectly) calls
itself. A recursive function consists of three parts:
1) the termination condition, or when to stop
2) the operation or modification, or what to do to the input
to move closer to a termination condition
3) the recursive call itself.
Recursion is a program control mechanism that allows
repetitive operations without traditional iteration, which
requires the use of side effects and the maintenance of
variables as counters or temporary storage places...things
which add unnecessary complexity. Using recursion
effectively requires a different style of thinking, but
you'll get better at it with practice if you find it
difficult early on. Recursion also results in nice, clean,
compact source code which is often easier to read than the
iterative equivalents. A recursive function can also eat up
lots of memory as it is running, but it doesn't necessarily
have to; we'll see more about this later.
The substitution model of evaluation
We briefly went over how the LISP evaluator works in a previous lecture.
Let's take a moment and go into this issue in a little bit more depth.
We won't go into all the details here; we'll leave some of the details
for the weeks to come. But this little discussion should give you
enough understanding to make you dangerous.
Let's say you've created a file with all the LISP code for evaluating the
quadratic formula that I gave you last week. Then let's say that you've
also added your implementation of the function for computing the square
root of the discriminant, as required by the first homework assignment.
Now you tell the LISP interpreter to evaluate all those defun functions,
as you were shown in your lab session. You're ready to compute stuff.
If you type (pos-root 1 0 -4) at the LISP evaluator, here's what will happen:
? (pos-root 1 0 -4)
2
?
You can follow that a little bit more closely by telling the evaluator that
you want to "trace" some of the functions that are being called:
? (trace pos-root pos-numerator sqrt-of-discriminant denominator)
NIL
?
Then when you type (pos-root 1 0 -4), you'll see something like this:
? (pos-root 1 0 -4)
Calling (POS-ROOT 1 0 -4)
Calling (POS-NUMERATOR 1 0 -4)
Calling (SQRT-OF-DISCRIMINANT 1 0 -4)
SQRT-OF-DISCRIMINANT returned 4
POS-NUMERATOR returned 4
Calling (DENOMINATOR 1)
DENOMINATOR returned 2
POS-ROOT returned 2
2
What's really happening is something like this: When I type the list
(pos-root 1 0 -4) at the interpreter, LISP assumes I want to invoke the
function "pos-root". LISP looks up the definition of "pos-root" and
extracts that definition. It then evaluates the arguments, 1, 0, and
-4, which in this case conveniently evaluate to themselves. Then,
LISP applies that definition to the evaluated arguments. This can be
viewed as a substitution, where:
(pos-root 1 0 -4)
becomes
(/ (pos-numerator 1 0 -4) (denominator 1 0 -4))
This expression is sent to the evaluator again. LISP evaluates the arguments,
but this time the arguments aren't numbers but are instead lists. So these
expressions are handed to the evaluator, and LISP treats them as function
calls.
The order in which these function calls, or any arguments in this example,
are evaluated won't be important. Why? Because of the functional programming
constraints we've followed, there are no side-effects. For example, computing
pos-numerator in no way affects the computation of denominator and vice-versa,
so it doesn't matter which one is computed first. (This will be a very nice
feature in the parallel-processing world of the future.) If, on the other
hand, one of these functions updated some global variable that was accessed
by the other, we would most definitely worry about which one was computed
first, resulting in a harder programming job, more complex software, and so on.
Still, something like Macintosh Common LISP has to evaluate one of these
arguments before the other, and the way it works (as do most LISP systems) is
to evaluate the arguments left to right. So LISP evaluates the expression
(pos-numerator 1 0 -4) and does the appropriate substitution:
(/ (pos-numerator 1 0 -4) (denominator 1 0 -4))
becomes
(/ (+ (- 0) (sqrt-of-discriminant 1 0 -4)) (denominator 1 0 -4))
This "substitution" process continues until LISP arrives at a solution
(which requires that all the functions you defined are eventually replaced by
functions that were already defined in LISP). In short, the LISP function
evaluation process can be described like this:
1. Look up and retrieve the function definition.
2. Evaluate the arguments to the function (by passing the
arguments themselves to the LISP evaluation function---
numbers evaluate to themselves, symbols evaluate to
the objects they're bound to, while lists are treated
as function calls and evaluated accordingly).
3. Apply the function definition to the evaluated arguments
(i.e., replace the argument place-holders in the new
function definition with the corresponding evaluated
arguments).
4. Substitute the result of all that for the original
function call from step 1.
5. Pass this new expression to the evaluator.
Warning: The is a very sketchy and hand-wavy description of the evaluation
process. It should suffice for now, but as time goes by, we'll beef this
description up with details about how various types of things get evaluated.
But rather than dump all the details on you at once, we'll add things
incrementally as we need them.
Analyzing recursion
Now that you're experienced with recursion in LISP and
the substitution model of evaluation, you can use the
substitution model to analyze the behavior of a recursive
function as it is evaluated. To do this, we'll introduce
a classic example for educating folks about recursion. It's
computing the factorial of an integer. The factorial function
is defined in one of two ways:
n! = n * (n-1) * (n-2) * ... * 2 * 1 if n > 0
1 if n = 0
or
n! = n * (n-1)! if n > 0
1 if n = 0
The first definition suggests a traditional iterative
approach to computing the factorial, while the second one
feels much more recursive. So let's implement the second
one:
(defun factorial (n)
(cond ((eql n 0) 1)
(T (* n (factorial (- n 1))))))
If we apply our substitution model of evaluation, we can
analyze what happens when we execute this program. When we
first invoke factorial, say on the integer 4, what goes on
the program stack is the equivalent of this (we'll use "fact"
instead of "factorial" to save space):
| | | | | | |
| | | | | | |
| | | | | | |
| | | | | | |
| | | | | | |
| | | | | | |
| | | | | | |
| | | | | | |
| | | | | | |
|(fact 4)| | | | | |
-------------------------------------------------------
Evaluating (fact 4) results in replacing (fact 4) with the
multiplication function and two arguments, the integer 4 and
a function call of (fact 3):
| | | | | | |
| | | | | | |
| | | | | | |
| | | | | | |
| | | | | | |
| | | | | | |
| | | | | | |
| |(fact 3)| | | | |
| | 4 | | | | |
|(fact 4)| * | | | | |
-------------------------------------------------------
Again, what's on top of the stack gets evaluated and is
subsequently replaced in our substitution model of
evaluation:
| | | | | | |
| | | | | | |
| | | | | | |
| | | | | | |
| | | | | | |
| | |(fact 2)| | | |
| | | 3 | | | |
| |(fact 3)| * | | | |
| | 4 | 4 | | | |
|(fact 4)| * | * | | | |
-------------------------------------------------------
This repeated substitution continues until we get to
(fact 0), which is a termination condition evaluating to 1:
| | | | | | |
| | | | |(fact 0)| 1 |
| | | | | 1 | 1 |
| | | |(fact 1)| * | * |
| | | | 2 | 2 | 2 |
| | |(fact 2)| * | * | * |
| | | 3 | 3 | 3 | 3 |
| |(fact 3)| * | * | * | * |
| | 4 | 4 | 4 | 4 | 4 |
|(fact 4)| * | * | * | * | * |
-------------------------------------------------------
So we hit the termination condition of our recursion, and in
the process of "unwinding" this recursion we return the
values of all these stacked or postponed computations:
| | | | | | |
| 1 | | | | | |
| 1 | | | | | |
| * | 1 | | | | |
| 2 | 2 | | | | |
| * | * | 2 | | | |
| 3 | 3 | 3 | | | |
| * | * | * | 6 | | |
| 4 | 4 | 4 | 4 | | |
| * | * | * | * | 24 | |
-------------------------------------------------------
Here's a slightly different notation for the substitution
model of evaluation. Maybe it'll give you a different
perspective on what's happening. It looks sort of like the
results of using the TRACE function (which you'll learn about
in lab, if you haven't already):
(fact 4)
(* 4 (fact 3))
(* 4 (* 3 (fact 2)))
(* 4 (* 3 (* 2 (fact 1))))
(* 4 (* 3 (* 2 (* 1 (fact 0)))))
(* 4 (* 3 (* 2 (* 1 1))))
(* 4 (* 3 (* 2 1)))
(* 4 (* 3 2))
(* 4 6)
24
The interesting thing to note here is "shape" of the growth
curve of the use of the program stack. Each time the
recursive call is made, we use up another chunk of program
stack. In fact, the use of memory grows linearly with the
integer n in the call to factorial (i.e., memory use is O(n)
for this algorithm).
What we see above is not just a picture of the factorial
procedure (i.e., the code, the program, the algorithm), it's
a look at the behavior of the "process"---the procedure in
execution. They are different, and it's important to be
aware of the distinction. In this case, we see a classic
pattern of process behavior, and it even has a name. Our
recursive procedure gives rise to what's called a "linear
recursive process".
O(n) memory use is not exactly something to be proud of.
We'd really like to do better than that. "Uh?" you might be
asking, "Is this the guy who told us not to worry about
saving a cycle here or a byte there for the sake of
efficiency?" Yes, it's still me, but here we're looking at
saving much more than a few cycles or a few bytes. Here
we're questioning whether we'll be able to compute factorials
for large integers without running into limitations of
available memory, and that's a whole different problem.
We can fix this. Here's how:
(defun factorial (n)
(factorial-iterative 1 1 n))
(defun factorial-iterative (product counter max-count)
(cond ((> counter max-count) product)
(T (factorial-iterative (* counter product)
(+ counter 1)
max-count))))
If we look at the behavior of the process that results from
this procedure, we get an entirely different shape:
| | | | |
|(fact 4)|(fact-it 1 1 4)|(fact-it 1 2 4)|(fact-it 2 3 4)| (continued...)
----------------------------------------------------------
| | | |
|(fact-it 6 4 4)|(fact-it 24 5 4)| 24 |
---------------------------------------
Or, using the other notation:
(fact 4)
(fact-it 1 1 4)
(fact-it 1 2 4)
(fact-it 2 3 4)
(fact-it 6 4 4)
(fact-it 24 5 4)
24
Now, isn't that nice? No growth in program stack space at
all, and we didn't trade away an increase in execution time
to get this either, as there are the pretty much the same
number of function calls, multiplications, and so on. So in
this case, our memory usage is O(1) instead of O(n), and that
is something to be proud of.
How did we get that solution? What makes it so magical? That's
the magic of "tail recursion". Think about it, and we'll talk about it
more next lecture.
Lecture notes by Kurt Eiselt, 1998.
Minor changes / additions by Brian McNamara, 1998.
Last updated on
Mon Jul 6 18:08:33 EDT 1998
by Brian McNamara