Compare Clojure macros, Java reflection, and Clojure interop reflection by timing, failure mode, performance cost, review style, and the kind of design problem each one solves.
The cleanest comparison is not “macros are Clojure reflection.” They are different mechanisms with different failure modes.
A macro rewrites a Clojure form. Java reflection inspects or invokes JVM members at runtime. Clojure interop reflection is an implementation fallback when a Java call cannot be resolved statically.
flowchart TB
A["Reader builds forms"] --> B["Macro expansion rewrites forms"]
B --> C["Compiler emits ordinary code"]
C --> D["Runtime executes code"]
D --> E["Java reflection may inspect or invoke JVM members"]
The important boundary is before runtime versus during runtime. After macro expansion, the macro is gone from the running code path; the expanded form remains.
| Mechanism | What to review |
|---|---|
| Clojure macro | Expansion-time transformation from Clojure forms to another form. Main risk: unreadable or unsafe generated code. Review with macroexpand. |
| Java reflection | Runtime lookup over JVM metadata and objects. Main risk: runtime failure, access issues, and slower dynamic calls. Review with tests around lookup and invocation. |
| Clojure interop reflection | Runtime fallback when a Java call target is ambiguous. Main risk: hidden cost in hot paths. Review with *warn-on-reflection*, profiling, and type hints. |
Suppose you want to log an expression and return its value. A macro can preserve the caller’s expression for logging:
1(defmacro log-expr [expr]
2 `(let [value# ~expr]
3 (println "expression:" '~expr "=>" value#)
4 value#))
5
6(log-expr (+ 1 2))
7;; prints: expression: (+ 1 2) => 3
8;; returns: 3
The macro works because it receives the unevaluated form (+ 1 2). Reflection would be the wrong tool because there is no unknown JVM method or class to discover at runtime.
Now suppose a plugin configuration names a Java class:
1String className = config.get("handlerClass");
2Class<?> type = Class.forName(className);
3Object handler = type.getDeclaredConstructor().newInstance();
Here reflection is appropriate because the type is not known until runtime. A Clojure macro cannot expand based on a runtime configuration value that does not exist yet.
| Java habit | Clojure correction |
|---|---|
| Treat dynamic behavior as a runtime framework concern. | Ask whether the macro needs the caller’s unevaluated form; if it does, syntax transformation is the real need. |
| Hide complexity behind generated machinery. | Keep the expansion small and inspect it with macroexpand before trusting the abstraction. |
| Use reflection as a universal escape hatch. | Prefer ordinary values, functions, dispatch, protocols, multimethods, or type hints before metaprogramming. |
Use a macro when the caller must write a different shape of code than a function can accept cleanly. Use Java reflection when the JVM member really is unknown until runtime. Use type hints when Clojure already knows enough in principle but needs help choosing a Java method without reflective dispatch.