4 ms·
Nil-checking is a form of input validation. Validation features often leak into helper functions lower down which don't have the context or control to solve the
by pgt 8y ago
Nil-checking is a form of input validation. Validation features often leak into helper functions lower down which don't have the context or control to solve the source of a problem, where they don't belong. Separation of concerns means assigning management of risk to the bearer of profit (the calling code), where it can "fail/retry/failover" fast, before leaking validation code into functions that could be pure.
Therefore, I prefer to hoist missing value handling up to the caller, akin to "smart vs dumb components", where the caller has sufficient context and control to recover from errors. For example,
Instead of:
(defonce bus (ZeroMQ. "some-url")) ;; this might be null, but it'll leak into helpers that should only care about values
(defn transmit! [bus payload]
(if bus ;; why???
(if payload) ;; gross
(.send bus (zmq/encode-string payload))
(log/error "nil payload whoops")
(log/error "something bad happens that the caller doesn't understand and can't do anything about")
(defn tick! [ms]
;; athe wrong place to handle initialization problems
(if-let [input (.read bus buffer-length)] ;; multiple failure modes
(transmit! bus [:some (inc input)])
(comment "do nothing")))
Instead, handle initialization and input validation nearer the source and pass in system components explicitly to simplify mocking and testing:
(defn transmit! [^ZeroMQ bus payload] ;; type hint
{:pre [bus payload]} ;; basic guards
(->> payload ;; notice lack of nil-checking. you have to enforce contracts somewhere
(zmq/encode-string)
(.send bus)))
(defn init! [{:as config :keys [zmq-url]}]
{:bus (ZeroMq. zmq-url)
:redis ...
:logger ...})
(defn tick! [{:as system :keys [bus]} ms]
(if-let [parsed (parse-input (.read bus buffer-length))]
(transmit! bus [:some (inc parsed)]) ;; parse-input always returns valid data
(log/error "it's fine to handle runtime parsing errors here"))
(defonce !running (atom true))
(defn -main []
(let [system (init! {:some "config-url"})] ;; attempt bus failover here
(if-let [bus (:bus system)]
(go-loop []
(if @!running?)
(do
(tick! (time/now))
(Thread/sleep 30)
(recur))
(log/info "Shutting down..."))
(log/error "Retrying serial bus connection... ."))))
- dustingetz 8y agoAre you suggesting that there is a meaningful difference between (:firstname record) and (.-firstName record), or (func x) and (.method x), the first of which is nil safe and the second isn't? (It doesn't seem you are, because you're focused on the ifs, but nil punning is about eliding ifs)
- pgt 8y agoHi Dustin. No, those function/method calls are equivalent. My central point is that it is not reasonable to expect the consumer of a value to recover from a failure mode that was generated at the producer. Rather, it is better to handle failover and retries at the producer. Of course, once a (valid) value has left your system, any downstream errors are the purvue of the subsystem, but you may want to provide a signalling pathway back to the producer if it can be resolved upstream. This naturally results in writing thinner consuming functions and "fatter producers" or "smarter callers."[^1] https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0 https://medium.com/@dan_abramov/smart-and-dumb-components-7c...
- dustingetz 8y agonil doesn't have to be a failure mode, if we are programming with data it can just mean "empty". The trouble, I think you would point out, is that (.send bus payload) doesn't have a sensible definition of empty – because the bus reference is imperative/stateful/object oriented, not data oriented. Nil punning can't help with foreign interop to imperative systems, but that doesn't mean nil is the root cause of the pain. The imperative dependency is.