Design Patterns and Higher-Order Procedures
Summary:
In this reading, we explore the so-called higher-order
procedures, procedures that take procedures as parameters
or return procedures as results. In effect we are considering what
happens when we allow procedures to serve as values.
Background: Design Patterns
One mark of successful programmers is that they identify and
remember common techniques for solving problems. Such abstractions
of common structures for solving problems are often called
patterns or design patterns.
You should already have begun to identify some patterns. For example,
you know that procedures almost always have the form
(define procname
(lambda (parameters)
body))
You may also have a pattern in mind for the typical recursive
procedure over lists:
(define procname
(lambda (lst)
(if (null? lst)
base-case
(do-something (car lst) (procname (cdr lst))))))
In some languages, these patterns are simply guides to programmers as
they design new solutions. In other languages, such as Scheme, you can
often encapsulate
a design pattern in a separate procedure.
A Simple Pattern: Apply a Procedure to Each Value in a List
Let's begin with a simple problem: Suppose we have a list of colors
and we want to create three new lists: one that contains the red
component of each color, one that contains the largest component of
each color, and one that contains the average of the three components.
Suppose also that your first inclination is to use recursion to create
each list, perhaps because you've forgotten about map.
After some reflection, you might write something like the following
Here's how each works on the colors in the rainbow.
What do these three procedures have in common? Most of the
documentation, not only the six P's, but also the internal documentation.
All return null when given the null list. More importantly, all three
do something to the car of the list, recurse on the cdr of the list,
and then cons the two results together.
Hence, it is natural to design a general pattern for
apply a procedure to every value in a list.
(define procname
(lambda (lst)
(if (null? lst)
null
(cons (modify (car lst))
(procname (cdr lst))))))
Now, here's the cool part. We can even turn this pattern into a procedure.
Where does TRANSFORM come from? We could define it first
(as the preconditions suggest). For example, here's an application of
colors-transform in which we get just the red
component of a color.
Similarly, we can get the average component by defining
TRANSFORM as extract-ave-components.
You may find this strategy of defining TRANSFORM first
inelegant. We certainly do. It also won't work for all cases.
For example, what if we want to get the red components of all the
colors in one list and then get the average components of all the
colors in another list? We'd have to redefine TRANSFORM
right in the middle of the code. Ugly.
As importantly, we can't use local bindings to define
TRANSFORM. For example, consider the following
code.
So, we're stuck with redefining TRANSFORM using
define in the middle of our code, and that is unlikely
to be clear or convenient.
Is there a better solution? Yes! Instead of forcing
TRANSFORM to be defined before we call
colors-transform, we can make the
TRANSFORM procedure a parameter to the
colors-transform pattern. For example, here's a new
version of colors-transform that takes the
extra parameter.
If we plug in procedures (either previously-named procedures or newly
named procedures), we get the results we expect.
Thus, as you've seen before in this class, you can write your own
procedures that take procedures as parameters.
We call procedures
that take other procedures as parameters higher-order
procedures.
Of course, the idea of passing a procedure as a parameter should
be comfortable if you've been using map,
image-variant, foreach!,
and similar functions. In fact, map is likely
to be defined much like colors-transform (except
that officially, the order in which map builds
the result list is up to the implementer). We could even define our
own version (and you may even have already done so yourself).
Let us now return to the starting problems. What if we want
to get the red component of every color in a list? We map
rgb-red onto the list.
> (proc-map rgb-red colors-rainbow)
(255 255 255 0 0 102 79)
What if we want the average component? We might use
let to name the ave helper,
and then use map with that.
> (let ((ave (lambda (c)
(round (/ (+ (rgb-red c) (rgb-green c) (rgb-blue c)) 3)))))
(proc-map ave colors-rainbow))
(85 125 170 85 85 119 68)
Since we can write expressions like that, we would no longer need to write
extract-ave-components. We probably wouldn't even need
to write extract-red-components or
extract-max-components.
That observation suggests a second important moral: Once
you've defined a few higher-order procedures, like
map, you can often avoid writing other
procedures, since the higher-order procedures let you write more
general expressions.
For example, if we have a list of colors, and we want to compute a
lighter version of each
color in the list, we can use colors-transform,
proc-map, or even map.
> (define lighter-rainbow (proc-map rgb-lighter colors-rainbow))
Higher Order Predicates: Are they All Whatever?
There are many advantages to encoding design patterns in higher-order
procedures. An important one is that it stops us from tediously writing
the same thing over and over and over again. Think about writing the
predicates all-integer?, all-rgb?,
all-spot?, and so on and so forth. We've done so
many times. However, as our colleague, John Stone, says (in reference
to writing a sequence of similar procedures),
Writing and testing one of these definitions is an interesting and
instructive exercise for the beginning Scheme programmer. Writing and
testing another one is good practice. Writing and testing the third
one is, frankly, a little tedious. If we then move on to [others],
eventually programming is reduced to typing.
So, how do we avoid the repetitious typing? We begin with one of the
procedures.
Next, we identify the parts of the procedure that depend on our current
type (i.e., that everything is a list).
(define all-WHATEVER?
(lambda (val)
(or (null? val)
(and (WHATEVER? (car val))
(all-WHATEVER? (cdr val))))))
Finally, we remove the dependent part or parts from the procedure name
and make them parameters to the modified procedure.
Here's how we might test whether something is a list of numbers.
> (all integer? (list 1 2 3))
#t
> (all integer? (list 1 "two" 3))
#f
We can also define all-int? using
the all procedure.
(define all-int?
(lambda (lst)
(all integer? lst)))
The results are the same.
> (all-int? (list 1 2 3))
#t
> (all-int? (list 1 "two" 3))
#f
Built-in Higher-Order Procedures
We have seen that it is possible to write our own higher-order procedures.
Scheme also includes a number of built-in higher-order procedures.
You can read about many of them in section 6.4 of the Scheme report
(r5rs), which is available through the DrScheme Help Desk. Here
are some of the more popular ones.
The map Procedure
You already know about the basic use of the map
procedure. You also know how one might
implement it. It turns out that map has some
additional capabilities. For example, you can apply a procedure to
multiple lists (in which case it takes the corresponding value from
each list).
> (map + (list 1 2 3) (list .5 .6 .7))
(1.5 2.6 3.7)
> (define aardvark-grades (list 98 75 90 80 100))
aardvark-grades
> (define baboon-grades (list 80 82 84 86 88))
baboon-grades
> (define cheetah-grades (list 50 95 50 95 50))
cheetah-grades
> (define best-grades (map max aardvark-grades baboon-grades cheetah-grades))
best-grades
> best-grades
(98 95 90 95 100)
> (define aardvark-scaled (map (lambda (x) (* 100 x)) (map / aardvark-grades best-grades)))
aardvark-scaled
> aardvark-scaled
(100 78.94736842 100 84.21052632 100)
The apply Procedure
One of the most important built-in higher-order procedures is
apply, which takes a procedure and a list as
arguments and invokes the procedure, giving it the elements of the list
as its arguments:
> (apply string=? (list "foo" "foo"))
#t
> (apply * (list 3 4 5 6))
360
> (apply append (list (list 'a 'b 'c) (list 'd) (list 'e 'f)
null (list 'g 'h 'i)))
(a b c d e f g h i)
Unfortunately, since and and
or are not procedures but
keywords with their own evaluation rules,
we can't use them with apply.
> (map string? (list "alpha" 'beta "gamma"))
(#t #f #t)
> (and #t #f #t)
#f
> (apply and (map string? (list "alpha" 'beta "gamma")))
Error: eval: unbound variable: and
Anonymous Procedures, Revisited
Recall that in the examples above we wrote something like
> (let ((ave (lambda (c)
(round (/ (+ (rgb-red c) (rgb-green c) (rgb-blue c)) 3)))))
(map ave colors-rainbow))
Let's take this code apart. The let expression makes
ave the name of a procedure of one parameter that
takes a color and computes the average components. The body of the
let then applies this newly-defined ave to
each element of rainbow.
As you may recall from our discussion of Scheme's evaluation
strategy, when Scheme sees the name of a procedure, it substitutes
the body of the procedure. Hence, to Scheme, the
code above is essentially equivalent to
(map (lambda (c) (round (/ (+ (rgb-red c) (rgb-green c) (rgb-blue c)) 3)))
colors-rainbow)
Because Scheme does this substitution, we can also do it. That is,
we can write the previous code and have Scheme execute it.
> (map (lambda (c) (round (/ (+ (rgb-red c) (rgb-green c) (rgb-blue c)) 3)))
colors-rainbow)
(85 125 170 85 85 119 68)
So, what does this new code say? It says Apply a procedure
that averages the components to every element of the list
rainbow.
Do we care what that procedure is named?
No. We describe procedures without names as anonymous
procedures.
In effect, you can read lambda as a
procedure
. Hence, (lambda (params)
body) can be considered a shorthand for
a procedure of parameters params that computes
body
.
You may recall using anonymous procedures along with
higher-order procedures such as map,
image-variant, and
image-compute-pixels!.
You will find that you frequently
use anonymous procedures with higher-order procedures.
Returning Procedures
Just as it is possible for procedures to take procedures as their
parameters, it is also possible for procedures to produce new procedures
as their return values.
For example,
here is a procedure that takes one parameter, a number, and creates a
procedure that multiplies its parameters by that number.
Let's test it out.
> (make-multiplier 5)
#<procedure>
> (define timesfive (make-multiplier 5))
> (timesfive 4)
20
> (timesfive 101)
505
> (map (make-multiplier 3) (list 1 2 3))
(3 6 9)
We can use the same technique to build the legendary
compose operation that, given two functions,
f and g,
builds a function that applies g
and then f.
Here are some tests of that procedure.
> (define add2 (lambda (x) (+ 2 x)))
> (define mul5 (lambda (x) (* 5 x)))
> (define fun1 (compose add2 mul5))
> (fun1 5)
27
> (fun1 3)
17
> (define fun2 (compose mul5 add2))
> (fun2 5)
35
> (fun2 3)
25
Especially when using map, we often write anonymous
procedures that look something like the following.
(lambda (num) (* 2 num))
Even make-multiplier is actually something we
might want to generalize. You'll note that in that procedure, we
filled in one parameter (n) of a
two-parameter procedure (*). In pattern
form, we might write
(lambda (val) (BINARY-PROC ARG1 val))
Let's think about how we might turn that into procedures.
(left-section binary-proc
arg1) creates a new procedure
by filling in the first argument of a binary procedure.
(right-section binary-proc
arg2) creates a new procedure by filling in
the second argument of a binary procedure. We often abbreviate these
two procedures as l-s and r-s.
In the following example, we define procedures that multiply their
parameter by 2 or subtract 3 from their parameter, or some combination
thereof.
> (define mul2 (left-section * 2))
mul2
> (define sub3 (right-section - 3))
sub3
> (map mul2 (list 1 2 3 4 5))
(2 4 6 8 10)
> (map sub3 (list 1 2 3 4 5))
(-2 -1 0 1 2)
> (map (compose mul2 sub3) (list 1 2 3 4 5))
(-4 -2 0 2 4)
> (map (compose sub3 mul2) (list 1 2 3 4 5))
(-1 1 3 5 7)
> (map (compose (l-s * 2) (r-s - 3)) (list 1 2 3 4 5))
(-4 -2 0 2 4)
> (map (compose (l-s * 2) (l-s - 3)) (list 1 2 3 4 5))
(4 2 0 -2 -4)
Okay, what does left-section look like? The definition
is fairly straightforward.
How is right-section defined? We leave that as
an exercise for the reader.
Experienced Scheme programmers regularly use left-section
and right-section, not only in calls to
map and other higher-order procedures, but also
in defining new procedures. For example, consider the
all-int? procedure that we previously defined as
(define all-int?
(lambda (lst)
(all int? lst)))
Here is an even more concise definition that takes advantage of
l-s.
(define all-int? (l-s all int?))