Skip to content

Instantly share code, notes, and snippets.

@KingCode
Last active November 2, 2020 23:49
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save KingCode/773560f4ab5bf91e660a2a26e581b036 to your computer and use it in GitHub Desktop.
Save KingCode/773560f4ab5bf91e660a2a26e581b036 to your computer and use it in GitHub Desktop.
cond-let and cond-let> macros, to leverage bindings between test and result expressions, as well as earlier ones (for cond-let)
(ns cond-let)
;; (cond-let
;; (odd? x) [x n] (inc x)
;; (< n 10) [y (inc n)] 10
;; :else n))
;; we want the above to yield
;; (let [x n]
;; (if (odd? x)
;; (inc x)
;; (let [y (inc n)]
;; (if (< n 10)
;; 10
;; (if :else
;; n
;; (throw ...."no matching clause"))))))
(defmacro cond-let
"Takes ternary clauses which can use bindings visible to both the test
and result expression, as well as all following clauses (except when shadowed);
the last clause which can be binary and follows 'cond semantics.
Each ternary clause can be of the form
text-expr binding-vector result-expr
or
test-expr :>> result-expr
if there are no new bindings added to the clause
(:>> is an ordinary keyword)
"
[& clauses]
(let [emit (fn emit [args]
(let [[[pred binds expr :as clause] more]
(split-at 3 args)
n (count args)]
(cond
(= n 0) `(throw (IllegalArgumentException.
(str "No matching clause: " ~expr)))
(< n 2) `(throw (IllegalArgumentException.
(str "Must have at least 2 arguments: "
~@clause)))
(= n 2)
`(if ~pred
~(second clause)
~(emit more))
(= :>> (second clause))
`(if ~pred
~expr
~(emit more))
:else
`(let ~binds
(if ~pred
~expr
~(emit more))))))]
(emit clauses)))
;; (cond-let>
;; (odd? x) [x n] (inc x)
;; (even? n) :>> (dec n)
;; (< 10 (+ y z)) [y (inc n) z 80] (* 2 n z)
;; :else n
;; we want the above to yield:
;; (or (let [x n]
;; (when (odd? x)
;; (inc x))
;; (when (even? n)
;; (dec n))))
;; (let [y (inc n) z 80]
;; (when (< 10 (+ y z))
;; (* 2 n z)))
;; (when :else
;; n)
;; (throw..."No matching clause.."))
(defmacro cond-let>
"Same as for cond-let, except bindings are local to each clause only."
[& clauses ]
(let [emit (fn emit [args]
(let [[[pred binds expr :as clause] more]
(split-at 3 args)
n (count args)]
(cond
(= n 0) [`(throw (IllegalArgumentException.
(str "No matching clause: " ~expr)))]
(< n 2) [`(throw (IllegalArgumentException.
(str "Must have at least 2 arguments, only got: "
~@clause)))]
(= n 2)
(cons `(when ~pred
~(second clause))
(emit more))
(= :>> (second clause))
(cons `(when ~pred
~(last clause))
(emit more))
:else
(cons `(let ~binds
(when ~pred
~expr))
(emit more)))))]
`(or ~@(emit clauses))))
(defn cond-let-sample [n]
(cond-let
(neg? x) [x n] (inc x)
(even? n) :>> (* (quot x n) (dec n))
(< 9 (+ y z)) [y (inc n) z 3] (* 2 n z)
(= 7 (+ y z)) :>> "reused binding from previous clause"
:else n))
(cond-let-sample -3) ;; => -2
(cond-let-sample 34) ;; => 33
(cond-let-sample 7) ;; => 42
(cond-let-sample 3) ;; => 3
(cond-let-sample 3) ;; =? "reused binding from previous clause"
(defn cond-let>-sample [n]
(cond-let>
(neg? x) [x n] (inc x)
(even? n) :>> (dec n)
(< 10 (+ y z)) [y (inc n) z 3] (* 2 n z)
:else n)
)
(cond-let>-sample -3) ;; => -2
(cond-let>-sample 34) ;; => 33
(cond-let>-sample 7) ;; => 42
(cond-let>-sample 3) ;; => 3
;; cond-let> clauses don't nest bindings, uncomment to try:
#_(cond-let>
(odd? x) [x 2] x
(even? x) :>> "even steven" ;; can't reuse higher up bindings!
:else :whatever)
;; => ...Unable to resolve symbol: x in this context
@KingCode
Copy link
Author

KingCode commented Jul 14, 2020

I have come across a few situations where having this macro is worthwhile, specifically in cases where common values are sprinkled in both tests and outcome of various branches. Often, the following situations occur around the usage of cond:

  • a value is part of either the test or the outcome of a branch, and occurs in more than one branch (most often)

  • inside a branch, both the test and outcome forms use a common value, or common logic.

  • infinite possible ways combining both of the above.

A common current solution is to create an enclosing let or other binding form creating all such values needed by all branches. It would be nice to have the option of an "as needed", more local way to bind values for re-use inside one or more branches. If a value is costly to compute, we have better performance in addition to improved readability.

Hence a variant of the familiar cond macro which allows bindings at the branch level, in two variants (propagating downward, and strictly branch-local).

Implementation:
Regarding the use of inline functions to implement the local-only bindings cond-let> version, it may be better to generate 'let bindings instead, as per the macro name. My vague motivation at the time was that

  1. generating functions and wrapping them in a call seems easier than multiple lets whereas in cond-let the implementation is easier (a single 'let is generated).

  2. for branches where no bindings are provided, a no-args function is more intuitive and purposeful than an empty 'let binding, although that is an implementation detail.

Usage
Regarding the user syntax, I like having the bindings in second place between the predicate and clause because it brings out the original cond syntax we're all used to. So this code

(cond
   (pred x) [x expr] <outcome-or-value-1 using x>
   ...)

could be read as: "this predicate invoked with x being true, where x has the value expr, use x to produce this outcome/value", and so forth.

The name?
Finally, I am not even sure about the name for these two macros, which I think should be viewed as a kind of duality, or pair: maybe one of the following? For example:

  • cond-let vs cond-let> (current)

  • cond-let+ vs cond-let

  • cond-let-all vs cond-let

  • cond-let* vs cond-let

I don't know how best to bring this functionality to be usable for most, so am grateful for comments, criticism and corrections/forks...Thanks!

@KingCode
Copy link
Author

KingCode commented Nov 2, 2020

Changes (November 2 2020):

  1. Bug-fix: for both macros, the throw clause for insufficient arguments was itself throwing because of evaluating the input clause as a list when reporting.
  2. The implementation of cond-let> now uses local let forms instead of inline functions, at @miikka 's suggestion, which is more aligned with the name of the macro. (This is transparent to the user). This takes actually less code than the inline version!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment