Pure Conditioning Save

A simple, fast, purely functional condition / restart system for Clojure.

Project README

Pure Conditioning

Table of Contents

Clojars Project

Pure Conditioning is a purely functional, fast, and cleanly decomplected condition / restart system in Clojure. It does not use exceptions and needs no global state at all.

While global state is not required, it makes things more convenient so stateful variants of all pure functions that make use of a dynamicly bound var are available.

This project has been stable and in use for me without issue for nearly 18 months now. I still consider it alpha stage until I dedicate some time to revisiting its design in the coming months. At that time I may or may not introduce breaking changes. After that, if I'm still happy with the design I will bump it to 1.0.0 and try to keep it stable.

Background

I've tried a variety of error handling libraries and techniques, including the approach that Chris Houser presented a few years ago, which was probably the most successful of them, but still not totally satisfying and far from the capability of CL conditions and restarts. This library comes closer to any approach I've seen, while making basically no compromises on speed, syntax or compatibility with the Clojure world.

Tutorial and Background info

To help you understand what this library is and what it's good for, the interactive tutorial on NextJournal is probably a good place to start. I also recommend the excellent chapter on conditions and restarts in Peter Seibel’s excellent book, Practical Common Lisp.

Conditions in Clojure

I'll start here by showing some basic behavior and then move on to restarts and examples pulled directly from the CL documentation that I've come across.

Rather than raising an exception and unwinding the stack, it'd be nice if callers which may have the context could easily just inject missing information or resolve questions on intention easily, regardless of how deep down the stack the problem arises.

This library lets you do that.

Let's get started.

(require '[pure-conditioning :refer [manage condition default]])

In this first example we're doing nothing useful but we can see how the core pieces fit together. In the manage block, we define two handlers, one that will increment odd values, and another that just returns the constant :even. We realize the lazy sequence with vec, but later will see how to capture the handler scope to make lazy sequences safe for condition handling.

(manage [:odd inc
         :even :even]
  (vec (for [i (range 10)
             :let [c (if (odd? i) :odd :even)]]
         (condition c i))))
;; => [:even 2 :even 4 :even 6 :even 8 :even 10]

It's worth noting that the above usage is very simple and doesn't have any fancy restarts or other complex concepts. It just lets me ask higher scopes for context.

I may want to do something like provide a default, or throw a specific exception. That can be easily done:

(manage [:odd inc]
  (vec (for [i (range 10)
             :let [c (if (odd? i) :odd :even)]]
         (condition c i (default :unknown)))))
;; => [:unknown 2 :unknown 4 :unknown 6 :unknown 8 :unknown 10]

But what if the higher scope doesn't know? By default condition will use the required handler to throw a standard ex-info exception as follows:

(manage [:odd inc] ;; no :even handler anymore!
  (vec (for [i (range 10)
             :let [c (if (odd? i) :odd :even)]]
         (condition c i))))
1. Unhandled clojure.lang.ExceptionInfo
   No handler specified for condition
   {:condition :even, :value 0}

Provided Handlers

There are a few provided handlers, or you can easily make your own:

required        ;; the default. Raises the ex-info you see above.

(default value)
(default f)     ;; to resume with the default value. Fast and no exception.

optional        ;; equivalent to `(default identity)`.

(trace message)
trace           ;; prints out the condition and value, then returns the value.

(error message)
(error message ex-data) ;; will raise an ex-info exception.

;; Raise an instance of the given exception class:
(exception ExceptionClass message) 
(exception ExceptionClass message cause) ;; with optional cause exception

No unnecessary exceptions

It's worth noting that any of the handlers that raise exceptions only instantiate the exception class in case that the condition is hit (for instance if the condition hits the default case). The handler is just providing the condition system with the response should it need to use it.

More Handlers

While building up this library and experimenting with its implications I discovered some interesting behavior which I've captured in these more obscure handlers. Now we're getting into capabilities that go beyond other systems that I've seen.

Manage blocks may be constructed as a hierarchy. That hierarchy is preserved and may be navigated by handlers. I've provided some that make intuitive sense here but certainly have not exhausted what's possible.

(remap :new-handler) ;; is the simplest version of this.
(remap :new-handler f) ;; lets you change the value before restarting the handler search.
(remap handler-f f) ;; lets you change the new handler based on the value
(remap h f new-default) ;; lets you do the above plus change the default handler for the condition.

Remap a condition to a sibling handler

(sibling ...) ;; all of the options of `remap`, but starting from the current handler hierarchy level

Fall through to a handler defined at a higher scope after modifying the handled value

(fall-through f) ;; Change the value before falling through to look for another handler
(fall-through ...) ;; The other options are identical to `remap`

Optionally continue searching parent handler hierarchy.

(handle (fn [v] (if (good? v) v :continue))) ;; lets you choose whether to respond or just keep searching for a better handler.

Here's a silly example of a bunch of these handlers working together. Creating tangled messes like this isn't recommended in practice but it's interesting to see how all of these handlers can work together seamlessly.

;; This one requires way more than normal
(require '[pure-conditioning :refer [manage condition remap sibling 
                                     fall-through error exception required]])
(manage [:even (remap :thing reverse required)
         :odd (sibling :even vector)]
  (manage [:even (fall-through range)]
    (manage [:even (fall-through dec)
             :thing #(map float %)
             :str (error "nein")]
      (mapv (fn [i]
              (manage [:str #(apply str (reverse %))]
                [(condition :str "!ereht ih" (exception Exception "Failed to str"))
                 (condition (if (odd? i) :odd :even) i required)]))
        (range 10)))))

Restarts

Restarts take the idea of conditions and make them work in both directions. First the called method raises a condition, but provides some options to the handler. The handler handles that condition and chooses which of the provided options it prefers.

It's like they enable the called method to ask the caller a question which the caller answers.

;; Require a couple more functions
(require '[pure-conditioning :refer [manage condition restart restarts restart-with]])
(manage [:request-vacation (restart :summer "2 weeks")]
  (condition :request-vacation
             (restarts nil 
                       :summer #(str "I'll go to the cottage for " %)
                       :winter #(str "I'll go skiing for " %))))
;; => "I'll go to the cottage for 2 weeks"

Or if the startup is growing perhaps the manager needs to check the vacation policy:


(manage [:vacation-policy (restart :winter)]
  (manage [:request-vacation
           (restart-with
            (fn [condition arg default-action]
              (condition :vacation-policy
                         (restarts nil
                                   :summer [:summer "2 weeks"]
                                   :winter [:winter "3 weeks"]))))]
    (condition :request-vacation
               (restarts nil
                         :summer #(str "I'll go to the cottage for " %)
                         :winter #(str "I'll go skiing for " %)))))
;; => "I'll go skiing for 3 weeks"

Cool stuff!

CL Examples

I have incorporated two samples that I found in CL documentation. The first is from the C2 Wiki showing how restarts can be defined at multiple levels and managed from up the stack. The second comes from a paper on the CL condition system involving a robot butler. You can find them here.

Unwind the Stack With Retry! and Result!

The final missing piece is the ability to respond to an error by unwinding the stack in the same way as we are used to with try/catch blocks. We can now do that with result!. Even better, we can also respond by retrying from the current stack frame with retry!.

In this example, if do-something raises the condition :x, the result will be "nevermind" regardless of the logic nested within the do-something function, exactly as if that string were returned by a catch block.

(manage [:x (result! "nevermind")]
  (do-something 3))

much like:

(try
  (do-something 3)
  (catch Exception e
    "nevermind"))

On the other hand, retry! requires a specialized manage block called retryable which functions exactly like manage but adds the ability to handle using retry!. This is only necessary because when retrying you need to say which variable is changed, so retryable adds a binding form before the handlers. Below it is [x], but it can be any arity. The value(s) passed to (retry! value) will be bound to the variables listed in that block and then the block will be rerun.

(retryable [x]
    [:x #(retry! (+ 10 %))]
  (do-something x)))))))

These stack-unwinding operations are fully compatible with all of the other handlers and also work with any arbitrary nesting, as the retry and result operations are explicitly tied to the block that they are defined in.

Open Source Agenda is not affiliated with "Pure Conditioning" Project. README Source: pangloss/pure-conditioning
Stars
52
Open Issues
0
Last Commit
4 months ago
License
MIT

Open Source Agenda Badge

Open Source Agenda Rating