Tuesday, March 19, 2019

LISP Macros, Delayed Evaluation and the Evolution of Smalltalk

At a recent Clojure Berlin Meetup, Veit Heller gave an interesting talk on Macros in Clojure. The meetup was very enjoyable, and the talk also brought me a little closer to understanding the relationship between functions and macros and a bit of Smalltalk evolution that had so far eluded me.

The question, which has been bugging me for some time, is when do we actually need metaprogramming facilities like macros, and why? After all, we already have functions and methods for capturing and extracting common functionality. A facile answer is that "Macros extend the language", but so do functions, in their way. Another answer is that you have to use Macros when you can't make progress any other way, but that doesn't really answer the question either.

The reason the question is relevant is, of course, that although it is fun to play around with powerful mechanisms, we should always use the least powerful mechanism that will accomplish our goal, as it will be easier to program with, easier to understand, easier to analyse and build tools for, and easier to maintain.

Anyway, the answer in this case seemed to be that macros were needed in order to "delay evaluation", to send unevaluated parameters to the macros. A quick question to the presenter confirmed that this was the case for most of the examples. Which begs the question: if we had a generic mechanism for delaying evluation, could we have used plain functions (or methods) instead, and indeed the answer was that this was the case.

One of the examples was a way to build your own if, which most languages have built in, but Smalltalk famously implements in the class library: there is an ifTrue:ifFalse: message that takes two blocks (closures) as parameters. The True class evaluates the first block parameter and ignores the second, the False class evaluates the second block parameter and ignores the first.

The Clojure macro example worked almost exactly the same way, but where Smalltalk uses blocks to delay evaluation, the example used macros. So where LISP might use macros, Smalltalk uses blocks. That macros and blocks might be related was new to me, and took me a while to process. Once I had processed it, a bit of Smalltalk history that I had always struggled with, this bit about Smalltalk-76, suddenly made sense:



Why did it "have to" provide such a mechanism? It doesn't say. It says this mechanism was replaced by the equivalent blocks, but blocks/anonymous functions seem quite different from alternate argument-passing mechanisms. Huh?

With this new insight, it suddenly makes sense. Smalltalk-72 just had a token-stream, there were no "arguments" as such, the new method just took over parsing the token stream and picked up the paramters from there. In a sense, the ultimate macro system and ultimately powerful, but also quite unusable, incomprehensible, unmaintainable and not compilable. In that system, "arguments" are per-definition unevaluated and so you can do all the macro-like magic you want.

Dan's Smalltalk-76 effort was largely about compiling for better performance and having a stable, comprehensible and composable syntax. But there are times you still need unevaluated arguments, for example if you want to implement an if that only evaluates one of its branches, not both of them, without baking it into the language. Smalltalk did not have a macro mechanism, and it no longer had the Smalltalk-72 token-stream where un-evaluated "arguments" came for free, so yes, there "had" to be some sort of mechanism for unevaluated arguments.

Hence the open-colon syntax.

And we have a progression of: Smalltalk-72 token stream → Smalltalk-76 open colon parameters → Smalltalk-80 blocks.
All serving the purpose of enabling macro-like capabilities without actually having macros by providing a general language facility for passing un-evaluated parameters.

Aha!

6 comments:

  1. Why do you need macros in Lisp to "build your own if"? Couldn't you do this with a function that takes a Boolean expression and two closures, similar to Smalltalk? Maybe that would be too slow at runtime? (I'm not familiar with Lips macros, so please forgive me if this has an obvious answer.)

    ReplyDelete
  2. You don't. It was an example in a talk showing fun Clojure macros.

    ReplyDelete
  3. You can also sort-of delay evaluation using square brackets in clojure, like in reagent.

    ReplyDelete
  4. This blogger might not be aware of multiple evaluation strategies for programs. Lisp dialects tend to be strictly evaluated: arguments are reduced to their values before a function is called. Languages can also be lazily evaluated: the value of an expression is obtained at the last possible moment. Some well-known functional programming languages are lazily evaluated. They still have gobs of syntax (syntax which couldn't be written in those languages if it were removed), and even meta-syntactic facilities for making new syntax.

    A macro does not simply delay the evaluation of its arguments. Some arguments are not intended as expressions to be evaluated. For instance, sometimes a macro argument is a symbol which is to be the name of a variable to be bound. Binding a symbol is not evaluation; when a symbol is evaluated, it's expected to already have a binding. Some macro arguments are evaluated, but in some different way. Some are evaluated, but multiple times (as in a loop).

    ReplyDelete
  5. Food for thought: what argument evaluation is being delayed in (defstruct point x y)?

    ReplyDelete
    Replies
    1. point, x and y, which are symbols and shouldn't be evaluated.

      Delete