~ / track B / clojure advanced

Type hints and performance

Advanced

Clojure is dynamically typed, but on the JVM it compiles to typed bytecode. The compiler will use Java's Object for anything ambiguous, which forces reflection at every method call. Type hints are metadata that tells the compiler "this is a String," "this is a long" — eliminating reflection and unlocking primitive math.

Most code does not need them. Hot loops, low-level math, and Java interop do.

See reflection happen

(set! *warn-on-reflection* true)
 
(defn first-char [s]
  (.charAt s 0))
;; Reflection warning - call to method charAt can't be resolved

The warning fires because the compiler doesn't know s is a String — at the call site, it generates reflective dispatch. Hint it:

(defn first-char [^String s]
  (.charAt s 0))
;; (no warning, direct invokevirtual)

^String is metadata on the parameter. The compiler now compiles (.charAt s 0) to a direct method call on String. Reflective dispatch can be one to two orders of magnitude slower than a direct call on hot paths; on cold paths the difference is invisible.

Hinting return types

For interop calls whose return type the compiler can't infer:

(defn ^java.util.Date now [] (java.util.Date.))

Without the return hint, callers that do .getTime on the result will reflect.

Primitive math

Clojure uses boxed Long and Double by default. For tight numeric loops, you can use unboxed primitives:

(defn sum-to ^long [^long n]
  (loop [i 0 acc 0]
    (if (== i n)
      acc
      (recur (inc i) (+ acc i)))))

^long on a parameter or the arg vector means primitive long — the return-type hint goes on the arg vector, not the function name. The body must stay in primitive land — adding a BigInt or doing (* a 1.5) re-boxes. With primitives plus l‌o‌o‌p/r‌e‌c‌u‌r, numeric kernels can get within a few percent of hand-written Java.

Limit: a primitive-arg function supports up to 4 primitive args, and they must be long or double. For more, pack into arrays or records.

*warn-on-reflection*

Always turn this on for performance-sensitive namespaces:

(ns myapp.hot-path
  (:require ...))
 
(set! *warn-on-reflection* true)

Compile-time warnings flag every reflective call. The rule: zero warnings in the namespaces you care about.

You can also enable it project-wide by passing a JVM flag — for example, a :dev alias in deps.edn:

;; deps.edn
{:aliases
 {:dev {:jvm-opts ["-Dclojure.compiler.warn-on-reflection=true"]}}}

Then clj -M:dev compiles every namespace with the warning on.

d‌e‌f of expensive values

(def regex (re-pattern "..."))    ;; computed at load, fine
(def big   (delay (compute-big))) ;; computed at first use; see [[futures-promises-delay]]

Avoid recomputing constants. Don't inline (re-pattern ...) inside a hot function — it'll recompile the regex every call.

transient for batch construction

Persistent collections are O(log32 n) per write — fast, but not free. If you build a collection in one place and never share intermediates, use a transient:

(defn build-set [n]
  (persistent!
    (reduce conj! (transient #{}) (range n))))

transient!, conj!, assoc!, dissoc!, pop! are O(1) amortized for local construction. Always end with persistent! to return to immutable land. Don't share transients across threads.

Internally, [[transducers]] use this pattern to build results without intermediate allocation.

Arrays for true hot paths

Java arrays are unboxed and fast:

(let [^longs xs (long-array 1000)]
  (dotimes [i (alength xs)]
    (aset xs i (* i 2))))

aset, aget, alength, amap, areduce are the array primitives. Reach for them when you need numerical kernels or memory-tight buffers — and not before.

definline, proxy, gen-class — niche

  • definline emits an inline function — cheaper than a regular call, but fragile. Use rarely.
  • proxy creates a Java subclass at runtime — slow, useful for tests.
  • gen-class ahead-of-time compiles a real Java class with methods you can call from Java. Needed when Java needs to call back into Clojure.

A pragmatic checklist

Before adding hints, measure. Most Clojure code doesn't need them, and adding them everywhere clutters reading. The rule:

  1. Profile (Criterium, async-profiler).
  2. Find the hot path.
  3. Turn on *warn-on-reflection* for that namespace.
  4. Hint to clear the warnings.
  5. If still slow, try primitive args / transients / arrays.
  6. Re-measure.

Try it (informational)

Reflection penalties are a JVM-only phenomenon — the in-browser SCI REPL runs on JavaScript, where (.length s) would fail anyway (JS strings expose length as a property, not a method). The snippet below describes the JVM behavior:

;; Without a hint, the JVM compiler can't resolve .length on s and falls
;; back to reflection at runtime — slower per-call.
(defn count-chars [s]
  (.length s))
 
;; With a hint, the compiler emits a direct String.length() call.
(defn count-chars-fast [^String s]
  (.length s))

To see something equivalent in the browser, use CLJS property access:

loading sci
press ⌘/Ctrl-↵ or click ▶ run to evaluate

Real-world

Where speed mattersWhat to do
Web request handlersUsually I/O-bound; rarely needs hints. Focus on connection pools and JSON parser choice
JSON / EDN deserializationUse jsonista (Jackson-backed) — its internal hot path is fully hinted
Numeric simulation, finance, ML preprocessing^long / ^double args, l‌o‌o‌p/r‌e‌c‌u‌r, sometimes arrays
Datomic / XTDB indexing layerHand-tuned with arrays and primitives
nippy serializationUses ^byte arrays throughout for compact encoding
ClojureScript hot rendering pathsDifferent rules — see Reagent's ^js hint and goog.string
Benchmarking toolingcriterium for micro-benchmarks; JMH for cross-JVM comparisons

Check yourself

? quiz

`*warn-on-reflection*` shows a warning on `(.length s)`. Adding which hint silences it?

Exercise

Take this function:

(defn dot [xs ys]
  (reduce + (map * xs ys)))
  1. Run it on two long vectors and benchmark with (time (dot xs ys)).
  2. Rewrite it as a primitive-friendly l‌o‌o‌p/r‌e‌c‌u‌r over ^longs arrays, with ^long accumulator and (aget).
  3. Benchmark again. Where does the speedup come from — fewer allocations, primitive ops, or removed reflection?
 status: new