They say Haskell and Clojure are quite different beasts. Don’t believe them! In this post, I’ll show you how to make Clojure more Haskellish than Haskell itself!
In the previous post, we improved the function from the first post by encoding the directory our effects live in at the type level. After firing up a few language pragmas, summoning a demon or two from the Haskell standard library, and bending our types here and there, we were good to go. Now, imagine your customer has just been diagnosed with paranoia and suddenly wants you to check at compile time that your I/O function actually writes to the directory and nowhere else. “Sure”, you say, and ask him for a contact number for his psychologist. “Stop”, I say, “ask the guys from Flexiana for help, they all drive Evos… I mean… they craft their code in Clojure!”
Tooling
Let’s begin, then. First, the tooling:
:dependencies [[org.clojure/clojure "1.10.0"]
[org.clojure/core.match "0.3.0"]]
(ns safety-performance.core
(:require [clojure.spec.alpha :as s])
(:require [clojure.core.match :refer [match]]))
Haskellish Clojure
We’re going to shamelessly copy the design pattern of algebraic effects. More precisely, we’ll split the code into two parts: building the computation
(defn make-file-writer [dir c]
(->>
(list
[::trace (will-write-msg dir c)]
[::io (make-file-path dir c) (file-contents dir c)]
[::trace (wrote-msg dir c)]
[::state c inc])
(repeat 5)
(apply concat)))
…and running it:
(defmulti run first)
(defmethod run ::file-path [[_ dir sep c ext]]
(str dir sep @c ext))
(defmethod run ::msg [[_ start file-path end]]
(str start (run file-path) end))
(defmethod run ::file-contents [[_ start file-path end]]
(str start (run file-path) end))
(defmethod run ::state [[_ & args]]
(apply swap! args))
(defmethod run ::trace [[_ msg]]
(println (run msg)))
(defmethod run ::io [[_ file file-contents]]
(spit (run file) (run file-contents)))
(def write-files (map run))
Here, dir
is the directory to write to and c
the counter (the integer variable in Haskell). Each side effect is represented by a vector whose first element (on which the run
multimethod dispatches) encodes the type of the side effect (StateC
, TraceByPrintingC
, LiftC IO
in Haskell). To make deep checking of the side effects possible, we use the same representation for the pure sub-computations, too:
(defn make-file-path [dir c] [::file-path dir "/" c ".txt"])
(defn will-write-msg [dir c]
[::msg "Will write " (make-file-path dir c) "."])
(defn wrote-msg [dir c]
[::msg "Wrote " (make-file-path dir c) ".\n"])
(defn file-contents [dir c]
[::file-contents "You've just opened " (make-file-path dir c) "!"])
Now, let’s build our effectful computation:
(def dir "test/safety_performance/data")
(def c (atom 1))
(def file-writer (make-file-writer dir c))
When you don’t take your pills…
Checking that the computation doesn’t contain any malicious side effects (as we did in the first post) is as simple as:
(s/explain
(s/coll-of
#(match %
[::state _ _] true
[::trace _] true
[::io _ _] true
:else false))
file-writer)
The code above will return a resounding “Success!” if and only if file-writer
is a collection of side effects of the correct type. Checking that the ::io
side effects are parametrized by the correct directory (as we did in the second post) entails only a slight adjustment to the code above:
(s/explain
(s/coll-of
#(match %
[::state _ _] true
[::trace _] true
[::io [_ x "/" & _] _] (= x dir)
:else false))
file-writer)
Recall the type gymnastics we had to go through to make this work in Haskell! And now for the finale. We’re going to check that the I/O function actually writes to the correct directory and nowhere else. And while we’re at it, we’ll also check the logs don’t lie and the file contents don’t fool us into thinking we’ve opened a different file:
(s/explain
(s/coll-of
#(match %
[::state _ _] true
[::trace [::msg _ [_ x "/" y ".txt"] & _]]
(and (= x dir) (= y c))
[::io [_ x "/" y ".txt"] [::file-contents _ [_ u "/" v ".txt"] & _]]
(and (= x u dir) (= y v c))
:else false))
file-writer)
I believe we have attained such a level of confidence in our code that we can release the beast without fear of it biting anyone:
(sequence write-files file-writer)
Conclusion
To conclude this miniseries of posts, I will once again compare Clojure to the Evo: yes, it can get you in trouble. However, it also gives you the power to skate through the most difficult situations. In the end, it’s not so much about the car, but who’s driving it!