Eliminating FORMAT from Lisp (2003)
cs-www.cs.yale.edu
cs-www.cs.yale.edu
Similarly, I like LOOP more than I'm supposed to, if I go by what I read online.
An ambitious young student of Lisp came to Master Foo, seeking to deepen her knowledge and understanding.
One day, during their studies and practice, the students stopped and said, "Master, I am troubled. In Lisp we have minimal syntax, and we use S-expressions to convey structure, and this syntactic uniformity gives us great power to build our own composable syntax constructs. And yet we have LOOP in the standard, which is an ad-hoc imitation of the same arbitrary syntax of other languages that we use Lisp in order to avoid!"
The next day, the Master brought the student on a hike into the hills. As the sun was setting late in the afternoon, they reached a small cabin near the summit of a hill. A village of huts was visible in the valley below them. "Observe that this building is constructed from many small pieces of wood, each nearly uniform in size and shape. With those pieces I long ago built a hut that resembles the shoddy huts of the village, yet this one is both strong and easy to modify. You could even build it yourself from plans." The student observed and agreed, and was very impressed, but was also confused. "Master, what does this teach me about Lisp?" she asked. Master Foo continued, "You shall stay here on the mountain tonight. Return to me in the morning."
With that, the Master drew a key from his robe, firmly locked the door of the cabin, and hiked away. As he did so, a cold rain and steady wind began to blow. The student spent the night cold and damp on the ground beside the cabin, and awoke enlightened.
I prefer them to the functional versions sometimes so I don’t have to write lambda, the for loops have options for combining multiple lists, etc.
I have no doubt much of this can be done with the racket constructs. But I still find I can more easily read loop usages I've written in the past. Whereas many similarly dense for/folds are opaque within a few minutes for me. :(
I do appreciate the convenience and brevity of FORMAT as it currently exists, but I also think it would be pretty cool to have a proper S-expression macro that expands to a FORMAT control string. It would be analogous to a macro that generates SQL or HTML.
(format t (formatter "~a-~b") x y)
is like (format t "~a-~b" x y)
except that "~a-~b" is transformed into a lambda which takes a stream and two arguments, and which does the specified formatting.10000% agree. LOOP and FORMAT are excellent examples of using lisp to build dsls. so much so that they made it into the ANSI standard
Yes, but with great power comes great responsibility. The problem with FORMAT and LOOP is not that they are DSL's, but rather that they are badly designed DSLs. The reasons are different in both cases.
FORMAT is badly designed because it is write-only, kind of like Perl regexps. It is very, very hard to debug a complex FORMAT string.
LOOP, by way of very stark contrast, is very readable, even more so than regular Lisp. But it is badly designed because it is chock-full of non-orthogonal constructs. For example, WITH is completely equivalent to a set of LET bindings outside the loop, so it doesn't actually add any functionality. All it does is give you a new non-lispy syntax for creating external bindings. The semantics of FOR depend on what comes after. "for x =" does something completely different from (for example) "for x in". And the list of problems with LOOP goes on and on and on.
So the problem is not that DSLs are bad, the problem is that these DSLs are bad. But since they are part of the standard, we are stuck with them.
Additionally the LOOP sees the WITH definition in standard Common Lisp. It does not see any outside LET bindings, unless the Common Lisp would provide a feature to ask the current environment. This allows the LOOP to, for example, see a type declaration and provide a default value for the common cases.
(let (a) ; a is initialized to NIL
(declare (type integer a))
....
(loop repeat 10 do (incf a))
...
a)
vs. (loop with a fixnum ; a is initialized to 0
repeat 10 do (incf a)
finally (return a))
It provides a compact notation with ONE scoping construct, the LOOP macro.Similar for the LOOP name. In the same way you could argue that this is just a named BLOCK around the LOOP construct. But, again, the name inside the LOOP makes it clear that the this is the name of this LOOP construct.
Btw., the ITERATE macro works in a similar way.
Of course it would. This:
(loop with a = ...)
is exactly equivalent to: (let ((a ...)) (loop ...))
and so you can e.g. capture with-bindings in closures: (funcall (loop with a = 123 return (lambda () a))) ==> 123
> Additionally the LOOP sees the WITH definition in standard Common Lisp. It does not see any outside LET bindings, unless the Common Lisp would provide a feature to ask the current environment. This allows the LOOP to for example see a type declaration and provide a default value for the common cases.I think it is highly questionable whether providing implicit typed default values is actually a useful feature, but assuming for the sake of argument that it is, why not just make a general binding construct that does this? If it's useful, it should be useful (and usable) everywhere, not just inside a loop.
> you could argue that this is just a named BLOCK around the LOOP construct
Indeed you could. :-)
> the name inside the LOOP makes it clear that the this is the name of this LOOP construct.
Yes, but why is that useful? How is (loop named foo ...) any better than (block foo (loop ...)) ? And if it is better, then why not (progn named foo ...) ? (tagbody named foo ...) ?
(loop with a = ...) maybe equivalent to (let ((a ...)) (loop ...))
but
(let ((a ...)) ... (loop ...) ...) is not equivalent to (loop with a = ...)
The difference is that in the latter it is not clear to the Lisp developer that A is meant as a LOOP local variable.
That's a design choice, not bad design.
Historically LOOP was named FOR and was only a tiny part of CLISP (Conversational Lisp) in Interlisp. There CLISP had a different syntax than normal Lisp, including iteration, binding, infix operations, etc. The idea of such a LOOP macro was then brought to Maclisp, ZetaLisp and, eventually, ANSI Common Lisp.
That's because in the latter example, A is NOT a loop-local variable. If you write:
(let ((a ...)) (some-form))
then it is clear that a is local to (some-form) regardless of what (some-form) actually is. If you write:
(let ((a ...)) (some-form) (some-other-form))
then obviously it is not clear whether a is local to (some-form) or (some-other-form) or both.
But so what? If it matters that this be clear, just don't put multiple forms inside the body of your LET, and then it is clear. You don't need to design a whole new language to solve this non-existent problem.
> That's a design choice, not bad design.
It introduces a lot of additional complexity, including a syntax that is radically different from anything else in the language, and confers very little benefit. If that is not diagnostic of a bad design I can't imagine what would be.
The designers thought that a whole new language would be useful. One may disagree with it. I would have preferred something like the ITERATE macro, but it has exactly the same idea of ENCLOSING all of these LOOP clauses, but with more parentheses.
(iterate
(with a = 1)
(incf a)
(when (> a 10)
(return a)))
is (loop with a = 1
do (incf a)
when (> a 10)
return a)
If one cares about radically different syntax then you wouldn't use LOOP at all. (loop for i in l do (print i)) is equivalent to (dolist (i l) (print i)) which is equivalent to something with a LET/TAGBODY... construct. Why not just use the latter? Maybe its design choices made it practical enough?Yes, obviously they thought this. They were wrong.
> ITERATE
Iterate is a slight improvement over loop because at least it doesn't require a whole new editor mode to format it properly, but simply adding parens to loop doesn't really fix it. The lack of parens is the least of LOOP's problems.
I'll give you two more examples of problems with loop. This is still nowhere near an exhaustive list.
1. There is no way to iterate over a sequence. You can iterate over a list, or you can iterate over a vector, but you cannot efficiently iterate over something that is either a list or a vector. You have to write something like:
(typecase seq
(cons (loop for item in seq ...))
(vector (loop for item across seq ...)))
which forces you to duplicate all of the LOOP code twice. Worse, you have to actually duplicate all the code represented by the elipses. You can't abstract it away in a function or a macro.2. LOOP is not extensible. There are a lot of things I might want to be able to loop over, like streams, but I can't. There are a lot of control constructs I might want to embed in a loop, like with-open-file, but I can't. Instead, I have to resort to manually writing idioms like:
(loop
with stream = (open path)
with eof-marker = (gensym "EOF")
for thing = (read stream nil eof-marker)
while (not (eq thing eof-marker))
...
finally (close stream))
and again, because of the non-orthogonal syntax, I can't abstract all that away in to a macro either. I have to write it all out manually every single time I want to iterate over the contents of a file.(And after all that it doesn't even do the right thing if the loop code signals a condition!)
> There is no way to iterate over a sequence.
With iterate, you'd do:
(iter (for item in-sequence seq) ...)
> LOOP is not extensible.But iterate can be extended with DEFMACRO-CLAUSE.[1] I think that's why iterate added the parentheses in the first place.
[1]: https://iterate.common-lisp.dev/doc/Rolling-Your-Own.html
I'd probably have to write a whole blog post to explain why I think iterate is only a slight improvement. But the TL;DR is that IMHO if you are writing code that uses a lot of the features of iterate or loop that is an indication that you are doing something wrong.
To cite but one example: both loop and iterate include constructs for collecting values. But collecting values has nothing to do with iterating or looping. It should be a separate construct. The right way to collect values is something like:
(with-collector collect
... (collect value) ...)
Now you can collect values whether or not you are looping, and regardless of what iteration construct you decide to use. You don't need special constructs for conditional behavior. So, for example, you could do this: (with-collector collect
(dotimes (i 100)
(if (primep i) (collect i)))
to get a list of primes under 100.See https://github.com/rongarret/ergolib for an implementation of WITH-COLLECTOR and lots of other constructs that are IMHO the Right Way to write code.
I don't care about such rules.
Personally I have no problem using:
(loop for i below 100 when (primep i) collect i)Yes, that is clear.
> Personally I have no problem using:
Loop is fine for simple examples like this. But I am currently maintaining a code base that has LOOPs with dozens and dozens -- sometimes a few hundred -- clauses. A single LOOP can extend over multiple pages. It's a nightmare.
Note that LOOP can fail even for simple examples. Suppose I have a list of lists of numbers and I want to collect all the prime numbers. With WITH-COLLECTOR I can do this:
(with-collector collect
(dolist (l1 l)
(dolist (n l1)
(if (primep n) (collect n)))))
But with LOOP I can't because there is no way for an inner loop to collect into a collector bound in an outer loop. I have to collect the individual sub-lists and then append them, or something like that, which is both inelegant and inefficient.And if I have a tree of items which I want to walk over and collect all of the once satisfying a predicate, LOOP just doesn't handle that at all. But by separating collection from iteration it becomes trivial:
(with-collector collect
(do-tree (item tree)
(if (predicate item) (collect item))))
Neither WITH-COLLECTOR and DO-TREE are part of CL, of course, but writing them is an elementary exercise (and both are part of ergolib if you really don't want to be bothered).The standardized LOOP is not extensible. Each LOOP implementation is extensible. A common extension is to provide new loop iteration paths.
LispWorks:
CL-USER 7 > (lispworks:defloop (s-element s-elements) elt length sequence t)
(S-ELEMENT S-ELEMENTS)
Now we can iterate over sequences in a not performance optimized version: CL-USER 8 > (loop for e being each s-element of "hello-world" count e)
11
CL-USER 9 > (loop for e being each s-element of '(hello world) count e)
2
There are portable extensible LOOP implementations. This is then just as non-standard as any self-defined iteration library.https://research.gold.ac.uk/id/eprint/2344/1/sequences-20070... gives an example to extend SBCL's LOOP (which is based on the MIT LOOP implementation).
There are lots of things in the Common Lisp standard, which are not extensible, but where implementations and libraries provide extensible versions. Example: the CLOS MOP, sequences, hash tables, ...
Allegro isn't. Neither is SBCL. Nor CCL. Nor CLisp. In fact, the only implementation that offers this feature AFAICT is Lispworks.
> There are lots of things in the Common Lisp standard, which are not extensible
Sure. So? Just because loop is not the only thing in CL that sucks does not make it suck any less.
I don't see a lot of value in "X is just Y and Z, why use X?" arguments. Clearly it has enough ergonomic benefit that people like using it.
I definitely think the design of the DSL is a little chaotic, not to mention difficult to remember, and having two entirely different DSLs implemented by the same macro is downright ridiculous. But it's definitely good enough at its job and better than not having it at all. If it didn't exist, people would probably be DIYing their own (even worse) versions of it, without the assurance of it being part of the specification and ostensibly a well-tested part of any implementation.
And sharing lisp code, is that a thing people do? (this half an insult half a joke) So you can easily enforce your own ideals in your own project.
I'm not advocating getting rid of format, or even loop (which is by far the greater of the two evils). I'm pointing them out as cautionary tales for future DSL designers.
https://www.dreamsongs.com/Files/PatternsOfSoftware.pdf
> What are the trade-offs? Format strings don’t look like Lisp, and they constitute a non-Lispy language embedded in Lisp. This isn’t elegant. But, the benefit of this is compact encoding in such a way that the structure of the fill-in-the-blank text is apparent and not the control structure.
IMO McDermott's OUT macro has precisely the drawbacks Gabriel predicts. While McDermott seems to think it's an advantage that
> we no longer have to squeeze the output data into a form intelligible to format, because we can use any Lisp control structure we like
his example PRINT-XAPPING basically duplicates the logic for extracting data from the xapping structure as Steele's FORMAT call, but buries the data extraction into control structure alongside constant strings that go into the output.
And that's where I think FORMAT really a win: a FORMAT control deliberately separates the control flow and constant text from the data extraction logic. Presumably you could write functions that do the same thing using McDermott's OUT macro, but they'll be more verbose and no more enlightening; what's the point?
I think there is some advantage to allowing comments to be nested alongside the relevant bits of format text.
It reminds me a bit of Perl's /x modifier[1], which arguably achieves some of the best of both worlds for regex expressions.
(format stream t
#.(concatenate 'string
;; comment about the first piece
<piece 1>
;; comment about the second piece
<piece 2>)
...)
Additionally, for the record, CL's FORMAT is sufficiently hairy that you can achieve the effect of in-band comments if you wanted to: (format t "~
~0{ Because the previous line ended with a tilde,
the following newline is ignored. All of this text
occurs inside an iteration construct that loops
zero times, so will not be output. However, it
will consume one element from the list of arguments,
so after this loop, we'll use tilde-colon-asterisk
to back up one element, and let what comes next
decide how to format it. So this is more or less
a comment inside a CL FORMAT string.
~}~:*~
~S" 'Foo)I think CL-PPCRE gets this correct for a similar domain: regex strings. The library is not pedantic about whether you provide the compact string or an expanded nice version.
PPCRE> (parse-string "(ab)*")
(:GREEDY-REPETITION 0 NIL (:REGISTER "ab"))
PPCRE> (scan '(:greedy-repetition 0 nil (:register "ab")) "ababcd")
0
4
#(2)
#(4)
PPCRE>Anyhow, while it's certainly possible to parse FORMAT control strings into S-expressions, ISTM that if you want them to be invertible back into FORMAT strings, you'll end up with control structure and constant strings being contained within the S-expression, with data extraction as a separate concern. IOW, you won't get McDermott's preferred style of interwoven control, data extraction, and constant strings. For instance, you could have this FORMAT control string
"~<~{~A~^, ~}.~:>"
parse to something like (:paragraph-fill ()
(:iterate ()
(:aesthetic)
(:exit-if-list-exhausted)
", ")
".")
but this still separates concerns the way FORMAT does, and the way OUT doesn't.Your further points are well taken.
I'm tired of reading about lisp, but I always do it. This gem is the sort of thing I'm after: an explanation of why (specifically) some folks just can't seem to give it up. I admit I still find the language incomprehensible. But at least I understand why others don't. It's a start.
The reason for it is in your quote - extremely regular syntax. No operators, no precedence, no special syntax forms - just a bunch of lists with symbols. I didn't realise how important it is until I started writing macros in Elixir and realised they are not quite "native", even if really powerful.
Lisp will be back, I feel certain of it.
The specific shape of the parentheses matters. Say we have:
(let ((s nil))
(dotimes (i 16)
(setq s (rbt-insert (1+ i) s)))
(pp s))
But say we replace the ( and ) glyphs with the ^ and $ shapes: ^let ^^s nil$$
^dotimes ^i 16$
^setq s ^rbt-insert ^1+ i$ s$$$
^pp s$$
There is a night-and-day difference between those two, for you and me. Now imagine you have the hypothetical cognitive disorder which I have christened "dyslispia". Maybe to someone with dyslispia, there isn't much difference between these two; their brain doesn't process and "auto-complete" the enclosure hint of the shape of the parentheses. All they see is noise. Those people are helped by dimming the color or using some indentation-only notation.I also observe the curious effect that if we swap the shapes of the parentheses, I can still train myself to see the closure by rewiring my cognition to see the parentheses as pointing toward an interior in the convex direction:
)let ))s nil((
)dotimes )i 16(
)setq s )rbt-insert )1+ i( s(((
)pp s((
Whatever the reason, this is not as bad as ^ and $. It helps if I imagine this in 3D, and pretend that the ))s nil(( is pushed into a pillow, sorta thing.If dyslispia is real, it's basically a form of dyslexias; it's fundamental brain wiring problem for which there is no cure. No amount of explanations about Lisp will fix it.
In general, to enjoy working with Lisp, the raw experience as such, it probably helps to be well-sighted (no visual impairment), and no dyslispia-like cognitive impairment. My remarks here are mainly about those who report persistent difficulties with Lisp, but who do not mention any visual impairment.
Any sequence is simply separated by space.
Having syntactic genericity for generic trees is quite a free meal IMO.
Really, the right answer is having options for string interpolation (as long as they are safe (that is not perl)) and let programmers choose because string interpolation is just another one of those bikeshedding things that developers change their opinions on as the seasons change.
(let ((x 5))
#?"there are ${x} hackers")
works as expected.“The second special character, #, is substituted by the tally of format arguments yet abiding their consumption.”
1> (let ((words '#"how now brown"))
(put-line `@(+ 2 2) --- @{words ","}, she said`))
4 --- how,now,brown, she said
t
2> (put-line (pic "0,###,###,###.## <<<<<<< >>>>>>" 1234567.93 "xx" "yy"))
0,001,234,567.93 xx yy
t
3> (format t "~0,8x\n" 12345)
3039
tProbably slow compared to format, but more easily extendable. The slowness could be fixed for the included formatters with something like CLs compiler macros
As I suggested elsewhere in this thread, IMO the ideal scenario would be to have both the string and sexpr "interfaces". Since we are stuck with the string version, it would be nice to have a macro that expands to a valid control string. Not unlike how you'd write a macro to generate SQL or HTML.