In my last post, titled My First Lisp Program, I wrote a simple program that would tell me whether each letter in a word was a consonant or a vowel. The program looked like this:
(defun string-to-list (string)
(loop for char across string collect char))
(defun is-vowel (char)
(if (find char "aeiou") t))
(defun letter-type (char)
(if (is-vowel char) "vowel" "consonant"))
(defun analyze-word (word-string)
(loop for char across word-string collect (letter-type char)))
Then someone posted a comment saying he might instead write a couple of the functions like this:
(defun letter-type (char)
(case char ((#\a #\e #\i #\o #\u) :vowel) (t :consonant))
(defun analyze-word (word)
(map `list `letter-type (string-downcase word)))
This code gives output that’s slightly different and, if I understand what the poster was saying, slightly faster:
CL-USER> (analyze-word "hello")
(:CONSONANT :VOWEL :CONSONANT :CONSONANT :VOWEL)
I’m certainly not concerned with speed here but it’s interesting to see how someone else would have written the same thing differently. I can’t say I fully understand how the CASE function works, I’ve only done seen a couple MAP examples and I my understanding of keys (:vowel and :consonant are keys, as opposed to “vowel” and “consonant” which are strings) is foggy at best. And what are those backticks for?
Conveniently, since Lisp is comprised of s-expressions (covered in this chapter of this book), we can break these functions down and look at their building blocks separately.
LETTER-TYPE and CASE
Let’s look at LETTER-TYPE first:
(defun letter-type (char)
(case char ((#\a #\e #\i #\o #\u) :vowel) (t :consonant))
This function relies heavily on a function called CASE. Upon closer inspection, it turns out CASE is actually a macro. The CMU page for it says so. They show that the form of the macro is this:
(case keyform
(keylist-1 consequent-1-1 consequent-1-2 ...)
(keylist-2 consequent-2-1 ...)
(keylist-3 consequent-3-1 ...)
...)
Do you understand that? I don’t. They don’t give any examples, either, so that doc isn’t all that helpful, at least not to me. Let’s see if we can kind of figure out what’s going on:
(defun letter-type (char)
(case char ((#\a #\e #\i #\o #\u) :vowel) (t :consonant))
That’s our definition for LETTER-TYPE. Can we pull out the CASE call, replace char with some character, and feed that through the REPL?
CL-USER> (case #\a ((#\a #\e #\i #\o #\u) :vowel) (t :consonant))
:VOWEL
Yes, we can. That’s what you would expect, too. Just to be sure, let’s try a consonant:
CL-USER> (case #\b ((#\a #\e #\i #\o #\u) :vowel) (t :consonant))
:CONSONANT
Yup. If we examine the CASE call, we can see that there are three arguments: #\a, ((#\a #\e #\i #\o #\u) :vowel) and (t :constant).
What is #\a? In my loose understanding, it’s a character, and that’s probably all that’s important for now.
What is ((#\a #\e #\i #\o #\u) :vowel)? It’s a list. The first item is (#\a #\e #\i #\o #\u) and the second item is :vowel. Let’s not analyze it further than that just yet.
What is (t :constant)? That’s a list as well. The first element is t (AKA the constant TRUE) and the second is :consonant.
We already have one CASE example, but let’s see if we can make our own example that might be easier to understand. What about looking for a number in a list of numbers instead of looking for a character in a list of characters?
CL-USER> (case 1 ((list 1 2 3) :yes) (t :no))
:YES
That seems to have the expected output. We’re asking, is 1 in the list 1, 2, 3? It is, so the answer is yes. Let’s try looking for something that’s not there:
CL-USER> (case 5 ((list 1 2 3) :yes) (t :no))
:NO
That’s the expected output as well.
Now that we’re looking at CASE from a couple different angles, it’s a little easier to tell what’s going on. It seems that if the first argument (in the latter case, 5) is found in the first list (1 2 3), the output will be the item inside that form (:yes). Otherwise, it will go on to the next list. I’m guessing that the (t :no) is kind of a catch-all, and if control gets there, :no will always be the output.
CL-USER> (case 1 (t :no))
:NO
That behavior is in-line with that expectation.
Now that we understand the CASE macro better, maybe its form will make more sense.
(case keyform
(keylist-1 consequent-1-1 consequent-1-2 ...)
(keylist-2 consequent-2-1 ...)
(keylist-3 consequent-3-1 ...)
...)
It certainly does. In our last numeric example, 5 is the keyform, (list 1 2 3) is keylist-1, :yes is consequent-1-1, t is keylist-2 and :no is consequent-2-1.
There’s something a little funny, though. In the LETTER-TYPE function, the first keylist looked listy, but it wasn’t wrapped in a LIST call. Does CASE accept different types for its keylists or is LIST just not necessary?
CL-USER> (case 1 ((1 2 3) :yes) (t :no))
:YES
It looks like LIST is just not necessary. Our number example works just fine without it.
That demystifies the mysteries of CASE (at least enough for me, for now), and since LETTER-TYPE is mostly just a CASE call, let’s move on to ANALYZE-WORD.
Here’s what the function looks like again:
(defun analyze-word (word)
(map `list `letter-type (string-downcase word)))
I don’t understand a lot about this function, so let’s start out with the highest-level part of it: MAP.
The MAP Function
Peter Seibel talks about MAP in the Collections chapter of his book. The first example he gives is this:
CL-USER> (map 'vector #'* #(1 2 3 4 5) #(10 9 8 7 6))
#(10 18 24 28 30)
It’s not too hard to tell what’s going on here: each number in the first list got multiplied by its corresponding number in the second list. That weird-looking argument, #'*, is the way you pass functions as arguments in Lisp. If we wanted to use a different function from *, we could:
CL-USER> (map 'vector #'list #(1 2 3 4 5) #(10 9 8 7 6))
#((1 10) (2 9) (3 8) (4 7) (5 6))
The 'vector argument tells MAP that the output should be a vector. We could get a list, instead, if we wanted to:
CL-USER> (map 'list #'* #(1 2 3 4 5) #(10 9 8 7 6))
(10 18 24 28 30)
Just like we did with CASE, let’s pull MAP out and use it by itself. In place of word, we’ll use “hello”:
CL-USER> (map `list `letter-type (string-downcase "hello"))
(:CONSONANT :VOWEL :CONSONANT :CONSONANT :VOWEL)
That works. To make things even simpler, let’s take out the STRING-DOWNCASE call. All that does is makes sure everything’s lower-case.
CL-USER> (map `list `letter-type "hello")
(:CONSONANT :VOWEL :CONSONANT :CONSONANT :VOWEL)
Same thing. Since we now know what the first two parameters MAP expects are, list and letter-type make plenty of sense. The "hello" may seem odd, but we’ll get to that in a second. First, I’m curious: why does this MAP call use backticks when our other one used single quotes? Do they have to be backticks?
CL-USER> (map 'list 'letter-type "hello")
(:CONSONANT :VOWEL :CONSONANT :CONSONANT :VOWEL)
Apparently not. Single quotes work just as well as backticks in this case. Let’s compare our two MAP calls.
Example A:
(map 'vector #'* #(1 2 3 4 5) #(10 9 8 7 6))
Example B:
(map 'list 'letter-type "hello")
Example A has a vector output and Example B has a list output, but that distinction is pretty trivial. What’s more important is the function and vectors being passed. At first, I was confused as to why Example A passed two vectors and Example B only passes one, but it actually makes sense once you think about it: the * function accepts two parameters and LETTER-TYPE takes just one. And a string is just a special vector, as you can see if we do this:
CL-USER> (map 'list 'letter-type #(#\h #\e #\l #\l #\o))
(:CONSONANT :VOWEL :CONSONANT :CONSONANT :VOWEL)
So it looks like one of MAP’s uses is a handy way to apply a single function to each element in a list.
Keys
I don’t feel qualified to explain keys yet, and I can’t find any great explanation online. Perhaps a future exercise will force us to gain an understanding of keys. For this one, they’re not really necessary.
In any case, we learned how to use two new things: CASE and MAP.