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.