PL: Lecture #2  Tuesday, January 9th

Lists & Recursion

Lists are a fundamental Racket data type.

A list is defined as either:

  1. the empty list (null, empty, or '()),

  2. a pair (cons cell) of anything and a list.

As simple as this may seem, it gives us precise formal rules to prove that something is a list.

Examples:

null
(cons 1 null)
(cons 1 (cons 2 (cons 3 null)))
(list 1 2 3) ; a more convenient function to get the above

List operations — predicates:

null? ; true only for the empty list
pair? ; true for any cons cell
list? ; this can be defined using the above

We can derive list? from the above rules:

(define (list? x)
  (if (null? x)
    #t
    (and (pair? x) (list? (rest x)))))

or better:

(define (list? x)
  (or (null? x)
      (and (pair? x) (list? (rest x)))))

But why can’t we define list? more simply as

(define (list? x)
  (or (null? x) (pair? x)))

The difference between the above definition and the proper one can be observed in the full Racket language, not in the student languages (where there are no pairs with non-list values in their tails).

List operations — destructors for pairs (cons cells):

first
rest

Traditionally called car, cdr.

Also, any c<x>r combination for <x> that is made of up to four as and/or ds — we will probably not use much more than cadr, caddr etc.


Example for recursive function involving lists:

(define (list-length list)
  (if (null? list)
    0
    (+ 1 (list-length (rest list)))))

Use different tools, esp:

How come we could use list as an argument — use the syntax checker

(define (list-length-helper list len)
  (if (null? list)
    len
    (list-length-helper (rest list) (+ len 1))))

(define (list-length list)
  (list-length-helper list 0))

Main idea: lists are a recursive structure, so functions that operate on lists should be recursive functions that follow the recursive definition of lists.

Another example for list function — summing a list of numbers

(define (sum-list l)
  (if (null? l)
    0
    (+ (first l) (sum-list (rest l)))))

Also show how to implement rcons, using this guideline.


More examples:

Define reverse — solve the problem using rcons.

rcons can be generalized into something very useful: append.

Redefine reverse using tail recursion.

Some Style

When you have some common value that you need to use in several places, it is bad to duplicate it. For example:

(define (how-many a b c)
  (cond [(> (* b b) (* 4 a c)) 2]
        [(= (* b b) (* 4 a c)) 1]
        [(< (* b b) (* 4 a c)) 0]))

What’s bad about it?

In general, the ability to use names is probably the most fundamental concept in computer science — the fact that makes computer programs what they are.

We already have a facility to name values: function arguments. We could split the above function into two like this:

(define (how-many-helper b^2 4ac) ; note: valid names!
  (cond [(> b^2 4ac) 2]
        [(= b^2 4ac) 1]
        [else        0]))

(define (how-many a b c)
  (how-many-helper (* b b) (* 4 a c)))

But instead of the awkward solution of coming up with a new function just for its names, we have a facility to bind local names — let. In general, the syntax for a let special form is

(let ([id expr] ...) expr)

For example,

(let ([x 1] [y 2]) (+ x y))

But note that the bindings are done “in parallel”, for example, try this:

(let ([x 1] [y 2])
  (let ([x y] [y x])
    (list x y)))

(Note that “in parallel” is quoted here because it’s not really parallelism, but just a matter of scopes: the RHSs are all evaluated in the surrounding scope!)

Using this for the above problem:

(define (how-many a b c)
  (let ([b^2 (* b b)]
        [4ac (* 4 a c)])
    (cond [(> b^2 4ac) 2]
          [(= b^2 4ac) 1]
          [else        0])))

Tail calls

You should generally know what tail calls are, but here’s a quick review of the subject. A function call is said to be in tail position if there is no context to “remember” when you’re calling it. Very roughly, this means that function calls that are not nested as argument expressions of another call are tail calls. Pay attention that we’re talking about function calls, not, for example, being nested in an if expression since that’s not a function. (The same holds for cond, and, or.)

This definition is something that depends on the context, for example, in an expression like

(if (huh?)
  (foo (add1 (* x 3)))
  (foo (/ x 2)))

both calls to foo are tail calls, but they’re tail calls of this expression and therefore apply to this context. It might be that this code is inside another call, as in

(blah (if (huh?)
        (foo (add1 (* x 3)))
        (foo (/ x 2)))
      something-else)

and the foo calls are now not in tail position. The main feature of all Scheme implementations including Racket (and including Javascript) WRT tail calls is that calls that are in tail position of a function are said to be “eliminated”. That means that if we’re in an f function, and we’re about to call g in tail position and therefore whatever g returns would be the result of f too, then when Racket does the call to g it doesn’t bother keeping the f context — it won’t remember that it needs to “return” to f and will instead return straight to its caller. In other words, when you think about a conventional implementation of function calls as frames on a stack, Racket will get rid of a stack frame when it can.

You can also try this with any code in DrRacket: hovering over the paren that starts a function call will show a faint pinkish arrow showing the tail-call chain from there for call that are actually tail calls. This is a simple feature since tail calls are easily identifiable by just looking at the syntax of a function.

Another way to see this is to use DrRacket’s stepper to step through a function call. The stepper is generally an alternative debugger, where instead of visualizing stack frames it assembles an expression that represents these frames. Now, in the case of tail calls, there is no room in such a representation to keep the call — and the thing is that in Racket that’s perfectly fine since these calls are not kept on the call stack.

Note that there are several names for this feature:

When should you use tail calls?

Often, people who are aware of tail calls will try to use them always. That’s not always a good idea. You should generally be aware of the tradeoffs when you consider what style to use. The main thing to remember is that tail-call elimination is a property that helps reducing space use (stack space) — often reducing it from linear space to constant space. This can obviously make things faster, but usually the speedup is just a constant factor since you need to do the same number of iterations anyway, so you just reduce the time spent on space allocation.

Here is one such example that we’ve seen:

(define (list-length-1 list)
  (if (null? list)
    0
    (+ 1 (list-length-1 (rest list)))))

;; versus

(define (list-length-helper list len)
  (if (null? list)
    len
    (list-length-helper (rest list) (+ len 1))))
(define (list-length-2 list)
  (list-length-helper list 0))

In this case the first (recursive) version version consumes space linear to the length of the list, whereas the second version needs only constant space. But if you consider only the asymptotic runtime, they are both O(length(l)).

A second example is a simple implementation of map:

(define (map-1 f l)
  (if (null? l) l (cons (f (first l)) (map-1 f (rest l)))))

;; versus

(define (map-helper f l acc)
  (if (null? l)
    (reverse acc)
    (map-helper f (rest l) (cons (f (first l)) acc))))
(define (map-2 f l)
  (map-helper f l '()))

In this case, both the asymptotic space and the runtime consumption are the same. In the recursive case we have a constant factor for the stack space, and in the iterative one (the tail-call version) we also have a similar factor for accumulating the reversed list. In this case, it is probably better to keep the first version since the code is simpler. In fact, Racket’s stack space management can make the first version run faster than the second — so optimizing it into the second version is useless.

Sidenote on Types

Note: this is all just a side note for a particularly hairy example. You don’t need to follow all of this to write code in this class! Consider this section a kind of an extra type-related puzzle to read trough, and maybe get back to it much later, after we cover typechecking.

Types can become interestingly complicated when dealing with higher-order functions. Specifically, the nature of the type system used by Typed Racket makes it have one important weakness: it often fails to infer types when there are higher-order functions that operate on polymorphic functions.

For example, consider how map receives a function and a list of some type, and applies the function over this list to accumulate its output, so it’s a polymorphic function with the following type:

map : (A -> B) (Listof A) -> (Listof B)

But Racket’s map is actually more flexible that that: it can take more than a single list input, in which case it will apply the function on the first element in all lists, then the second and so on. Narrowing our vision to the two-input-lists case, the type of map then becomes:

map : (A B -> C) (Listof A) (Listof B) -> (Listof C)

Now, here’s a hairy example — what is the type of this function:

(define (foo x y)
  (map map x y))

Begin by what we know — both maps, call them map1 and map2, have the double- and single-list types of map respectively, here they are, with different names for types:

;; the first `map', consumes a function and two lists
map1 : (A B -> C) (Listof A) (Listof B) -> (Listof C)

;; the second `map', consumes a function and one list
map2 : (X -> Y) (Listof X) -> (Listof Y)

Now, we know that map2 is the first argument to map1, so the type of map1s first argument should be the type of map2:

(A B -> C) = (X -> Y) (Listof X) -> (Listof Y)

From here we can conclude that

A = (X -> Y)

B = (Listof X)

C = (Listof Y)

If we use these equations in map1’s type, we get:

map1 : ((X -> Y) (Listof X) -> (Listof Y))
      (Listof (X -> Y))
      (Listof (Listof X))
      -> (Listof (Listof Y))

Now, foo’s two arguments are the 2nd and 3rd arguments of map1, and its result is map1s result, so we can now write our “estimated” type of foo:

(: foo : (Listof (X -> Y))
        (Listof (Listof X))
        -> (Listof (Listof Y)))
(define (foo x y)
  (map map x y))

This should help you understand why, for example, this will cause a type error:

(foo (list add1 sub1 add1) (list 1 2 3))

and why this is valid:

(foo (list add1 sub1 add1) (map list (list 1 2 3)))

But…!

There’s a big “but” here which is that weakness of Typed Racket that was mentioned. If you try to actually write such a defninition in #lang pl (which is based on Typed Racket), you will first find that you need to explicitly list the type variable that are needed to make it into a generic type. So the above becomes:

(: foo : (All (X Y)
          (Listof (X -> Y))
          (Listof (Listof X))
          -> (Listof (Listof Y))))
(define (foo x y)
  (map map x y))

But not only does that not work — it throws an obscure type error. That error is actually due to TR’s weakness: it’s a result of not being able to infer the proper types. In such cases, TR has two mechanisms to “guide it” in the right direction. The first one is inst, which is used to instantiate a generic (= polymorphic) type some actual type. The problem here is with the second map since that’s the polymorphic function that is given to a higher-order function (the first map). If we provide the types to instantiate this, it will work fine:

(: foo : (All (X Y)
          (Listof (X -> Y))
          (Listof (Listof X))
          -> (Listof (Listof Y))))
(define (foo x y)
  (map (inst map Y X) x y))

Now, you can use this definition to run the above example:

(foo (list add1 sub1 add1) (list (list 1) (list 2) (list 3)))

This example works fine, but that’s because we wrote the list argument explicitly. If you try to use the exact example above,

(foo (list add1 sub1 add1) (map list (list 1 2 3)))

you’d run into the same problem again, since this also uses a polymorphic function (list) with a higher-order one (map). Indeed, an inst can make this work for this too:

(foo (list add1 sub1 add1) (map (inst list Number) (list 1 2 3)))

The second facility is ann, which can be used to annotate an expression with the type that you expect it to have.

(define (foo x y)
  (map (ann map ((X -> Y) (Listof X) -> (Listof Y)))
      x y))

(Note: this is not type casting! It’s using a different type which is also applicable for the given expression, and having the type checker validate that this is true. TR does have a similar cast form, which is used for a related but different cases.)

This tends to be more verbose than inst, but is sometimes easier to follow, since the expected type is given explicitly. The thing about inst is that it’s kind of “applying” a polymorphic (All (A B) ...) type, so you need to know the order of the A B arguments, which is why in the above we use (inst map Y X) rather than (inst map X Y).

Again, remember that this is all not something that you need to know. We will have a few (very rare) cases where we’ll need to use inst, and in each of these, you’ll be told where and how to use it.

Side-note: Names are important

An important “discovery” in computer science is that we don’t need names for every intermediate sub-expression — for example, in almost any language we can write something like:

s = (-b + sqrt(b^2 - 4*a*c)) / (2*a)

instead of

x₁ = b * b
y₁ = 4 * a
y₂ = y * c
x₂ = x - y
x₃ = sqrt(x)
y₃ = -b
x₄ = y + x
y₄ = 2 * a
s  = x / y

Such languages are put in contrast to assembly languages, and were all put under the generic label of “high level languages”.

(Here’s an interesting idea — why not do the same for function values?)