Skip to content

Instantly share code, notes, and snippets.

@wandersoncferreira
Created October 2, 2020 13:38
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save wandersoncferreira/661ee797904bbb25d08fde503d25a2df to your computer and use it in GitHub Desktop.
Save wandersoncferreira/661ee797904bbb25d08fde503d25a2df to your computer and use it in GitHub Desktop.

Clojure assertion

Discussing with a colleague about some issues with dynamic programming languages and I quickly run into the rationale that programming with assertions is a common practice around this world. The idea would be to protect your function from a bad input or output state.

I am not entirely sure on that, and by far I can't speak for the industry, and I found fews posts about the subject such as Life with dynamic typing from David Nolen, but nothing too concrete. Anyway, I want to dig deeper into the subject and summarize here some approaches to use asserts in Clojure.

Our function will compute the last term of Pythagorean Theorem with some requirements for inputs and output:

  1. a and b must be integers
  2. return value must be a BigDecimal
(defn pythagorean
  [a b]
  (Math/sqrt (+ (Math/pow a 2) (Math/pow b 2))))

Assert

Evaluates expr and throws an exception if it does not evaluate to logical true

The logical start is the assert function, but it only provide solution for the input requirements.

(defn pythagorean
  [a b]
  (assert (and (int? a) (int? b)))
  (Math/sqrt (+ (Math/pow a 2) (Math/pow b 2))))

Easy enough, but now what will happen when we violate the expected type values?

1. Unhandled java.lang.AssertionError
   Assert failed: (and (int? a) (int? b))

Error, as expected. But something is odd about the error type. Let's imagine calling this function from an outer scopre, should we?

(let [a 1
      b 2]
 (when-let [c (pythagorean a b)]
   (format "is that true? %s² + %s² = %s²" a b c)))

With the wrong values, how do we catch the errors?

(let [a 1
      b 2.0]
  (try
    (when-let [c (pythagorean a b)]
      (format "is that true? %s² + %s² = %s²" a b c))
    (catch Exception e
      "ERRO")))

But the result remains the same.

1. Unhandled java.lang.AssertionError
   Assert failed: (and (int? a) (int? b))

The catch about this function for me lies in the documentation because it says that will throw an exception, and that is not true. assert throws an Error, more specifically an AssertionError. In Java, Error and Exception are not the same thing.

There are two ways to catch this value error:

;;; Throwable

(let [a 1
      b 2.0]
  (try
    (when-let [c (pythagorean a b)]
      (format "is that true? %s² + %s² = %s²" a b c))
    (catch Throwable t
      "ERRO")))
;; or

;;; AssertionError explicitly
(let [a 1
      b 2.0]
  (try
    (when-let [c (pythagorean a b)]
      (format "is that true? %s² + %s² = %s²" a b c))
    (catch AssertionError a
      "ERRO")))      

However, that is not all. Clojure has a dynamic value which controls if assert will or not in fact throw something. Difficult to find documentation around it, but *assert* can be changed.

(set! *assert* false)

(pythagorean 1.0 2.0)
;; => 2.23606797749979

Do not forget to re-evaluate your definition of pythagorean function after the set!

Assert friendly messages

It is possible to attach a message to your assertion.

(defn pythagorean
  [a b]
  (assert (and (int? a) (int? b)) "a or b are not integers")
  (Math/sqrt (+ (Math/pow a 2) (Math/pow b 2))))
  
;;; 1. Unhandled java.lang.AssertionError
;;;    Assert failed: a or b are not integers (and (int? a) (int? b))  

Intercepting the error message be like

(let [a 1
      b 2.0]
  (try
    (when-let [c (pythagorean a b)]
      (format "is that true? %s² + %s² = %s²" a b c))
    (catch AssertionError a
      (ex-message a))))
;; => "Assert failed: a or b are not integers\n(and (int? a) (int? b))"

Not too good and not too bad either, but we can do better. The output validation requirement is not yet possible to accomplish using assert.

Condition map

The condition-map parameter may be used to specify pre- and post-conditions for a function.

{:pre [pre-expr*]
 :post [post-expr*]}

pre-expr and post-expr are boolean expressions that may refer to the parameters of the function. In addition, % may be used in a post-expr to refer to the function's return value.

Our function now becomes

(defn pythagorean
  [a b]
  {:pre [(int? a)
         (int? b)]
   :post [(decimal? %)]}
  (Math/sqrt (+ (Math/pow a 2) (Math/pow b 2))))

What happen when an invalid input is provided?

1. Unhandled java.lang.AssertionError
   Assert failed: (int? a)

The same as the assert function, we get an AssertionError. Now, the real challenge with the condition map is how do we provide good error messages for our callers? Let's see some options:

Condition map messages using clojure.test

If you are concern only in logging a more appropriate message error, but continue to throw the opaque assertion error. You can cheat using the clojure.test/is macro.

(defn pythagorean
  [a b]
  {:pre [(is (int? a))
         (is (int? b))]
   :post [(is (decimal? %))]}
  (Math/sqrt (+ (Math/pow a 2) (Math/pow b 2))))

In the REPL will print something along these lines.

FAIL in () (assert.clj:6)
expected: (int? a)
  actual: (not (int? 1.0))

Condition map messages using custom exception

The beauty is that we can provide any pre-expr* and clojure will evaluate, it means we can get very creative.

(defn pythagorean
  [a b]
  {:pre [(or (int? a)
             (throw (ex-info "Parameter a is not integer" {:a a})))
         (or (int? b)
             (throw (ex-info "Parameter b is not integer" {:b b})))]
   :post [(or (decimal? %)
              (throw (ex-info "Parameter c is not bigdecimal" {:c %})))]}
  (Math/sqrt (+ (Math/pow a 2) (Math/pow b 2))))

which yields

1. Unhandled clojure.lang.ExceptionInfo
   Parameter a is not integer
   {:a 1.0}

And we can catch the error and have a nice custom message.

(try
  (pythagorean 1.0 2.0)
  (catch Exception e
    (ex-message e)))
;; => "Parameter a is not integer"

Neat. But we have to be honest, a small and nice function with one single line of logic, now has several lines of error handling.

Let's try to improve a bit.

(defmacro check
  ([pred msg]
   `(check ~pred ~msg {}))
  ([pred msg evidence]
   `(let [res# ~pred]
      (or res#
          (throw (ex-info ~msg ~evidence))))))

(defn pythagorean
  [a b]
  {:pre [(check (int? a) "Parameter a is not integer")
         (check (int? b) "Parameter b is not integer" {:b b})]
   :post [(check (decimal? %) "Parameter c is not bigdecimal")]}
  (Math/sqrt (+ (Math/pow a 2) (Math/pow b 2))))

A lot nicer from my perspective.

Condition map messages using clojure.spec

In this segment I will combine the previous solution with clojure.spec and improve on providing a custom message by leveraging the explainability of the specs.

(s/def ::a int?)
(s/def ::b int?)
(s/def ::c decimal?)

(defmacro valid?
  [spec value]
  `(if-let [res# (s/valid? ~spec ~value)]
     res#
     (let [ex-data# (s/explain-data ~spec ~value)
           ex-str# (s/explain-str ~spec ~value)]
       (throw (ex-info ex-str# ex-data#)))))

(defn pythagorean
  [a b]
  {:pre [(valid? ::a a)
         (valid? ::b b)]
   :post [(valid? ::c %)]}
  (Math/sqrt (+ (Math/pow a 2) (Math/pow b 2))))

yields

(try
  (pythagorean 1.0 2.0)
  (catch Exception e
    [(ex-message e)
     (ex-data e)]))
;; => ["1.0 - failed: int? spec: :leetclojure.assert/a\n"
;;    #:clojure.spec.alpha{:problems [{:path [], :pred clojure.core/int?, :val 1.0, :via [:leetclojure.assert/a], :in []}], :spec :leetclojure.assert/a, :value 1.0}]

Funny enough, while digging into clojure.spec I found a function called check-assets that enable spec assertions in your system. The default value is false, but you can change and achieve a very similar result of our valid? macro by using s/assert function.

Nothing stops you from accepting a custom error message here as well.

(defmacro valid?
  ([spec value]
   `(valid? ~spec ~value nil))
  ([spec value msg]
   `(if-let [res# (s/valid? ~spec ~value)]
      res#
      (let [ex-data# (s/explain-data ~spec ~value)
            ex-str# (or ~msg (s/explain-str ~spec ~value))]
        (throw (ex-info ex-str# ex-data#))))))

(defn pythagorean
  [a b]
  {:pre [(valid? ::a a "Parameter a is not integer")
         (valid? ::b b "Parameter a is not integer")]
   :post [(valid? ::c % "Parameter c is not bigdecimal")]}
  (Math/sqrt (+ (Math/pow a 2) (Math/pow b 2))))

Best of both worlds?

(try
  (pythagorean 1.0 2.0)
  (catch Exception e
    [(ex-message e)
     (ex-data e)]))
;; => ["Parameter a is not integer"
;;     #:clojure.spec.alpha{:problems [{:path [], :pred clojure.core/int?, :val 1.0, :via [:leetclojure.assert/a], :in []}], :spec :leetclojure.assert/a, :value 1.0}]

Conclusions

In the end, we covered several options. Clojure's builtin solution is not totally complete, however seems to be very aligned with the power of "no" described by David Nolen as "Rich would often say 'no' and instead, empower the users to find their own answers"

In fact, without too much work, we were able to provide several possible solutions to this problem and it all boils down to which one you want to keep.

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