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:
a
andb
must be integers- return value must be a BigDecimal
(defn pythagorean
[a b]
(Math/sqrt (+ (Math/pow a 2) (Math/pow b 2))))
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!
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
.
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:
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))
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.
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}]
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.