Accepted 2026-08-29. A principle, not a feature: it names a rule the API
already follows in cfn, callback, field-reader and field-writer
(ADR 0006), and binds every later addition to it.
Much of what babashka.ffi does per call is not the memory access or the
C call. It is turning a description into the facts the access needs: a
symbol into an address, a signature into a call shape, a layout and a path
into an offset and a codec, a variadic argument list into a tail shape.
Each of those is a lookup keyed by a runtime value, and a lookup keyed by
a runtime value cannot be removed by a JIT or by an interpreter's
call-site cache, because the key is data and the table lives behind a
mutable reference. It can only be removed by the program doing it once.
Measured on the JVM while deciding ADR 0006 (best of five runs of two million), for a 33-slot struct and a two-int struct:
read-field :parent, per call, resolving each time 26-31 ns
read p (place bone :parent), the place made once 3.5 ns
(read p point), per call 107 ns
read p (place point), the whole layout, made once 16 ns
read :int 32, the offset by hand 1.7 ns (the JIT folds a constant)
In the native image nothing folds, and the hoisted accessor costs exactly the hand read (102 ns against 101) at any path depth, where the per-call form pays about 55 ns more and the whole-struct read 1466 ns.
A per-call form and a hoisted form of the same operation are not two
conveniences. Measured, a let that makes the accessor and uses it once
costs the same as the per-call form; every further use is nearly free. So
the per-call form has no case in which it wins, and it costs the API a
second name and a second habit.
A function with a resolve step is made once and kept, and has no per-call twin. Resolve steps are: finding a symbol, building a call for a signature, walking a layout to a place, choosing a codec, fixing a variadic tail shape. The function that does one returns a function; the returned function only accesses or calls.
This is the shape of cfn (symbol and signature, once), callback
(stub, once), and place (path and codec, once; the whole layout without
a path), which read and write then take where they take a type. The one-off use is a let,
which costs the per-call price once and nothing after.
read and write stay as per-call primitives, and are the exception the
rule defines: for a scalar type there is nothing to resolve (a keyword is
a case dispatch), and for a whole layout they are the one-off operation
itself, fill a struct and hand it to C. When a whole layout is read in a
loop, (place layout) is the hoisted form.
Where a per-call spelling is wanted later, it is a macro that hoists at
expansion time, the way Specter's select caches a compiled path at the
call site, never a function that resolves at run time. Additive, and not
planned.
root-path), and a declared variadic tail shape,
(cfn "printf" [:string :& :int :string] :int), resolved at bind time
instead of inferred from the values on every call.Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |