From 1ef657999549fc60921cd91bb077fe4681827351 Mon Sep 17 00:00:00 2001 From: David Frese Date: Tue, 9 May 2023 15:10:32 +0200 Subject: [PATCH 1/2] Add an effect system based on dynamic vars. --- src/active/clojure/effect.clj | 95 +++++++++++++++++++++++++++++ test/active/clojure/effect_test.clj | 17 ++++++ 2 files changed, 112 insertions(+) create mode 100644 src/active/clojure/effect.clj create mode 100644 test/active/clojure/effect_test.clj diff --git a/src/active/clojure/effect.clj b/src/active/clojure/effect.clj new file mode 100644 index 0000000..8c7f93d --- /dev/null +++ b/src/active/clojure/effect.clj @@ -0,0 +1,95 @@ +(ns active.clojure.effect + "Effects based on dynamic vars. Effects can be used to have better + control over side effects, to abstract over different possible + interpretations of an aspect of a program or to make things easier + testable. + + Main usage patterns: + + ``` + (declare-effect eff [a]) + + (defn foo [] + (assert (= 4 (eff 2)))) + + (defn square [a] (* a a)) + + (foo) ;; => throws exception + + (with-effects [eff square] + (foo)) + + ((bind-effects* {#'eff square} foo)) + + ``` + " + (:refer-clojure :rename {bound-fn* clj-bound-fn*})) + +(defn not-implemented [effect] + (ex-info "Effect not implemented." {:effect effect :type ::not-implemented})) + +(defmacro declare-effect + "Declares `name` as an abstract effect, to be bound to an + implementation later via [[with-effects]]. `params` and `docstring` + are for documentation purposes." + ([name params] + `(declare-effect ~name nil ~params)) + ([name docstring params] + `(do (defn ~name [~@params] (throw (not-implemented ~name))) + (alter-meta! (var ~name) assoc + :dynamic true + ::effect true + :docstring ~docstring) + ;; Note: adding :dynamic meta data is not enough in clojure :-/ need to call clojure.lang.Var/setDynamic. + (.setDynamic (var ~name)) + (var ~name)))) + +(defn effect-var? [v] + (and (var? v) + (::effect (meta v)))) + +(defmacro with-effects + "Binds effects to implementations during the evaluation of `body`. + + ``` + (declare-effect add-user! [user]) + + (with-effects [add-user! db-add-user!] + ...) + ``` + " + [bindings & body] + `(binding ~bindings + ~@body)) + +(defn with-effects* + "Calls `(thunk)` and binds effects to implementation via a map of + effect vars during the evaluation of `(thunk)`." + [binding-map thunk] + (assert (every? effect-var? (keys binding-map))) + (with-bindings* binding-map thunk)) + +(defn merge-effects + "Like merge, but asserts that all keys are effect vars, and the same + vars are not bound to different implementations." + [binding-map & more] + (assert (every? #(every? effect-var? (keys %)) (cons binding-map more))) + (apply merge-with (fn [v1 v2] + (assert (= v1 v2) (str "Conflicting effect implementations: " v1 v2)) + v2) + (cons binding-map more))) + +(defn bound-fn* + "Returns a function that will call `f` with the same effect + implementations in place as there are now. Passes all arguments though to f." + [f] + (clj-bound-fn* f)) + +(defn bind-effects* + "Returns a function that will call `f` with the given map of effect + implementations in place. Note that the returned function can then + be called on other threads, too." + [binding-map f] + (with-effects* binding-map + (fn [] + (bound-fn* f)))) diff --git a/test/active/clojure/effect_test.clj b/test/active/clojure/effect_test.clj new file mode 100644 index 0000000..be2906a --- /dev/null +++ b/test/active/clojure/effect_test.clj @@ -0,0 +1,17 @@ +(ns active.clojure.effect-test + (:require [active.clojure.effect :as sut] + [clojure.test :as t])) + +(sut/declare-effect foo "Foo" [bar]) + +(t/deftest declare-effect-test + ;; (t/is (:dynamic (meta #'foo))) + (t/is (= "Foo" (:docstring (meta #'foo))))) + +(t/deftest with-effects-test + (sut/with-effects [foo (fn [x] (* x 2))] + (t/is (= 8 (foo 4))))) + +(t/deftest threading-test + (sut/with-effects [foo (fn [x] (* x 2))] + (t/is (= 8 @(future (foo 4)))))) From dfde3f8b8a679b789e6968f257db3a44d46beb38 Mon Sep 17 00:00:00 2001 From: David Frese Date: Fri, 8 Mar 2024 14:49:19 +0100 Subject: [PATCH 2/2] Rename effects as 'dynj' --- src/active/clojure/dynj.clj | 97 +++++++++++++++++++++++++++++ src/active/clojure/effect.clj | 95 ---------------------------- test/active/clojure/dynj_test.clj | 27 ++++++++ test/active/clojure/effect_test.clj | 17 ----- 4 files changed, 124 insertions(+), 112 deletions(-) create mode 100644 src/active/clojure/dynj.clj delete mode 100644 src/active/clojure/effect.clj create mode 100644 test/active/clojure/dynj_test.clj delete mode 100644 test/active/clojure/effect_test.clj diff --git a/src/active/clojure/dynj.clj b/src/active/clojure/dynj.clj new file mode 100644 index 0000000..8455ec8 --- /dev/null +++ b/src/active/clojure/dynj.clj @@ -0,0 +1,97 @@ +(ns active.clojure.dynj + "Thin layer over dynamic vars for implicit dependency injection. Dynjs + can be used to have better control over side effects, to abstract + over different possible interpretations of an aspect of a program or + to make things easier for testing. + + Main usage patterns: + + ``` + (declare-dynj eff [a]) + + (defn foo [] + (assert (= 4 (eff 2)))) + + (defn square [a] (* a a)) + + (foo) ;; => throws exception + + (binding [eff square] + (foo)) + + ((with-bindings* {#'eff square} foo)) + + ``` + " + (:refer-clojure :rename {bound-fn* clj-bound-fn* + binding clj-binding + with-bindings* clj-with-bindings*})) + +(defn ^:no-doc not-implemented [dynj] + (ex-info "Dynj var not implemented." {:dynj dynj :type ::not-implemented})) + +(defmacro declare-dynj + "Declares `name` as a dynamic injection point, to be bound to an + implementation/value later via [[binding]]. `params` and `docstring` + are for documentation purposes." + ([name params] + `(declare-dynj ~name nil ~params)) + ([name docstring params] + `(do (defn ~name [~@params] (throw (not-implemented ~name))) + (alter-meta! (var ~name) assoc + :dynamic true + ::dynj true + :docstring ~docstring) + ;; Note: adding :dynamic meta data is not enough in clojure :-/ need to call clojure.lang.Var/setDynamic. + (.setDynamic (var ~name)) + (var ~name)))) + +(defn- dynj-var? [v] + (and (var? v) + (contains? (meta v) ::dynj))) + +(defmacro binding + "Binds one or more dynjs to implementations during the evaluation of `body`. + + ``` + (declare-dynj add-user! [user]) + + (binding [add-user! db-add-user!] + ...) + ``` + " + [bindings & body] + `(clj-binding ~bindings + ~@body)) + +(defn with-bindings* + "Calls `(thunk)` and binds implementations via a map of + dynj vars during the evaluation of `(thunk)`." + [binding-map thunk] + (assert (every? dynj-var? (keys binding-map))) + (clj-with-bindings* binding-map thunk)) + +(defn merge-dynjs + "Like merge, but asserts that all keys are dynj vars, and the same + vars are not bound to different implementations." + [binding-map & more] + (assert (every? #(every? dynj-var? (keys %)) (cons binding-map more))) + (apply merge-with (fn [v1 v2] + (assert (= v1 v2) (str "Conflicting dynj implementations: " v1 v2)) + v2) + (cons binding-map more))) + +(defn bound-fn* + "Returns a function that will call `f` with the same dynj + implementations in place as there are now. Passes all arguments though to f." + [f] + (clj-bound-fn* f)) + +(defn bind-fn* + "Returns a function that will call `f` with the given map of dynj + implementations in place. Note that the returned function can then + be called on other threads, too." + [binding-map f] + (with-bindings* binding-map + (fn [] + (bound-fn* f)))) diff --git a/src/active/clojure/effect.clj b/src/active/clojure/effect.clj deleted file mode 100644 index 8c7f93d..0000000 --- a/src/active/clojure/effect.clj +++ /dev/null @@ -1,95 +0,0 @@ -(ns active.clojure.effect - "Effects based on dynamic vars. Effects can be used to have better - control over side effects, to abstract over different possible - interpretations of an aspect of a program or to make things easier - testable. - - Main usage patterns: - - ``` - (declare-effect eff [a]) - - (defn foo [] - (assert (= 4 (eff 2)))) - - (defn square [a] (* a a)) - - (foo) ;; => throws exception - - (with-effects [eff square] - (foo)) - - ((bind-effects* {#'eff square} foo)) - - ``` - " - (:refer-clojure :rename {bound-fn* clj-bound-fn*})) - -(defn not-implemented [effect] - (ex-info "Effect not implemented." {:effect effect :type ::not-implemented})) - -(defmacro declare-effect - "Declares `name` as an abstract effect, to be bound to an - implementation later via [[with-effects]]. `params` and `docstring` - are for documentation purposes." - ([name params] - `(declare-effect ~name nil ~params)) - ([name docstring params] - `(do (defn ~name [~@params] (throw (not-implemented ~name))) - (alter-meta! (var ~name) assoc - :dynamic true - ::effect true - :docstring ~docstring) - ;; Note: adding :dynamic meta data is not enough in clojure :-/ need to call clojure.lang.Var/setDynamic. - (.setDynamic (var ~name)) - (var ~name)))) - -(defn effect-var? [v] - (and (var? v) - (::effect (meta v)))) - -(defmacro with-effects - "Binds effects to implementations during the evaluation of `body`. - - ``` - (declare-effect add-user! [user]) - - (with-effects [add-user! db-add-user!] - ...) - ``` - " - [bindings & body] - `(binding ~bindings - ~@body)) - -(defn with-effects* - "Calls `(thunk)` and binds effects to implementation via a map of - effect vars during the evaluation of `(thunk)`." - [binding-map thunk] - (assert (every? effect-var? (keys binding-map))) - (with-bindings* binding-map thunk)) - -(defn merge-effects - "Like merge, but asserts that all keys are effect vars, and the same - vars are not bound to different implementations." - [binding-map & more] - (assert (every? #(every? effect-var? (keys %)) (cons binding-map more))) - (apply merge-with (fn [v1 v2] - (assert (= v1 v2) (str "Conflicting effect implementations: " v1 v2)) - v2) - (cons binding-map more))) - -(defn bound-fn* - "Returns a function that will call `f` with the same effect - implementations in place as there are now. Passes all arguments though to f." - [f] - (clj-bound-fn* f)) - -(defn bind-effects* - "Returns a function that will call `f` with the given map of effect - implementations in place. Note that the returned function can then - be called on other threads, too." - [binding-map f] - (with-effects* binding-map - (fn [] - (bound-fn* f)))) diff --git a/test/active/clojure/dynj_test.clj b/test/active/clojure/dynj_test.clj new file mode 100644 index 0000000..d891e7f --- /dev/null +++ b/test/active/clojure/dynj_test.clj @@ -0,0 +1,27 @@ +(ns active.clojure.dynj-test + (:require [active.clojure.dynj :as sut] + [clojure.test :as t])) + +(sut/declare-dynj foo "Foo" [bar]) + +(t/deftest declare-dynj-test + ;; (t/is (:dynamic (meta #'foo))) + (t/is (= "Foo" (:docstring (meta #'foo))))) + +(t/deftest binding-test + (t/is (thrown? Exception (foo 4))) + + (sut/binding [foo (fn [x] (* x 2))] + (t/is (= 8 (foo 4))))) + +(t/deftest threading-test + ;; threads 'inherit' current bindings + (sut/binding [foo (fn [x] (* x 2))] + (t/is (= 8 @(future (foo 4)))))) + +(t/deftest bound-fn-test + ;; bind a function to current bindings + (let [f (sut/binding [foo (fn [x] (* x 2))] + (sut/bound-fn* (fn [v] + (foo v))))] + (t/is (= 8 (f 4))))) diff --git a/test/active/clojure/effect_test.clj b/test/active/clojure/effect_test.clj deleted file mode 100644 index be2906a..0000000 --- a/test/active/clojure/effect_test.clj +++ /dev/null @@ -1,17 +0,0 @@ -(ns active.clojure.effect-test - (:require [active.clojure.effect :as sut] - [clojure.test :as t])) - -(sut/declare-effect foo "Foo" [bar]) - -(t/deftest declare-effect-test - ;; (t/is (:dynamic (meta #'foo))) - (t/is (= "Foo" (:docstring (meta #'foo))))) - -(t/deftest with-effects-test - (sut/with-effects [foo (fn [x] (* x 2))] - (t/is (= 8 (foo 4))))) - -(t/deftest threading-test - (sut/with-effects [foo (fn [x] (* x 2))] - (t/is (= 8 @(future (foo 4))))))