Loopy

An anonymous commenter says the tail-recursive loop in my previous post would be clearer with loop. I agree in principle: recursion is more general than needed, so a loop should be clearer. Should be. But Common Lisp is not especially great at expressing iteration.

(loop for c = (peek-char nil stream eof-error-p)
  while (whitespacep c)
  do (read-char stream)
  finally (return c))

I'm one of those “knee-jerk anti-loopists”, so maybe I'm reading this with unfriendly eyes, but it doesn't look any clearer to me. It saves a line of explicit recursion, but spends one to explicitly return the result (but maybe I just don't know how to use loop; is there a better way?), which makes the control flow a little odd. Plus I don't like having to parse an S-expression!

What about ordinary do?

(do ((c (peek-char nil stream eof-error-p) (peek-char nil stream eof-error-p)))
  ((not (whitespacep c)) c)
  (read-char stream eof-error-p))

As usual, do has more non-form parentheses than I'd like, but it would be considerably shorter if it weren't for the hideous repetition of (peek-char nil stream eof-error-p). This is an unfortunate side-effect of a feature (not stepping variables with no step-form) I rarely (never?) use - if I wanted a non-stepping variable, I'd use let. In my opinion do would be much more useful if it handled the common case instead, and made a two-element binding clause (var step) equivalent to (var step step). One could also reduce the mystery parentheses a bit (and increase generality) at the cost of only a little verbosity, by making the end-test a standalone form:

(defmacro until (test &body result-forms)
  "Terminate a loop if TEST evaluates to true."
  `(when ,test (return (progn ,@result-forms))))

(do ((c (peek-char nil stream eof-error-p)))
  (until (not (whitespacep c)) c)
  (read-char stream eof-error-p))

That's not so bad.

I'm not the first Common Lisper to complain about the lack of iteration facilities. Jonathan Amsterdam's answer is iterate, which is basically loop with parentheses and more features - including one, finding, for writing this sort of loop. Here's the (untested) iterate version:

(iter (for c next (peek-char nil stream eof-error-p))
  (finding c such-that (not (whitespacep c)))
  (read-char stream eof-error-p))

It would be a two-liner, except that (finding ... such-that (complement #'whitespacep)) doesn't work - that shortcut only works if the right side is a (function ...) form.

That functional bit is a reminder that I'm going about this the wrong way. None of these loops approaches the clarity of the functional way. If the sequence functions worked on streams, reading characters as needed, we could just do something like:

(find-if (complement #'whitespacep) stream)

and unread the character afterward, if we got one. Or, if peeking returns a derived stream which delays removing each character until the next one is needed:

(find-if (complement #'whitespacep) (peeking stream))

These functional conveniences are less general than loop or iterate, but that's okay. Their lack of generality makes the common loops much simpler. Too bad they don't exist.

5 comments:

  1. Never heard of series?

    (collect-first (choose-if (complement #'whitespacep) (scan-stream stream #'read-char)))

    ReplyDelete
  2. Scan-stream doesn't do the peek-char thing, does it?

    I like the idea behind Series - writing loops as functional expressions on streams, with confidence that linear ones will be compiled efficiently - but I don't like that specific incarnation, because it's glaringly separate from the languge's other sequence operations. I realize this is impractical in CL, but series really ought to use the regular sequence operations, and the optimizations should apply to any linear sequence expression, as reliably on (reduce #'+ (mapcar #'foo somelist)) as on lazy streams. That's not too hard to guarantee, and it would make many loops much more readable. Something for a new lisp.

    ReplyDelete
  3. What makes this suck so bad, IMO, is that here we are in CL with a lot of powerful tools (loop, streams, series and recursion) and none of them really seem to be speaking to each other. Series would often more elegant, but now you have to change your code to use it, the names are poorly chosen (choose-if instead of filter, latch, etc.). And I often find that it's easier to get the same results in Haskell with just plain lists than with Series. Loop, which I like, is often verbose or inelegant. And streams are also a source of unnecessary complexity, and then you need the Gray streams library to do anything interesting.

    Other programming models, notably Haskell, get by with fewer abstractions than this and are just as serviceable. Why does this area seem particularly lame in Lisp?

    ReplyDelete
  4. here we are in CL with a lot of powerful tools (loop, streams, series and recursion) and none of them really seem to be speaking to each other.

    Yeah. And different collection types that require gratuitously different functions.

    I suspect Series' weird names are deliberate, to avoid colliding with other things.

    I often find that it's easier to get the same results in Haskell with just plain lists than with Series.

    Well, Haskell lists are lazy streams, and they can be (and are, in ghc, right?) optimized to loops just like Series. That's what happens when the language doesn't unnecessarily split basic data types.

    Other programming models, notably Haskell, get by with fewer abstractions than this and are just as serviceable. Why does this area seem particularly lame in Lisp?

    Maybe because Lisp is supposed to be more general, so our expectations are higher. We don't miss hashtables in Haskell because they'd be inconvenient to use, but in Lisp we expect a wider variety of tools.

    ReplyDelete
  5. I don't think the LOOP example is bad. Not that I like LOOP that much - I also prefer ITERATE. But LOOP is a bit longish - that's all. That there is a separate clause that tells what LOOP returns I'd see as a bonus. Some people think that the shortest code is the best. I don't - there are other readability criteria. When I read some iteration code I often want to know what it returns. With LOOP this is quite easy. Just read downwards and read the first word of each clause until you find a FINALLY.

    One could write it differently:

    (loop for bar = ... unless (foo-p bar) do (return bar) ...)

    True: if such a thing is used often, then write a functional abstraction (could use LOOP internally).

    ReplyDelete

It's OK to comment on old posts.