Skip to content

Latest commit

 

History

History
229 lines (182 loc) · 15.8 KB

clojure_vs_clojurewasm.md

File metadata and controls

229 lines (182 loc) · 15.8 KB

Differences between Clojure (JVM) and ClojureWasm

ClojureWasm (cljw) is a from-scratch Clojure runtime written in Zig. Its north star is behavioural equivalence with JVM Clojure on the user-observable surface (F-011): the same input produces the same value, including which inputs are accepted versus rejected. The internals are free to be Zig-native rather than a JVM port.

This page is the single-sheet answer to "how does cljw differ from Clojure?" — in the spirit of ClojureScript's "Differences from Clojure". It has two parts:

  • Part 1 — Intentional divergences: behaviours that differ by design and will not be "fixed". Each is anchored to a project invariant (an F-NNN fact or an ADR) and is locked by a regression test so it cannot drift silently. These are the AD-NNN rows of the machine-readable ledger .dev/accepted_divergences.yaml.
  • Part 2 — Not yet implemented: surface that Clojure has and cljw does not yet (or, for a few JVM-only corners, ever) carry. These are tracked work items, not silent gaps.

If a behaviour is not on this page and differs from Clojure, treat it as a bug, not a feature.

The single root cause behind most of Part 1 is no JVM: cljw has no java.lang.Class hierarchy (a TypeDescriptor instead, ADR-0059), no reproducible identity-hash address, and a single Zig-native numeric model (F-005). Wherever Clojure's observable surface leaks one of those JVM facts — a class FQCN, a 0xADDR, an f32, a JVM exception class — cljw substitutes its own honest surface and records the divergence here.

Part 1 — Intentional divergences

By-design; the "why" below is the short form, the full rationale is in the linked ledger entry. Examples are cljw => … / clj => …, clj-verified.

Printing and representation

Opaque / identity-bearing values cannot be reproduced because clj's #object[Class 0xADDR …] form embeds a JVM class FQCN and a non-reproducible identity hash. cljw prints a stable, honest form instead.

Behaviour Clojure (JVM) ClojureWasm AD
Set / non-sorted-map print order hash-order (version-dependent) deterministic insert-derived order AD-001
Opaque references (atom / fn-less / …) #object[clojure.lang.Atom 0x… {…}] #<atom> AD-002
A namespace value (*ns*, :ns meta) #object[clojure.lang.Namespace 0x… "user"] #object[Namespace "user"] AD-010, AD-021
A PersistentQueue opaque #object[…] (no print-method) readable #queue (1 2 3) + #queue reader AD-012
A host object (java.util.Random …) #object[java.util.Random 0x… …] #<java.util.Random> AD-020
A callable (fn / defmulti / proto-fn) #object[user$boom__N 0xHASH "…@…"] #<user/boom> (#<fn> if unnamed) AD-025

(str *ns*) / (ns-name …) and every value's behaviour stay clj-faithful; only the pr/prn identity rendering diverges. str and pr of a callable render identically (matching clj).

Numeric tower (F-005: a single Zig-native double + one arbitrary-precision integer)

Behaviour Clojure (JVM) ClojureWasm AD
Long overflow past i64 + / * throw ArithmeticException (only +' / *' promote) auto-promotes to BigInt AD-008
(float x) yields an f32 yields an f64 (no f32 representation) AD-004
Subnormal double shortest-render 4.9E-324 5.0E-324 (same f64 bit pattern) AD-005
(biginteger 5) 5 of class java.math.BigInteger 5N of class BigInt (one big-int type) AD-016

(* Long/MAX_VALUE 2) => 18446744073709551614N (cljw) vs a throw (clj) is the one accept/reject difference; the rest are cosmetic or rare-edge. cljw collapses clj's BigInt/BigInteger into one .big_int, so every op on a biginteger already returns BigInt in both runtimes ((class (+ (biginteger 5) 1)) => BigInt both).

No JVM class hierarchy & host interop (ADR-0059)

cljw carries a TypeDescriptor, not a java.lang.Class; its collections are native types, not java.util.* implementors; and it has one value-hash, not the JVM's hashCode/hasheq split.

Behaviour Clojure (JVM) ClojureWasm AD
(class x) / (type x) java.lang.Long (FQCN) Long (simple name) AD-003
Error rendering ArithmeticException … (JVM exception class) [arithmetic_error] … (catalog Kind); same accept/reject AD-007
Stack trace frames includes clojure.core machinery user frames only (stdlib + host elided uniformly) AD-024
clojure.stacktrace per-frame printing Class.method (file:line) frames [no stack trace available] marker (cause-chain + message work) AD-029
hash / .hashCode values JVM/Murmur3 values cljw-native values (intra-cljw consistent) AD-009
APersistentMap/mapHash vs /mapHasheq distinct (additive hashCode vs murmur) both = the single (hash m) content hash AD-028
ns-interns/ns-publics of clojure.core includes reduce, +, … omits the rt-referred primitives (ns-map includes them) AD-011
(class (object-array 0)) + typed arrays [Ljava.lang.Object;, aset type-checks array; type-erased []Value, ^"[B" hints advisory AD-019
Unresolved clojure.lang.* / .asm.* ref resolves to the JVM class namespace LOADS; the ref errors only if evaluated (call-time) AD-022
(extend-protocol P java.util.Map …) covers all clj maps (a map IS a java.util.Map) LOAD-ONLY no-op; a map receiver falls to Object AD-023
java.util.Map methods under a clojure.lang.* deftype section dispatch (the interfaces extend the java ones) accepted-and-dropped; a later (.iterator x) is method-not-found AD-027

(class 5) => Long, (quot 10 0) rejects in both (only the message format differs), and (= (hash "abc") (.hashCode "abc")) holds within cljw — the HAMT key contract that actually matters. Integers happen to match clj's hash value; strings/keywords differ. Java arrays, clojure.lang.* refs, and java.util.* protocol targets all resolve to an explicit error or a declared no-op, never a silent success.

deftype mutable fields & type hints (F-004 uniform Value)

cljw stores every deftype field as one 8-byte NaN-boxed Value, so the JVM's primitive-slot machinery has nothing to constrain.

Behaviour Clojure (JVM) ClojureWasm AD
^long / ^"[B" on a mutable field primitive slot; set! of a non-prim throws hint parsed-and-ignored (advisory) AD-017
:volatile-mutable vs :unsynchronized-mutable differ in cross-thread visibility unified to one in-place write (single-thread; revisit Phase 15+) AD-018

Concurrency & vars

Behaviour Clojure (JVM) ClojureWasm AD
STM conflict resolution barge (older txn preempts a younger) retry-only; identical committed result AD-013
An ESCAPED with-local-vars var, deref'd late #object[clojure.lang.Var$Unbound 0x…] nil (no Unbound sentinel; memory-safe) AD-015

STM's committed state is identical (4 threads × 100 (dosync (alter c inc)) => 400 in both); only contention scheduling — unobservable in the result — differs. Locking and with-local-vars are clj-faithful inside their normal use; only the literal-as-lock and escaped-var anti-patterns diverge.

Strings are indexed by code point, not UTF-16 unit

cljw stores a string as UTF-8 and every index into it counts Unicode code points. The JVM stores UTF-16 and clj indexes by code unit, so a character outside the Basic Multilingual Plane (emoji, rare CJK, most historic scripts) is one position in cljw and two (a surrogate pair) in clj. BMP text, including multibyte characters such as é or 世, indexes the same in both.

Expression Clojure 1.12 (JVM) ClojureWasm AD
(count "🌍") 2 1 AD-076
(count "ab🌍") 4 3 AD-076
(.length "🌍") 2 1 AD-076
(subs "a🌍b" 1 2) a lone high surrogate "🌍" AD-076
(vec (map int "🌍")) [55356 57101] [127757] AD-076
(str (first "🌍")) a lone high surrogate "🌍" AD-076
(.indexOf "a🌍b" "b") 3 2 AD-076

cljw column measured on v1.14.11; clj column from issue #1.

Both surfaces use code points, on purpose. The language surface (count, subs, nth, get, seq) and the Java interop names (.length, .charAt, .indexOf, .substring) share one index space, so (= (count s) (.length s)) always holds and an index from .indexOf is safe to pass to subs. The interop methods are therefore not a faithful java.lang.String#length: they keep the Java names for source compatibility and answer in code points.

Porting JVM code. Code that only takes indices from one string operation and feeds them to another (.indexOf then subs, count then a loop over nth) keeps working unchanged, and stops producing half surrogate pairs. Look again at code that:

  • compares a length or offset with one computed outside cljw: a UTF-16 offset from a JavaScript or Java peer, an LSP character position, a protocol field counting UTF-16 units. Convert at the boundary rather than inside the program.
  • tests (Character/isHighSurrogate c) / isLowSurrogate, or reassembles pairs with Character/toCodePoint: in cljw a char from a string is already a whole code point, so those branches never fire.
  • sizes a buffer from .length expecting bytes. Neither runtime gives bytes there; use (count (.getBytes s "UTF-8")), which agrees in both.

Reader & security

Behaviour Clojure (JVM) ClojureWasm AD
(read-string "#=(+ 1 2)") 3 (*read-eval* true → the form is run) error No reader function for tag = (eval-free) AD-026

cljw's read-string is the same eval-free path as clojure.edn/read-string: reading data never executes code (secure-by-default; the JVM read-eval footgun is removed). Evaluation is reached only via an explicit eval on read data.

Part 2 — Not yet implemented

Gaps relative to Clojure, tracked as work items — a missing Tier A/B/C form raises an explicit error rather than quietly mis-behaving.

Concurrency tail

The concurrency surface is complete (future / promise / delay, full STM dosync / alter / commute / ensure / ref-set, atom with CAS, agent with error modes — agent-error / restart-agent / set-error-handler! / agent-errors / clear-agent-errors — reference watches — add-watch / remove-watch fire uniformly across atoms, agents, refs and vars — await / await-for, shutdown-agents, locking, volatile, real threads, Thread/sleep). The lower-frequency tail:

  • validators are wired on atoms and agents (set-validator! / get-validator, and the (atom v :validator f) / (agent v :validator f) ctor option); ref and var validators are not yet wired (set-validator! on a ref/var errors "expected atom or agent")
  • the ref constructor option map — (ref v :validator f) / :min-history / :max-history — is not accepted (clj's ref does); pair it with the validator-on-refs gap above

JVM-only surface (deferred or permanently out of scope)

Because cljw is no-JVM, the following Clojure forms that exist only to bridge to the JVM are not part of the runtime (Tier C/D):

  • gen-class, gen-interface, compile (AOT to .class files)
  • deep proxy (subclassing arbitrary Java classes), bean
  • reflection over, and import of, arbitrary Java classes

cljw provides its own host-class surface (a curated set, see data/compat_tiers.yaml) rather than open JVM interop. Note that Java arrays are implemented (type-erased — see AD-019), as is with-local-vars (AD-015); they are no longer in this list.

How to read this page

A divergence listed in Part 1 is designed: it derives from a project invariant and is pinned by a regression test, so it reads as a deliberate choice rather than a defect. A gap listed in Part 2 was planned but never built — it sat on the roadmap when the project stopped, or, for the JVM-only corners, was intentionally excluded. Anything not on this page that differs from Clojure is a bug that was never found.

Both parts are a map of the territory as upstream left it, not a list of things about to change: this fork inherited the divergences rather than choosing them. Part 1 marks behaviour not to "correct" without first understanding the invariant behind it; Part 2 marks what is genuinely missing, with what was intended.

For the authoritative, machine-readable form of Part 1, see .dev/accepted_divergences.yaml; each AD-NNN there carries its derives_from invariant, its clj-verified example, and its pin test.