Skip to content

Commit d4904f2

Browse files
authored
Add s/defprotocol (#432)
Close #117 Implementation details were needed from both platforms to make this work in practice, but none of the actual dispatch logic was copied here, so `s/defprotocol` gracefully inherits the semantics of whichever Clojure/Script version is used. Luckily these implementation details seem to apply to all tested versions of Clojure/Script. Once merged, we can test against dev CLJS releases via https://github.com/cljs-oss/canary (and we already test future CLJ versions). EDIT: canary seems dead, I'll look into alternatives. I thought it was important to be able to completely opt-out of instrumentation either at expansion or evaluation time. For example, if you pull in a lib that AOT compiles a `s/defprotocol` form and there's an instrumentation-related problem, you can opt out by setting it at eval time. I ensured that `s/fn-schema` works with and without instrumentation. Some of the more interesting issues encountered: In Clojure: - added `:inline` metadata to method vars to prevent the compiler from inlining the method (and skipping schema checks) - instrument method builders, which are used to reset methods after `extend` - propagating `.__methodImplCache` without introducing (more) data races required a lock (see `clj-protocol-cache-test` for propagation tests) In CLJS: - Some tricky wrapping is needed for multi-arity protocol methods to avoid infinite loops. Squashed commits: * Add s/defprotocol * make test repeatable by clearing impls * more faithful clearing of impls * fix protocol reference from another ns * fixed in clojure 1.7 * support declare-class-schema! for protocol methods in bb * fix * support bb * explain __methodImplCache * typo * move simple-symbol? to a place where we can delete it later * ws * add example in readme * doc
1 parent e242dc7 commit d4904f2

File tree

5 files changed

+462
-32
lines changed

5 files changed

+462
-32
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
## NEXT
2+
* Add `s/defprotocol`
3+
14
## 1.3.5 (`2022-07-28`)
25
* [#391](https://github.com/plumatic/schema/issues/391): Improve `s/defalias` error message when passed a schema that doesn't support metadata
36
* [#442](https://github.com/plumatic/schema/pull/442): Add support for cross-platform JVM type hints (another attempt at fixing [#174](https://github.com/plumatic/schema/issues/174))

README.md

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -126,30 +126,39 @@ See the [More examples](#more-examples) section below for more examples and expl
126126
If you've done much Clojure, you've probably seen code with documentation like this:
127127

128128
```clojure
129+
(defprotocol TimestampOffsetter
130+
(offset-timestamp [this offset] "adds integer offset to stamped object and returns the result"))
131+
129132
(defrecord StampedNames
130133
[^Long date
131-
names ;; a list of Strings
132-
])
134+
names] ;; a list of Strings
135+
TimestampOffsetter
136+
(offset [this offset] (+ date offset)))
133137

134138
(defn ^StampedNames stamped-names
135139
"names is a list of Strings"
136140
[names]
137141
(StampedNames. (str (System/currentTimeMillis)) names))
138142

139143
(def ^StampedNames example-stamped-names
140-
(stamped-names (map (fn [first-name]
144+
(stamped-names (map (fn [first-name] ;; takes and returns a string
141145
(str first-name " Smith"))
142146
["Bob" "Jane"])))
143147
```
144148

145149
Clojure's type hints make great documentation, but they fall short for complex types, often leading to ad-hoc descriptions of data in comments and doc-strings. This is better than nothing, but these ad hoc descriptions are often imprecise, hard to read, and prone to bit-rot.
146150

147-
Schema provides macros `s/defrecord`, `s/defn`, `s/def`, and `s/fn` that help bridge this gap. These macros are just like their `clojure.core` counterparts, except they support arbitrary schemas as type hints on fields, arguments, and return values. This is a graceful extension of Clojure's type hinting system, because every type hint is a valid Schema, and Schemas that represent valid type hints are automatically passed through to Clojure.
151+
Schema provides macros `s/defprotocol`, `s/defrecord`, `s/defn`, `s/def`, and `s/fn` that help bridge this gap. These macros are just like their `clojure.core` counterparts, except they support arbitrary schemas as type hints on fields, arguments, and return values. This is a graceful extension of Clojure's type hinting system, because every type hint is a valid Schema, and Schemas that represent valid type hints are automatically passed through to Clojure.
148152

149153
```clojure
154+
(s/defprotocol TimestampOffsetter
155+
(offset-timestamp :- s/Int [this offset :- s/Int]))
156+
150157
(s/defrecord StampedNames
151158
[date :- Long
152-
names :- [s/Str]])
159+
names :- [s/Str]]
160+
TimestampOffsetter
161+
(offset [this offset] (+ date offset)))
153162

154163
(s/defn stamped-names :- StampedNames
155164
[names :- [s/Str]]

src/clj/schema/macros.clj

Lines changed: 170 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
(ns schema.macros
22
"Macros and macro helpers used in schema.core."
3+
(:refer-clojure :exclude [simple-symbol?])
34
(:require
45
[clojure.string :as str]
56
[schema.utils :as utils]))
67

8+
;; can remove this once we drop Clojure 1.8 support
9+
(defn- simple-symbol? [x]
10+
(and (symbol? x)
11+
(not (namespace x))))
12+
713
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
814
;;; Helpers used in schema.core.
915

@@ -18,10 +24,11 @@
1824
[then else]
1925
(if (cljs-env? &env) then else))
2026

21-
(let [bb? (boolean (System/getProperty "babashka.version"))]
22-
(defmacro if-bb
23-
[then else]
24-
(if bb? then else)))
27+
(def bb? (boolean (System/getProperty "babashka.version")))
28+
29+
(defmacro if-bb
30+
[then else]
31+
(if bb? then else))
2532

2633
(defmacro try-catchall
2734
"A cross-platform variant of try-catch that catches all* exceptions.
@@ -406,6 +413,165 @@
406413
(new ~(symbol (str name))
407414
~@(map (fn [s] `(safe-get ~map-sym ~(keyword s))) field-schema)))))))
408415

416+
(if-bb nil
417+
(defn -instrument-protocol-method
418+
"Given a protocol Var pvar, its method method-var and instrument-method,
419+
instrument the protocol method."
420+
[pvar ;:- Var
421+
method-var ;:- (Var InnerMth)
422+
instrument-method #_:- #_(s/=>* OuterMth
423+
[InnerMth
424+
(named (=> Any OuterMth InnerMth)
425+
'sync!)])]
426+
(let [;; propagate method cache to inner method.
427+
;; explanation: all functions in Clojure have special support for protocol methods
428+
;; via the __methodImplCache field: https://github.com/clojure/clojure/search?q=methodimplcache&type=.
429+
;; this mutable field is used inside each protocol method's implementation via (fn this [..] (.__methodImplCache this))
430+
;; and also mutated from the "outside" via (set! .__methodImplCache protocol-method).
431+
;; since we wrap protocol methods, we need to preserve these two features (settable from outside, readable from inside).
432+
sync! (fn [^clojure.lang.AFunction outer-mth
433+
^clojure.lang.AFunction inner-mth]
434+
(when-not (identical? (.__methodImplCache outer-mth)
435+
(.__methodImplCache inner-mth))
436+
;; lock to prevent outdated outer caches from overwriting newer inner caches
437+
(locking inner-mth
438+
(set! (.__methodImplCache inner-mth)
439+
;; vv WARNING: must be calculated within protected area
440+
(.__methodImplCache outer-mth)
441+
;; ^^ WARNING: must be calculated within protected area
442+
))))
443+
^clojure.lang.AFunction inner-mth @method-var
444+
^clojure.lang.AFunction outer-mth (instrument-method inner-mth sync!)
445+
;; populate outer cache so we can use outer-mth as the protocol method without needing
446+
;; to call -reset-methods.
447+
_ (set! (.__methodImplCache outer-mth)
448+
(.__methodImplCache inner-mth))
449+
method-builder (fn [cache]
450+
(set! (.__methodImplCache outer-mth) cache)
451+
(sync! outer-mth inner-mth)
452+
;; preempt future fix for CLJ-1796--have a canonical method
453+
;; representation for the duration of the protocol, matching
454+
;; CLJS semantics.
455+
outer-mth)
456+
this-nsym (ns-name *ns*)]
457+
;; instrument method builder
458+
(alter-var-root pvar assoc-in [:method-builders method-var] method-builder)
459+
;; defeat Compiler.java inlining capabilities so we can always enforce schemas
460+
(alter-meta! method-var assoc :inline (fn [& args]
461+
`((do ~(symbol (name this-nsym) (str (.sym ^clojure.lang.Var method-var))))
462+
~@args)))
463+
;; instrument the actual method
464+
(alter-var-root method-var (fn [_] outer-mth)))))
465+
466+
(defn parse-defprotocol-sig [env pname name+sig+doc]
467+
(let [[doc name+sig] (let [lst (last name+sig+doc)]
468+
(if (string? lst)
469+
[lst (butlast name+sig+doc)]
470+
[nil name+sig+doc]))
471+
[method-name sig] (maybe-split-first simple-symbol? name+sig)
472+
_ (assert! (simple-symbol? method-name) "Missing method name %s" (pr-str method-name))
473+
[output-schema sig] (let [fst (first sig)]
474+
(if (= :- fst)
475+
(let [nxt (next sig)]
476+
(assert! nxt "Missing schema after :- in %s" method-name)
477+
[(first nxt) (next nxt)])
478+
[`schema.core/Any sig]))
479+
_ (assert (seq sig))
480+
binds (mapv #(process-arrow-schematized-args env %)
481+
sig)
482+
cljs? (cljs-env? env)]
483+
{:sig (->> (concat (cons method-name binds) (when doc [doc]))
484+
;; work around https://clojure.atlassian.net/browse/CLJS-3211
485+
(apply list))
486+
:method-name method-name
487+
:schema-form `(schema.core/=>* ~output-schema ~@(map #(mapv (comp :schema meta) %) binds))
488+
:instrument-method (let [outer-mth-meta (-> (or (meta method-name) {})
489+
(dissoc :always-validate :never-validate)
490+
(into
491+
(cond
492+
(-> method-name meta :never-validate) {:never-validate true}
493+
(-> method-name meta :always-validate) {:always-validate true}
494+
(-> pname meta :never-validate) {:never-validate true}
495+
(-> pname meta :always-validate) {:always-validate true}))
496+
not-empty)
497+
inner-mth (gensym)
498+
gen-binder (fn [gs bind]
499+
(vec (mapcat #(list %1 :- (-> %2 meta :schema)) gs bind)))
500+
gen-bind-syms (fn [bind]
501+
(mapv (fn [s]
502+
(if (symbol? s)
503+
(gensym (str (name s) "__"))
504+
(gensym)))
505+
bind))]
506+
(cond
507+
;; instrumentation not possible babashka yet
508+
bb? nil
509+
510+
cljs?
511+
(let [cljs-nsym (-> env :ns :name)
512+
->arity-sym #(symbol (str cljs-nsym "." method-name ".cljs$core$IFn$_invoke$arity$" %))
513+
arities (into {}
514+
(map (fn [bind]
515+
[(count bind) (gensym)])
516+
binds))]
517+
`(let ~(vec (mapcat (fn [[i g]]
518+
[g (if (= 1 (count arities))
519+
;; just one arity, wrap method-name
520+
method-name
521+
;; multiple arites, wrap each arity individually. don't save/call old method-name
522+
;; as it will dispatch right back to the wrapper's arities in an infinite loop.
523+
(->arity-sym i))])
524+
arities))
525+
;; use defn instead of set! to completely hide the $arity$ methods of the underlying protocol
526+
;; in case the cljs compiler attempts inlining.
527+
(schema.core/defn ~(with-meta method-name
528+
(assoc outer-mth-meta
529+
:protocol (symbol (name cljs-nsym) (name pname))
530+
:doc doc))
531+
:- ~output-schema
532+
~@(map (fn [bind]
533+
(let [arity (count bind)
534+
gs (gen-bind-syms bind)
535+
inner-mth (get arities arity)
536+
_ (assert inner-mth)]
537+
(list (gen-binder gs bind)
538+
(cons inner-mth gs))))
539+
binds))))
540+
:else
541+
(let [outer-mth (gensym (str method-name "__"))
542+
sync! (gensym)]
543+
`(-instrument-protocol-method
544+
(var ~pname)
545+
(var ~method-name)
546+
;; a function that wraps a protocol method in a schema check with a
547+
;; cache synchronization point
548+
(fn [~inner-mth ~sync!]
549+
(schema.core/fn ~(with-meta outer-mth outer-mth-meta)
550+
:- ~output-schema
551+
~@(map (fn [bind]
552+
(let [gs (gen-bind-syms bind)]
553+
(list (gen-binder gs bind)
554+
(list sync! outer-mth inner-mth)
555+
(cons inner-mth gs))))
556+
binds)))))))}))
557+
558+
(defn process-defprotocol [env name+opts+sigs]
559+
(let [[pname opts+sigs] (maybe-split-first simple-symbol? name+opts+sigs)
560+
_ (assert! (simple-symbol? pname) "Missing protocol name: %s" (pr-str pname))
561+
[doc opts+sigs] (maybe-split-first string? opts+sigs)
562+
[opts sigs] (loop [preamble []
563+
[fst :as opts+sigs] opts+sigs]
564+
(if (keyword? fst)
565+
(let [nxt (next opts+sigs)]
566+
(assert! nxt "Uneven args to defprotocol %s" pname)
567+
(recur (conj preamble fst (first nxt))
568+
(next nxt)))
569+
[preamble opts+sigs]))]
570+
{:pname pname
571+
:opts opts
572+
:doc doc
573+
:parsed-sigs (mapv (partial parse-defprotocol-sig env pname) sigs)}))
574+
409575
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
410576
;;; Public: helpers for schematized functions
411577

0 commit comments

Comments
 (0)