Breadth-first Numbering: Lazy Lists
In my last post, I wrote a simple functional queue as a stepping stone towards a breadth-first-numbering solution. By using an ordered and reversed list to represent the front and back of the queue, it’s cheap and easy to enqueue and dequeue items. A simple queue is fine for this toy example, but it’s inefficient, since it requires reversing the tail list whenever the head list is empty. We can do better with two improvements: lazy lists and incremental reversal.
Clojure’s lazy seqs seemed powerful and mysterious until I read
through chapter
3
of SICP. Building a lazy list is based on two simple operations,
delay
and force
:
1 2 3 4 5 6 7 8 9 10 11 |
|
Delay
wraps a form in an anonymous function of no arguments. It can
be stored and passed around like any other list, but won’t perform the
computation “stored” inside until it’s evaluated. Force
is the
opposite of delay, forcing a delayed form to evaluate. From these two
basics, we can build a lazy versions of cons
:
1 2 3 |
|
Racket’s macro system uses syntax-case
macros,
which are a little different from the comma-spliced defmacro
beasts you know
and love from Common Lisp and Clojure. In addition to enforcing good
hygiene,
the syntax-case macro system works by pattern matching against syntax
objects. In lcons
, any form that matches the pattern (lcons item
items)
is mapped to (cons item (delay items))
. In the delay
macro above, anything matching (delay form)
maps to (lambda ()
form)
. We’re still defining the ways we want to
change the syntax of our program, but the transformation is applied
at a different level: to syntax objects instead of raw s-expressions.
With lcons
finished, it’s easy to create lazy lists:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
|
Just as eager lists are composed of nested pairs, lazy lists are composed of nested, delayed pairs:
1 2 3 4 5 6 |
|
Like a normal list, the car
of each pair is the list item, and the
cdr
represents the rest of the list. But it doesn’t return the
next pair until it’s evaluated:
1 2 3 4 5 6 |
|
With this behavior in mind, we can write lazy car
and lazy cdr
:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
A take-n
function is also handy for converting lazy lists back to
eager ones:
1 2 3 4 5 6 7 8 |
|
And that’s it! We’ve written all the basics necessary for lazy lists. (For a few more lazy-fied list operations, see section 3.5.1 of SICP).
Finally, we should make one important optimization. Over the course of
list operations like lcdr
, the same delayed form can be called many
times. If the delayed computation is simple, this won’t be noticeably
inefficient. In our case, we’re just storing values that are
immediately returned (integers in these examples, and eventually some
node representation in our numbering solution). But there’s no
guarantee that delayed computations will be cheap! We could put
functions in a lazy list just as easily:
1 2 |
|
And those functions could require a lot of work:
1 2 3 4 |
|
In practice, we should memoize lazy computations, so subsequent calls look up their previously computed values. It’s an easy fix:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
Now that we’ve written lazy lists, we can use them to build an efficient functional queue for the breadth-first numbering problem.