Tracing Clojure with Contrail.

One of the great things about the “better is better1 ethos in the Common Lisp community is that it’s given rise to a culture of very good tools. For example, most implementations extend trace in interesting ways. In particular, SBCL provides a number of useful options which I’ve missed when working in Clojure. (There are a number of tools in this space for Clojure, most notably clojure.tools.trace, which ships with Clojure, but their functionality is limited.)

A little while ago I began building Contrail, which is an attempt to bring some of these features (as well as others that might prove useful) to Clojure. It’s built using the Richelieu advice (in the Emacs Lisp sense) library, with a few tweaks to support advice that persists when an interactively traced var is re-defined (typically by the user re-compiling her source file).

This “persistent trace” feature is a good example, in fact, of some of the tradeoffs between the “better is better” approach (with its focus on interface simplicity) and the now-dominant2 “worse is better” ethos (with its focus on implementation simplicity). I wanted a simple interface: when you, the user, invoke the trace function on a var, it stays traced until you subsequently call untrace.

In particular, you shouldn’t need to think about the fact that the tracing is implemented by re-binding the traced var to a modified copy of the function to which it was originally bound, nor should you have to worry about whether recompiling the var’s original defn will blow away that binding and thus your trace.

It turns out that this simple interface is bought at the cost of substantial implementation complexity. It’s no longer possible to store all of the information regarding trace state on the traced var itself (since defn replaces not only the existing value of a var but its metadata as well), so now I need to store the set of traced vars separately.

Additionally, it becomes necessary to monitor changes to the var’s value and re-trace it as required–and of course I need to distinguish between changes made by the tracing library (e.g. when a user explicitly untraces) and those made elsewhere:

(defn get-watcher
  "Returns a function suitable for use by `add-watch` which
  will watch a var and re-advise it with `advice-fn` whenever
  it changes."
  [advice-fn]
  (fn [_ advised-var _ new-var-value]
    (when-not *suppress-readvising?*
      (binding [*suppress-readvising?* true]
        (println advised-var "changed, re-tracing.")
        (unadvise* advised-var)
        (advise* advised-var advice-fn)))))

(add-watch f "contrail" (get-watcher final-advice-fn))

But it’s worse than that; I’ve also got to handle the case where a var that has been explicitly untraced by the user gets accidentally bound to its old, traced value (this is a situation which with-redefs makes it very easy to get into). So I’ve got to check for that on every invocation of the trace reporting machinery:

There is a non-trivial amount of implementation complexity involved in supporting the simple interface to trace. Is this the right tradeoff? I’m not sure. I think the answer mostly comes down to whether I’ve hidden that complexity successfully, or whether trace is a leaky abstraction. As the author I’m very poorly positioned to answer that question, so I’ll have to see what happens when and if Contrail picks up other users.

  1. Gabriel calls this the “the right thing” ethos, but I think this connotes something more rigid and deterministic than the design process and belief system which he is actually describing. 

  2. There are notable exceptions, of course: Apple frequently attempts to build simple interfaces concealing implementation complexity (e.g. Handoff, various cross-device syncing) with mixed results. 

April 5, 2015