Live data from Hacker News

Lisp Macros, Delayed Evaluation and the Evolution of Smalltalk

blog.metaobject.com

71–80 of 114 posts

Re: Lisp Macros, Delayed Evaluation and the Evolution of Smalltalk

#71
Lisp user here. I'll chime in with another example of using macros for more than just delaying evaluation.

I wrote a library called Chancery[1] for procedurally generating strings (and other data). It's inspired by Tracery[2] but takes advantage of macros to make it easier to read and feel more like part of the language. I use it to write stupid Twitter bots like https://twitter.com/git_commands and https://twitter.com/rpg_shopkeeper for fun.

As an example let's say we want to generate a message about the loot we receive from a monster in a fantasy, D&D-style story. Maybe we'll start with some random weapons:

    (chancery:define-string weapon-type
      "sword"
      "spear"
      "lance"
      "flail"
      "mace")

    (weapon-type) ; => "mace"
    (weapon-type) ; => "sword"
This expands like so:

    (macroexpand-1
      '(chancery:define-string weapon-type
        "sword"
        "spear"
        "lance"
        "flail"
        "mace"))
    ; =>
    (DEFUN WEAPON-TYPE ()
      (CASE (CHANCERY::CHANCERY-RANDOM 5)
        (0 "sword")
        (1 "spear")
        (2 "lance")
        (3 "flail")
        (4 "mace")))
This simple case actually could be done just by delaying evaluation, as long as we get every body clause as a separate thunk. Now let's define a rule for generating the material of a weapon:

    (chancery:define-string (weapon-material :distribution :weighted)
      (100 "iron")
      (40 "steel")
      (5 "silver")
      (4 "gold")
      (1 "adamantine"))
This will generate the materials according to a weighted distribution, and macroexpands to:

    (DEFUN WEAPON-MATERIAL ()
      (CASE (CHANCERY::WEIGHTLIST-RANDOM #)
        (0 "iron")
        (1 "steel")
        (2 "silver")
        (3 "gold")
        (4 "adamantine")))
This case needs more than just delayed evaluation. If you receive `(100 "iron")` as an opaque thunk, where all you can do is evaluate it, there's no way to pull out the weight and body components.

If we add a few more rules, we can see more cases where we need to go beyond delayed evaluation:

    (defun currency-amount ()
      (+ 10 (random 100)))

    (chancery:define-string (currency-type :distribution :zipf)
      "copper"
      "silver"
      "gold"
      "platinum")

    (chancery:define-string loot
      #((weapon-material weapon-type) chancery:a)
      (currency-amount currency-type "coins"))

    (chancery:define-string discovery
      ("You open the chest and find" loot :. ".")
      ("You find" loot "in the monster's hidden stash.")
      ("You find nothing but dust and cobwebs."))

    (discovery) ; => "You find nothing but dust and cobwebs."
    (discovery) ; => "You find an iron sword in the monster's hidden stash."
    (discovery) ; => "You find 61 copper coins in the monster's hidden stash."
    (discovery) ; => "You open the chest and find a steel sword."
Macroexpanding the last one:

    (DEFUN DISCOVERY ()
      (CASE (CHANCERY::CHANCERY-RANDOM 3)
        (0 (CHANCERY::JOIN-STRING "You open the chest and find"
                                  (PRINC-TO-STRING #\ )
                                  (LOOT)
                                  "."))
        (1 (CHANCERY::JOIN-STRING "You find"
                                  (PRINC-TO-STRING #\ )
                                  (LOOT)
                                  (PRINC-TO-STRING #\ )
                                  "in the monster's hidden stash."))
        (2 (CHANCERY::JOIN-STRING "You find nothing but dust and cobwebs."))))
Here we can see the macro walking the lists and doing different things to each element: strings are included raw, symbols are turned into function calls, and the special keyword :. suppresses the usual joining space character inserted between everything. There's also some special handling of vectors, in the LOOT example, which I won't go into. This is more than just delayed evaluation — we're inspecting the actual structure of the code received by the macro at macroexpansion time. If all we had were an opaque thunk that we could evaluate later, we couldn't do this.

Delayed evaluation is enough for certain kinds of abstraction, like writing basic control structures, but isn't as powerful as full macros. Macros let you transform arbitrary code into other arbitrary code using the full power of the language.

[1]: https://sjl.bitbucket.io/chancery/ [2]: http://tracery.io/

Re: Lisp Macros, Delayed Evaluation and the Evolution of Smalltalk

#72
post #42

Earlier quoted context omitted.

Different name for the same thing, whats your point? Lambdas/AF are used to delay evaluation but keep the default semantics. Macros are more than simple code transformers, that wording somehow implies that they somehow retain the semantics of the data passed to them, which mighy be the case but is not required at all. S-Expressions are just a serialisation format for the m-expression AST.

I thought I'd weigh in, as a casual Racket/Elisp user > Lambdas/AF are used to delay evaluation but keep the default semantics. You and lispm seem to agree that lambdas can be used to delay evaluation, e.g. `(lambda () (+ 1 2))`. They are also used to abstract/parameterise, e.g. `(lambda (x) (* x x))`. These two ideas coincide in most languages, since it's often not clear how we would evaluate a parameterised express…

> This shows that parameterising and delaying evaluation are two distinct ideas, but they're usually represented with one language construct (lambda).

We delay evaluation because it may be expensive (potentially non-terminating), or because it may involve side-effects.

Delayed evaluation allows us to create new semantics, like lazy data structures.

The steps of a procedural program which are have not yet executed are delayed evaluation. Sometimes that is important, because those steps require some external event to have taken place, so that their own effect takes place after that event, or because that event provides a needed input. Mechanisms like closures and continuations give us new useful ways to structure this.

Re: Lisp Macros, Delayed Evaluation and the Evolution of Smalltalk

#73

Earlier quoted context omitted.

> don't need to be valid code Yes. One of the examples from the talk was a comment macro. Very cool (and the talk was about fun/cool stuff, not about practicalities). The question is whether you want that sort of power in day-to-day programming. My guess is no. That's also what the PARC/LRG folks found out with Smalltalk-72. It's also something I hear from some very seasoned LISP hackers. It's also the sense I am get…

"we should always use the least powerful mechanism that will accomplish our goal" I like this when implementing something for non proficient users. But when it comes to providing tools for (supposedly) advanced users, like programmers... There's late-"socialism" joke in Bulgaria: "thrift is mother of misery". A designer doesn't know ahead of time what problems "creative" users will face long term. Providing a set of…

Sounds very related to "shadow languages" ( https://gbracha.blogspot.com/2014/09/a-domain-of-shadows.htm... ) where we add a feature to our language in a very limited form, e.g. imports; then we end up wanting that feature to be a little more powerful, so we add some special case for that, e.g. conditional imports; then we want to use that feature some other way, so we add support for that, e.g. renaming imports; etc.

We end up with a language that has a complicated, limited, special-purpose second-language built in just to handle that feature.

The alternative is to try implementing the feature using existing facilities right from the start, e.g. making imports first-class values that can use the language's own conditionals, variable names, etc. for imports.

We can also go one step further and rather than just trying to re-use the existing language features as they are (e.g. conditionals, variables, etc.), we can ask what new feature could we use to build both the new functionality and the old functionality. That way, rather than e.g. using the built-in conditionals to implement conditional imports, we might decide to something more powerful than both, like macros, and use macros to implement conditional imports and replace the built-in conditionals :)

Re: Lisp Macros, Delayed Evaluation and the Evolution of Smalltalk

#74
post #66
post #58

Earlier quoted context omitted.

> no interpreter ClojureScript is interpreter, unless you run Closure compiler in compilation phase. Interpretation is done by default in repl. There is also Joker [0]. > no linked lists as base data structure Not true. Linked lists are base data structure on the same level as vectors and hash maps. Unlike Scheme where hash maps are sometimes implemented as assoc-ed lists. > no Lisp in Lisp Check for CinC and derivat…

> ClojureScript is interpreter I thought ClojureScript compiles to JavaScript? > What you define by "runtime"? For example the stuff the JVM provides: memory management, data layout, interrupt handling, threads, loading code, talking to the environment, ... > By this requirement, 90% Lisps out there would not be Lisp I'd think it's more like 70% use images... just a guess. The exceptions usually are Lisps using runti…

> I thought ClojureScript compiles to JavaScript?

According to [0], these days it is hard to distinguish between compilers/interpreters. However, it translates code to javascript and execute it immediately, just like python does (converting code to own bytecode, before execution). No translation to machine language.

> For example the stuff the JVM provides...

I still fail to see what (Common) Lisp runtime gives over JVM, except direct translation to machine language (Hotspot does that in runtime) and maybe restarts - there are few clojure libraries which implements this, but thing would be much better if is baked in language/jvm.

> I'd think it's more like 70% use images

If you count into that Scheme implementations, that number would be significantly different ;)

[0] https://en.wikipedia.org/wiki/Interpreted_language

Re: Lisp Macros, Delayed Evaluation and the Evolution of Smalltalk

#75
post #2

That's a common misconception that Lisp macros are mostly used to 'delay' evaluation. What Smalltalk calls 'blocks' are just (anonymous) functions in Lisp. Books like SICP explain in detail how to use that for delayed evaluation in Lisp/Scheme: https://mitpress.mit.edu/sites/default/files/sicp/full-text/...

Did you ever try to implement a macro in a Lisp? If you ever try, you'll quickly find out that a macro is essentially a lambda without argument evaluation. That's it. If we take another look on it, yep, that's a form of delayed evaluation of lambda parameters, however I would prefer the term "delayed expansion".

Considering the username of the person you are replying to, you might consider that they are very likely to have implemented macros in a lisp.

In common lisp, defining a macro defines a function that receives as its arguments the unevaluated forms passed to it plus (optionally) the lexical environment. This part is indeed "essentially a lambda without argument evaluation."

The magic isn't there, the magic is in the interpretation of the value it returns. It can return any arbitrary lisp forms. This allows macros to do far more than just delay evaluation. Pretty much anything that involves code walking is not possible with lambdas, for example. Some setf expanders would be impossible with lambdas as well.

Re: Lisp Macros, Delayed Evaluation and the Evolution of Smalltalk

#76
post #27

There is whole set of "with-x" macros that aren't about delayed evaluation.

What's the with-x macro that isn't providing dynamic extent, that is, delaying execution of some cleanup logic?

Consider for example CL:WITH-SLOTS, or CL-WHO:WITH-HTML-OUTPUT... such macros establish context, and are not really about delaying evaluation. On Lisp has a section about uses of macros, which is not exhaustive, but shows there's more to them than "delayed evaluation".

Re: Lisp Macros, Delayed Evaluation and the Evolution of Smalltalk

#77
post #74
post #66

Earlier quoted context omitted.

> ClojureScript is interpreter I thought ClojureScript compiles to JavaScript? > What you define by "runtime"? For example the stuff the JVM provides: memory management, data layout, interrupt handling, threads, loading code, talking to the environment, ... > By this requirement, 90% Lisps out there would not be Lisp I'd think it's more like 70% use images... just a guess. The exceptions usually are Lisps using runti…

> I thought ClojureScript compiles to JavaScript? According to [0], these days it is hard to distinguish between compilers/interpreters. However, it translates code to javascript and execute it immediately, just like python does (converting code to own bytecode, before execution). No translation to machine language. > For example the stuff the JVM provides... I still fail to see what (Common) Lisp runtime gives over…

In Lisp an interpreter means something slightly different: it means an interpreter for s-expressions. It's not about a virtual machine interpreter or similar.

A Lisp interpreter runs directly the s-expressions. This is independent from the idea of evaluation. Evaluation could be implemented by an interpreter or by a compiler.

For example using an interpreter:

  CL-USER 32 > (defun fak (n)
                 (if (zerop n)
                     1
                     (* n (fak (1- n)))))
  FAK
Let's break into the function if the argument N is 0:

  CL-USER 33 > (trace (fak :break (eql (first *traced-arglist*) 0)))
  (FAK)

  CL-USER 34 > (fak 5)
  0 FAK > ...
    >> N : 5
    1 FAK > ...
      >> N : 4
      2 FAK > ...
        >> N : 3
        3 FAK > ...
          >> N : 2
          4 FAK > ...
            >> N : 1
            5 FAK > ...
              >> N : 0

  Break on entry to FAK with *TRACED-ARGLIST* (0).
    1 (continue) Return from break.
    2 Continue with trace removed.
    3 Continue traced with break removed.
    4 Continue and break when this function returns.
    5 (abort) Return to top loop level 0.

  Type :b for backtrace or :c  to proceed.
  Type :bug-form "" for a bug report template or :? for other options.

  CL-USER 35 : 1 > :bq

  FAK  :n
  Interpreted call to FAK

  CL-USER 37 : 1 > :lambda
  (LAMBDA (N) (DECLARE (SYSTEM::SOURCE-LEVEL #))
              (DECLARE (LAMBDA-NAME FAK))
              (IF (ZEROP N) 1 (* N (FAK #))))
You see above the actual s-expression being executed for that stack frame. It's not executing any compiled or translated code. It's actually interpreting the source directly without any such step.

If we want, we could inspect, alter it with the usual Lisp functions and continue running.

  CL-USER 39 : 1 > (fifth *)
  (IF (ZEROP N) 1 (* N (FAK (1- N))))

  CL-USER 40 : 1 > (setf (third *) 2)
  2

  CL-USER 41 : 1 > **
  (IF (ZEROP N) 2 (* N (FAK (1- N))))

  CL-USER 42 : 1 > :c 1
            5 FAK 
> I still fail to see what (Common) Lisp runtime gives over JVM

More efficient data representation, more efficient execution, saving and loading of images, tailored GCs, mixing compiled and interpreted (see above) Lisp code, deeper runtime inspection, resumeable exceptions, efficient stack traces, efficient function calls, fast startup times, etc etc.

Something like Lisp, which may have a bunch of very dynamic parts, is hard to implement efficiently on the JVM. Clojure is designed to map better to the JVM.

Re: Lisp Macros, Delayed Evaluation and the Evolution of Smalltalk

#78

Earlier quoted context omitted.

I thought I'd weigh in, as a casual Racket/Elisp user > Lambdas/AF are used to delay evaluation but keep the default semantics. You and lispm seem to agree that lambdas can be used to delay evaluation, e.g. `(lambda () (+ 1 2))`. They are also used to abstract/parameterise, e.g. `(lambda (x) (* x x))`. These two ideas coincide in most languages, since it's often not clear how we would evaluate a parameterised express…

> This shows that parameterising and delaying evaluation are two distinct ideas, but they're usually represented with one language construct (lambda). We delay evaluation because it may be expensive (potentially non-terminating), or because it may involve side-effects. Delayed evaluation allows us to create new semantics, like lazy data structures. The steps of a procedural program which are have not yet executed are…

> We delay evaluation because it may be expensive (potentially non-terminating),

Indeed, we must delay (full) evaluation if we want a function to be recursive. This is also why we can't inline every function call (even if we're prepared to accept the inevitable code bloat).

> or because it may involve side-effects.

> The steps of a procedural program which are have not yet executed are delayed evaluation. Sometimes that is important, because those steps require some external event to have taken place, so that their own effect takes place after that event, or because that event provides a needed input.

I didn't explicitly talk about side-effects, but I was making an implicit assumption that evaluating under a lambda would never move an effect from runtime to compile/expansion time, and would preserve the partial-order of runtime effects (i.e. the absolute order may change, if there's a race condition and our partial-evaluation manages to speed up one path more than the other, but those which are causally-linked like triggering an event handler or waiting for input would preserve their order). To me, these requirements are just part of what it means for the evaluation to be correct, just like a call to `foo` should run the code for `foo` rather than some arbitrary other function, etc.

A partial-evaluator/supercompiler/inliner/constant-folder/loop-unroller can partially-evaluate code by treating parameters as opaque symbols (since we don't know what their value will be), and they can likewise treat language primitives which have side-effects as opaque values. For example we can treat `(lambda () (print "hello world"))` as being in normal form (and hence we do no further evaluation), whilst still treating `(lambda () (print (concat "hello " "world")))` as not being in normal form, since we can go ahead and run the `concat` call.

> Delayed evaluation allows us to create new semantics, like lazy data structures.

> Mechanisms like closures and continuations give us new useful ways to structure this.

I agree, I was just pointing out we can (a) implement such things without `lambda` (e.g. using a separate `delay` construct) and that (b) we can implement `lambda` in a way which doesn't allow forms (e.g. by allowing evaluation under a lambda, by limiting what we count as a normal form).

I'm not saying it's practical or desirable to do so, just that it's important to know which properties are inherent to a feature (like `lambda`) and which are under our control to choose.

Re: Lisp Macros, Delayed Evaluation and the Evolution of Smalltalk

#79

Earlier quoted context omitted.

> This shows that parameterising and delaying evaluation are two distinct ideas, but they're usually represented with one language construct (lambda). We delay evaluation because it may be expensive (potentially non-terminating), or because it may involve side-effects. Delayed evaluation allows us to create new semantics, like lazy data structures. The steps of a procedural program which are have not yet executed are…

> We delay evaluation because it may be expensive (potentially non-terminating), Indeed, we must delay (full) evaluation if we want a function to be recursive. This is also why we can't inline every function call (even if we're prepared to accept the inevitable code bloat). > or because it may involve side-effects. > The steps of a procedural program which are have not yet executed are delayed evaluation. Sometimes t…

By the way, I've never worked with a delay construct that wasn't a macro for a lambda. :)

Re: Lisp Macros, Delayed Evaluation and the Evolution of Smalltalk

#80
post #23

Earlier quoted context omitted.

But an infix transform does delay the evaluation of expressions you've passed to it. Eg (infix (avg b) * (sgn a)). And most practical uses of macros involve passing in expressions that will be later evaluated verbatim, e.g. (time (reduce + (range 100))) I was only intending responding to this: "That's a common misconception that Lisp macros are mostly used to 'delay' evaluation." Which just seems to me confuses the m…

> But an infix transform does delay the evaluation of expressions you've passed to it. Eg (infix (avg b) * (sgn a)). It doesn't 'delay' anything. It just rewrites at compile time (infix (avg b) * (sgn a)) into (* (avg b) (sgn a)) That's all. At runtime only the rewritten statement will be executed. > At a very low level, macros operate by having the evaluation of their arguments delayed Not at all. The main purpose o…

I believe that if you're trying to understand what macros do from a fundamental level, treating them like functions with different semantics is a very good learning method. You implement them in a SICP style interpreter and then write a few. Totally clears up the mystery.

I'm not arguing with you about what happens in any production lisp. I'm sure you know more than me, but I do understand how they're implemented in practice, and the warts that brings - eg no (apply or ), and first class macros are so esoteric purely from the runtime cost that they're barely even written about in the literature.

I'm just saying the mental model of where macros fit in in an interpreter has value, and that view has not changed. In my opinion you're an expert coming in correcting a bunch of people attempting to learn the basics, and you're not wrong, but you are muddying the waters.

Post reply on HN