Welcome! Please see the About page for a little more info on how this works.

+3 votes
in Clojure by
edited by

I have been using Clojure for about 2 years now and love the language. Without making this into a static vs dynamic typing debate, I have been bouncing between Clojure and Rust for projects. With Clojure my biggest issue has been the discoverability of keywords and data shapes received and returned from functions.

For example, when it has been some time since I worked on a code base and I jump back in to look at a function, it is not immediately clear what is the structure of the data that is being accepted. Likewise with maps, selecting the right keyword (ie. is it :home-address or :address-home) takes extra time to search code outside of what I am currently writing. I also recently have been trying to use clj-fx which is a real nightmare of identifying the correct keywords. (Requires digging through Java docs).

With statically typed languages I have found it really useful to immediately see the structure/types of the arguments and return values without having to (re)study the function body. I am looking how this programmer "problem" of discoverability is best addressed in Clojure. I know this is not a new problem and can be addressed by several different approaches:

  • Specs: yet creating a separate spec def for each function is a bit cumbersome. I have also tried using defn-spec and Ghostwheel/Orchestra, yet none have seemed to cleanly integrate without other problems. (Linting for example).
  • Expressive doc strings: works, but has the same issues as comments becoming disconnected from the actual code.
  • Rich comment blocks. I like this approach and have used it heavily. While it is a bit repetitive as well, at least it serves multiple purposes. (Documenting + exploring via evaluation)
  • Tests in another file: don't like how this requires switching files and searching for the test.
  • Tests in defn
  • Spec2 in the future

I am curious what people have found as a best practice in this regard? What am I missing as clearly Clojure is used in relatively large code bases by distributed organizations. Any good examples of projects that demonstrate these practices?

2 Answers

+1 vote
by

I like to always use destructuring in the function, and using destructuring with qualified keywords I can go-to definition of this keyword to see what is available in there.

To know which keywords are available in the function, I setup a scope capture, then run a test (preferably an integration one)

There is many ways to do scope capture, from libraries to inline def, tap> is also a kind of scope capture.

With the captured scope, I can simply ask for (keys argm) in the repl and it will print all the available keywords to that function.

0 votes
by

There are two cases for me, either the function describes its input and the caller must provide them in the correct shape, or the function leverages an existing application entity (data model), and should refer to its definition by name.

In the case where a function doesn't operate over an entity, I make sure to have a good argument name, to describe the shape in the doc-string and to use destructuring if taking a map or tuple to name each element. Sometimes I might also have a spec for it, though that's really only when I want to also have generative tests or do validation with it.

In the case where a function operates over an entity, I make the name of the argument the same as the name of the entity definition.

I define my entities either using a constructor, a function called make-foo thus creates an instance of an entity of conceptual type foo. That function will document the shape of what it creates, or it'll be pretty easy to figure it out from looking at the code for it. Or I define them using a record where the record definition describes the shape. Or I define them using a Spec with the spec name the name of the entity.

(defn make-item
  "Makes an item which is a map of key
   :id UUID of the todo-list
   :name - the user readable string name of the item
   :content - the user provided string content of the item"
  [name content]
  {:id (java.util.UUID/randomUUID)
   :name name
   :content content})

(defn make-todo-list
  "Makes a todo-list which is a map of key
    :id - UUID of the todo-list
    :name - the user readable string name of the list
    :items - an ordered vector of item"
  [name & items]
  {:id (java.util.UUID/randomUUID)
   :name name
   :items (vec items)})

(defn insert-item
  "Inserts an item in a todo-list at given 0-based position"
  [todo-list item position]
  (apply conj
    (subvec todo-list 0 position)
    item
    (subvec todo-list position (count todo-list))))

(defn sanitize-input
  "Given an untrusted input string, return a sanitized version.
   Takes an optional options map with options:
     :extra-denylist - a seq of string words to delete on top of default sanitization."
  [input-string & {:keys [extra-denylist] :as options}]
  (some-lib/sanitize input-string extra-denylist)

So as you can see in my little example, sanitize-input is an example of a function that doesn't operate over the application domain model, so it just describes its input using good argument names, destructuring and a nice doc-string.

On the other hand, insert-item is a function that operates over the application domain model, so it just uses the same name as the domain entities it takes as input, and its extra argument position is simply described by a good name and the doc-string.

Finally the domain entities are described by the make- functions of their respective names. I could have used a record in their place, or a Spec as well.

...