A short guide: When to use assert
?
Written with JVM Clojure in mind but if you press me I’ll admit I believe it applies across dialects.
Feedback welcome.
A short guide: When to use assert
?
Written with JVM Clojure in mind but if you press me I’ll admit I believe it applies across dialects.
Feedback welcome.
The first thing I thought of when I saw this post was my use of :pre
and :post
assertions. I think this is good guidance for their usage.
As an aside, I’m not sure why they’re not more popular. I know there are more powerful tools since their appearance in version 1.1, but :pre
/:post
assertions really make reading a function much faster.
I suppose it’s a bit different in a large codebase with a defined architecture. For example, we’re now using finite state machines and fully specified keyword dispatch in our codebase. But it took time to get there and in the interim, I always found a :post
on a defn-
a welcome addition as a reader.
The gist’s origin story traces back to an Alex Miller comment that “pre/post is simultaneously under- and over-used. It’s under-used for true program invariants and over-used for input validation.”
Thanks for the gist, I added a comment to it, hope that’s okay.
I believe it is impossible to use asserts in their current form that defaults with them being “on”. You can see Spec learning from that mistake, as spec-assert is “off” by default.
Asserts would have been a nice development/testing time tool, and so would pre/post
, if they only defaults to being “off”.
The way they are now, I can never see a justification where you can identify a true program invariant that warrants a production assert.
P.S.: Note that asserts in Java are “off” by default. I don’t know why they were made “on” by default in Clojure, but it rendered pre/post
into a foot gun. And that’s why there are two upvoted ask.clojure.org request to try to fix this:
Unfortunately, a debug-assert
would not fix pre/post
which would likely continue to use assert
, and so pre/post
will remain a under-used feature, because it’s unsafe to use currently.
I’ll quote from both your post here and your comment there.
The challenge with assertions is that, what if the issue happens only for a small fraction of user-interaction with the software, but where the software crashes, it impacts all other user-interactions.
Assertions are for checking correctness, not user input. So “user interaction X causes corruption” doesn’t mean the user interaction is wrong — it means the program is wrong. Sometimes running the wrong program is catastrophic.
“What’s the point in running the wrong program?” is a perfectly legitimate perspective in some domains. Maybe not in yours or mine today. Nevertheless!
In reality,
:pre
and:post
would be a great place to validate that a function that expects a string is passed a string and not an int
I acknowledge that a lot of people want Clojure to be more like Java and blow up if passed an argument of the wrong type. But it’s hard to overstate just how much this is explicitly not something assertions are designed for.
I think in practice, nobody is able to think of a program condition that is deserving of an assert.
People not using assertions when they make sense doesn’t bother me. To me it’s more legitimate to be concerned with Clojurians using assertions for situations they’re not designed for. The former is a missed opportunity, the latter causes unnecessary headaches for, say, library consumers.
In my opinion, this is a sad artifact of Clojure’s choice to default them to “on”. That caused an aversion to assert, where-as otherwise, there would be no issue with using them to assert type, count, size, value, contains, etc. at dev time. And that would hold true of pre/post
as well.
Or alternatively, Clojure assert and pre/post should have not thrown an AssertionError, but some exception that makes them rescue-able, to be more similar to a design-by-contract system.
Clojure.spec has learned from that mistake, and:
I’m not sure what the initial rationale was for assert to be on by default and throw AssertionError, but in practice it leads to an aversion to using the feature.
Huh. The sources I’ve been reading consistently describe design-by-contract (and assertions) as designed for scenarios where you want to fail hard and fast and potentially disable the checks at runtime.
(The gist’s Ned Batchelder reference is the sole exception, in that he makes an interesting distinction between assertions’ semantics and their behavior.)
Heh, this made me check my libraries…
HoneySQL mostly throws ex-info
with information about invalid arguments – but there were a few assert
calls in there that should also have been proper validation and ex-info
. Fixed.
next.jdbc
mostly throws IllegalArgumentException
but had a surprising (to me) number of assert
calls – and even a test that asserted(!) an AssertionError
was thrown. Ahem. Also all fixed now.
Yes, but there’s nuance missing.
Design by Contract comes from Eiffel, and in Eiffel, they say that contract violations should fail fast, and “not be handled”. This does not imply crashing the thread or the running application, it means that your exception handling should not try to side-step the issue by say retrying, or falling back to an alternate strategy. Where as if it was an I/O error, which is not a contract violation, you might retry, or try a different location, or call a fallback endpoint, etc.
In Eiffel, contract violations raise exceptions similar to any other, and unlike in Java, it does not throw something different that won’t be caught. The advice is, contract violations should indicate program errors, as in, don’t use contracts to model expected outcomes that don’t indicate bugs, but otherwise it does not recommend failing hard, but instead having graceful degradation.
Basically:
In Eiffel, contract violations fail fast, but not hard, they are handled to keep the application running, only the immediate termination of the current routine happens by throwing an exception when the contract is violated, but the thread or application can continue to operate.
And in Java, assertions are meant to be a dev/test feature, where failing hard is fine.
That’s why I say, it needed one of two things, either be default off, so they can fail hard like Java assertions, and are used in dev/test only. Or fail fast, with graceful degradation, by throwing an exception and not an error, so they can be handled appropriately.
I appreciate this explanation