Side Effects Aren't Legos (I)

A referentially transparent bit of code can be replaced with the value it produces without altering the behavior of our program.1 Approaching problems using them makes programming an elegant process of building up transformations like Legos until you have a rad logical structure.

Like Legos, each function call is directly and concretely connected to the piece above and below it.

Also like Legos, mixing non-Lego-like objects tends to make building things much more difficult. In progrmaming terms, mixing referentially transparent expressions with side-effecting code hinders our ability to produce high quality programs.

In this installment of the Side Effects Aren’t Legos series we will investigate how mixing Legos with, say, oranges or spaghetti makes it more difficult with respect to testing.

Testing

Here’s a simple example, to show why you should try to separate side effects from pure data transformations.

How do you test this code?

;; Sample A
(defn my-print [s path] (spit path (str "I was given: " s)))

Notice that the data transformations our program makes are typically the tricky bits that we want to test. So here, we would want to check that “I was given: “ is correctly appended to s.

How to test it:

  • Construct a path
    • Ensure the path’s directory exists, or make it
    • Ensure there’s no file at path
      • Delete the file if there is one
  • Call my-print on a string like “abc”
  • Test that the file at path’s contents equals "I printed: abc"
  • Finally, remove the file at path, and whatever directories it needed

This test is pretty complicated - also since it is safe to assume clojure.core/spit works, we are introducing unnecessary complexity. We only care about ensuring the proper transformation was made on our string (s). However since we are testing a side effect at the same time, there is maybe 7x as much work.

Forgive me for not writing a code sample to do that. :)

Lego Mode:

Instead, let’s snap together 2 functions to handle this.

Compare Sample A with:

(defn my-transform [s] (str "I was given: " s))

(defn my-print [s path] (spit path s))
  1. Call my-transform on a string (say “abc”)
  2. Test the return value is “I was given: “abc”

Now, just test that my-transform works!

(deftest my-transform-test
  (is (= "I printed: abc" (my-transform "abc"))))

Note that when testing my-transform we are free to use a string generator, without adding too much “thought overhead”.

(defn generate-string []
  (->> "abcdef" shuffle (apply str)))

;; can be run once with a generated string:
(deftest one-my-transform-test
  (let [a-string (generate-string)]
    (is (= (str "I was given: " a-string)
           (my-transform a-string)))))

;; or a thousand times
(deftest one-thousand-my-transform-test
  (let [strings (repeatedly 1000 generate-string)]
    (doseq [s strings]
      (is (= (my-transform s)
             (str "I was given: " s))))))

Extra Credit

Even better is to make my-print take a function to apply on s. Then we can pass in any pure function, and have the side-effect separated by design.

(defn my-print [s path & [f]]
  (let [f (or f identity)]
    (spit path (f s))))

Conclusion

Try to avoid mixing side effecting code into the beautiful world of data driven development!

Footnotes

1 John C. Mitchell (2002). Concepts in Programming Languages. Cambridge University Press. p. 78