[HN Gopher] When you get to be smart writing a macro
___________________________________________________________________
When you get to be smart writing a macro
Author : borjs
Score : 95 points
Date : 2025-10-16 05:51 UTC (7 days ago)
(HTM) web link (tonsky.me)
(TXT) w3m dump (tonsky.me)
| EdwardDiego wrote:
| Very smart. But also a good example of why macros are brittle.
| kazinator wrote:
| #p x
|
| is one character less than (p x)
|
| When code golfing Lisps you can remove all whitespace after a
| closing paren, but not after a symbol. So in the following fully
| golfed token sequences, #p loses its one character advantage:
| #p x y (p x)y
|
| I bring up code golfing because that's what this is about,
| presumably.
|
| But what if the argument is a parenthesized expression:
| #p(x) (p(x))
|
| #p is back in the game with a 1 char lead.
|
| The thing is, we can make the printing operator take arguments
| and turn them into an expression. Suppose we make a cousin of p
| called q, such that: (q x) -> (p (x))
| (q x y z) -> (p (x y z))
|
| q no longer loses to #p: (q x) #p(x)
| asQuirreL wrote:
| I think what's more important than the character count is the
| fact that you can add #p with two key strokes.
|
| Inserting parentheses requires moving your cursor around or
| invoking some shortcut in your editor if you use paredit, vim-
| surround, or a similar plugin. Applies equally for removing the
| invocation (although paredit makes that part easy).
| daveliepmann wrote:
| Isn't this the exact same number of keystrokes? 'Shift-3 p'
| versus 'Shift-9 p' on my keyboard.
| zamadatix wrote:
| I think GP is saying you don't need to define where a
| closing 'Shift-3 p' goes, not that the initial character is
| a single key.
|
| A Lisp dialect is probably a poor choice if that's one's
| concern though.
| dkersten wrote:
| paredit, parinfer and whatever other Clojure/lisp editing
| tools exist make this trivial though. Editor macros also
| exist to wrap expressions in calls.
| kazinator wrote:
| Good point. The parinfer implementation just perhaps needs
| some kind of nudge to know that when (p is added in front
| of an object, the parenthesis goes after just one object.
| If it creates the matching parenthesis in the wrong place
| (like end-of-line), then you have to manually mess with
| parentheses.
| zelphirkalt wrote:
| I don't think this is a good enough reason to write a macro.
| Macros should be used for doing things one otherwise couldn't do
| and have sufficient advantage. For example the threading macro
| used right there in the code is worth writing a macro, because it
| makes things easier to read and follow and couldn't be done like
| that using functions, because of the order of evaluation and
| argument injection that the threading does. Another example are
| new define forms, which by definition cannot be expressed
| resulting in the same syntax using functions. Defines are
| special. Another example for a justifiable macro could be a
| timing macro, which relies on changing the order of execution,
| because when you write (time something) then something would be
| evaluated before time is ever called, if time was a function.
|
| But the #p in the post seems to me to be a dubious choice for
| writing a macro. Too specific, too easy to use something else,
| too much confusion, too little gain.
| theknarf wrote:
| Loving the dark mode.
| elicash wrote:
| Yes, I love the whimsy of that and of the "hamburger" icon.
| dkersten wrote:
| I started using Clojure in 2009 and used it heavily for a long
| time. I even ran my local Clojure meetup for many years. Lately,
| I've moved away from Clojure however and today I mostly write
| Typescript, some hobby C++, some Rust. Gleam when I can (which is
| rare). Anyway, that's just context to say that I've written and
| enjoyed a lot of Clojure which made me think about macros a lot
| over the years.
|
| I've come to the conclusion that macros are NEVER the right
| choice for normal application developers and only RARELY the
| right choice for library authors.
|
| I would pick function over macros every single time.
|
| Even in libraries, I feel that most uses of macros are
| unjustified. Even in cases where macros enable something that
| wouldn't otherwise be possible, I question whether its really the
| best way. For example, its pretty cool that core.async could be
| implemented as macros, but I feel that core.async has rather poor
| developer ergonomics because of it and building it into the
| language itself would have lead to a much better system with a
| much better developer experience. I have a number of reasons, but
| I'll just mention the biggest here: macros cannot see into
| functions, so core.async requires its async functions to be
| called directly within a go block (eg you cannot wrap
| core.async/<! inside a helper function because the macro won't be
| able to find it to transform it).
|
| Sometimes using macros means that things can be optimised at
| compile time (eg expanding core.match or specter selectors), but
| I feel these cases are pretty rare.
|
| I do like many of the macros in clojure.core (threading macro
| etc) but I see these as a language implementation detail -- they
| could have been built into the language grammar or compiler and
| the end user experience would be the exact same.
|
| I do wish Gleam supported some limited form of macros to generate
| code based on annotating types (kind of like Rust's derive), but
| I very much agree with Gleam's logic for not including macros,
| and my experience with Clojure has basically solidified my
| feelings that macros are very rarely a good idea.
| zelphirkalt wrote:
| > macros cannot see into functions, so core.async requires its
| async functions to be called directly within a go block (eg you
| cannot wrap core.async/<! inside a helper function because the
| macro won't be able to find it to transform it).
|
| Is that something specific to Clojure macros? How does that
| macro discovery process work, so that they cannot be called
| inside a helper function? I might not understand exactly what
| you mean. This sounds very limiting.
| dkersten wrote:
| This isn't specific to Clojure macros, but Clojure is where
| I've felt it.
|
| Consider a macro `(replace-here replacement expression)` that
| replaces every instance of `here` with `replacement` in
| `expression`: (replace-here 10 (*
| here (+ 5 here))
|
| The macro sees `10` as one argument and it sees `(* here (+ 5
| here))` as another argument. It can perform the replacement
| and produce: (* 10 (+ 5 10))
|
| But if we have a function `foo` that contains the
| placeholder: (defn foo [] here)
|
| Then our macro won't be able to see it:
| (replace-here 10 (foo))
|
| This does NOT replace the "here" inside foo because the macro
| just sees `(foo)` but is not able to look inside foo.
|
| We can see this I practice in core.async where the `(go
| expr)` macro will execute `expr` asynchronously and block
| (await) when it encounters a `(<! channel)` call.
|
| BUT `<!` must be visible to the go macro, because the macro
| transforms the code into a state machine that can suspend and
| resume when the call blocks and unblocks. Than means that
| this code will await the channel: (async/go
| (let [val (async/<! ch)] (println "got" val)))
|
| However this code will not: (defn foo [ch]
| (async/<! ch)) (async/go (let [val
| (foo ch)] (println "got" val)))
|
| Because in this example the `<!` is inside the function
| `foo`, but the `go` macro cannot look into the function to
| find and transform it.
|
| Practically, this means you cannot create helper functions
| that operate on channels, you must always wrap the channel
| operation in a go block so that the macro will see it.
|
| In practice it's not that bad, you learn to structure your
| code into ways that avoid it. But it's a limitation
| nonetheless and one that only exists because core.async is
| implemented as macros.
|
| As an aside, another problem I have with core.async that
| exists because it's a macro-based implementation rather than
| a first class citizen is that there are occasions where the
| code transformations mean that an error can occur somewhere
| that isn't in your source code. I have had a situation where
| there was an error and the stack trace was entirely in
| Clojure's code without referencing my code in any way.
| Imagine how difficult it is to debug when you don't even know
| what source file of yours contains the code with the error!
| If it was built into the language rather than as macros, then
| it could at least retain source location contextual data to
| be passed to whatever internal code raised the error. But
| that's a separate issue from the "see inside" issue.
| zelphirkalt wrote:
| Maybe this seems like a bit strange design. Couldn't `<!`
| itself be made a macro, so that it doesn't matter where it
| is, instead of `<!` being something to transform? EDIT: No,
| it couldn't, because then that `<!` macro wouldn't know
| enough context, as it typically cannot look into outer
| scopes.
|
| Another thing that comes to my mind reading this is, that
| in Guile I wrote a macro for function contracts. This macro
| references a unique value as the thing that should be
| replaced. That unique value can be imported from another
| module and aliased, so that name clashes are avoided. In
| the case of function contracts, I have a special define
| form `define-with-contract`, which recognizes the
| placeholder, where it should insert the result of applying
| function, to check the output contract of the function. I
| guess, if the placeholder made sense in other places than
| the contract definition/specification, then I would face
| the same problem, of the macro not seeing it in helper
| functions. Say for example I use the unique placeholder
| value inside a helper function, which I then use inside the
| contract definition.
|
| I guess in case of a placeholder value, it couldn't be a
| macro itself, because then it would lack context about what
| should be in its place, as the scope would then be too
| limited.
|
| The basic problem seems to be as you already explained,
| that macros cannot look inside function bodies hidden
| behind calls. Sort of like macros can only deal with "one
| layer", which is the syntax that is passed to the macro,
| but if that syntax contains calls to functions, then the
| macro is clueless about what happens inside those
| functions. I wonder if this limitation is present for all
| lisps, or if there is some macro system or method of
| designing them, that circumvents this problem.
|
| But also it seems, that this is only (?) a limitation, when
| the macro needs to look for something specific like a
| placeholder. For a timing macro for example, there is no
| such thing and expressions merely get reordered and maybe
| wrapped in some `let` or something.
| dkersten wrote:
| Consider this JS/TS code: async
| function foo() { before() const
| bar = await take(ch) after(bar) }
|
| its basically sugar for: function foo()
| { before() take(ch, function
| (bar) { after(bar) })
| }
|
| Where the callback passed to take will be called when
| take resolves. And other async functions can run in the
| meantime.
|
| In clojure that might look like this:
| (go (before) (let [a (<! ch)]
| (after a)))
|
| Which would, hypothetically, convert to something like:
| (do (before) (<! ch (fn [a]
| (after a))))
|
| If `<!` was the macro, it wouldn't be able to see the
| (after a) only the `(<! ch)`. `go` is the only part that
| can see both the code before the await and the code after
| the await, and it splits it at the await so it can
| suspend execution until the take has resolved.
|
| EDIT: You edited while I was typing and came to the same
| realisation!
|
| > I guess in case of a placeholder value, it couldn't be
| a macro itself, because then it would lack context about
| what should be in its place, as the scope would then be
| too limited.
|
| In the example you gave, it could be a macro that
| transformed into a function call: eg `(foo my-
| placeholder)` turns into `(foo (read-placeholder :my-
| placeholder))` and that function read the registered
| value from a placeholder registry. It wouldn't literally
| transform the placeholder into the set value at compile
| time, but it could dynamically inject the value at
| runtime.
|
| > I wonder if this limitation is present for all lisps,
| or if there is some macro system or method of designing
| them, that circumvents this problem.
|
| I would imagine so.
|
| Its theoretically possible that you could see the full
| AST + environment so that if you see a function call, you
| can look up the function in the environment and then
| access its AST. I've never heard of any language (lisp or
| otherwise) that did this. It could exist, though.
| dkersten wrote:
| > I would pick function over macros every single time.
|
| I should have went into a little more detail, maybe.
|
| The general advice has always been "prefer data over functions,
| prefer functions over macros", but I don't think "prefer" is
| strong enough. I would rephrase it as:
|
| "Prefer data over functions. Only use macros if there's
| absolutely no other option."
|
| That means that macros shouldn't be used to make code more
| terse, or more convenient, or more "pretty". They should be
| used when they make code possible that wouldn't otherwise be
| possible (at least without jumping through a lot of hoops). For
| all my complaints, core.async is actually a good example of a
| good use of macros, as far as a library goes. It adds
| functionality that would be quite difficult to do cleanly
| without macros. My complaint is just that a macro
| implementation of something so integral is very much inferior
| to an implementation that was part of the language itself. I
| don't think async should be something tacked on to a language
| as an afterthought.
|
| An example of what I consider a bad use of macros would be
| something like this:
|
| Imagine you have a system to register event handlers that can
| then be triggered by name: (defhandler my-
| event (println "my-event triggered")) (trigger!
| my-event)
|
| Many clojure libraries exist with patterns like this,
| especially from earlier on before the community began to shift
| more closely to the `data > functions > macros` mentality.
|
| A macro-less version might look something like this:
| (register-event! :my-event (fn [] (println "my-event
| triggered"))) ; or maybe: (def my-event (register-event
| (fn [] ...))) ; or maybe even (def registry2 (register-
| event registry :my-event (fn [] ...))) (trigger!
| :my-event)
|
| Where the expression that the macro makes possible is wrapped
| in an anonymous function and the naming is explicit. Its not
| quite as convenient as the macro version, but it avoids magic
| and therefore surprises, and its more flexible because you can
| compose it or operate on it like any other function.
| jeffrallen wrote:
| And reader macros are a special kind of satanic evil, making
| sure that the next guy can't even read the code without reading
| all the macros first.
|
| Or my favorite example of insanity from $WORK: a file that
| claims to be declarative, claiming to be "just" EDN, but which
| through the satanic evil of reader macros, is actually
| executable code.
|
| Argh.
| waffletower wrote:
| Spyscope has some similar reader macros. One of my favorites,
| obviously for the REPL and not for production code, is quite
| simple -- {eva/l clojure.core/eval} -- I use it to evaluate forms
| in the scope of the REPL prior to passing them onto threads that
| don't share the REPL interns.
| ares623 wrote:
| Macros were the OG prompt engineering.
| webdevver wrote:
| thank goodness for firefox reader mode
___________________________________________________________________
(page generated 2025-10-23 23:01 UTC)