First Clojure Program - Looking For Feedback

I’ve written my first non-trivial program and am looking for feedback in terms of style, alternative approaches, or any other suggestions for improving it.

I’m hoping the intent is clear from the code / comments. Thanks!

;; pick <limit>

;; display a random integer between 1 and <imit> inclusive, ensuring
;; previously selected values are not repeated, remembering history
;; betwen invocations

;; use first command line param as limit and fail if it isn't present

(let [limit (first *command-line-args*)]
  (when-not limit
    (println "usage: pick <limit>")
    (System/exit 0))

  ;; if (chosen-<limit>.txt) exists, open it and read history of chosen numbers

  (let [file-name (str "chosen-" limit ".txt")
        chosen-numbers-str (if (.exists (clojure.java.io/file file-name))
                             (slurp file-name)
                             "")
        chosen-numbers (set (clojure.string/split chosen-numbers-str #"\s+"))
        all-numbers-seq (range 1 (+ (Integer/parseInt limit) 1))
        all-numbers (set (map str all-numbers-seq))
        available-numbers (clojure.set/difference all-numbers chosen-numbers)]

    ;; pick number from 1 to <limit> inclusive that has not already been chosen

    (when (empty? available-numbers)
      (println "sorry, all numbers have been picked")
      (System/exit 0))

    (let [selected-number (rand-nth (vec available-numbers))]
      (println selected-number)
      
      ;; create chosen-<limit>.txt if it doesn't exist
      ;; write out latest choice to chosen-<limit>.txt and close file

      (spit file-name (str selected-number "\n") :append true))))

That’s readable code leveraging core libraries; good for an initial effort.
When I went through it, and thought about how I would approach the problem, I defaulted toward a couple of alternative ideas:

  • 1 - leverage the clojure reader and .edn as a simple format for serializing the state.
  • 2 - separate out steps into smaller functions and compose them to get the work done.

1 makes things easy since we can alter the algorithm for generating random picks to not even use sets. Instead, we can just generate a random sequence of numbers once, and store it for draws. When we run out of numbers in the sequence, we’re done. No checking for previously chosen values, no need to manually parse things (the reader does it for us).

2 helps make things a bit more readable (and easy to alter). I was able to extend the api after the fact by threading through an optional argument to reset the sequence of draws, so once the draws are exhausted a new sequence can be initiated. It also allows us to sort of simplify the control flow and factor out the imperative branches (like the multiple calls to System/exit depending on failure conditions). I opted for a simple api to advance the random number sequence and record the choice (using a clojure map). We basically use sequential transformations of the map to communicated stuff by adding/removing keys, and let the absence or presence of data drive the control flow. Clojure also tends to leverage the idea of nil punning, where the absense of a thing (the nil value) conveys meaning too. In this case, if the pick function can’t advance because there aren’t any numbers left, it will return nil, which can be picked up by consumers like draw! to indicate absence. If there aren’t any numbers, we can inform the user.

I also targeted using the clojure cli to run this as a little script:

(ns picker
  (:require [clojure.java.io :as io]
            [clojure.edn]))

(defn init-numbers [n]
  {:limit n
   :remaining (shuffle (range 1 (inc n)))})

(defn pick [{:keys [remaining] :as m}]
  (when-let [n (first remaining)]
    (assoc m :remaining   (rest remaining)
           :choice n)))

(defn load! [limit reset?]
  (let [fname (str "chosen-" limit ".edn")
        f     (io/file fname)]
    (if (or reset? (not (.exists f)))
      (init-numbers limit)
      (-> f slurp clojure.edn/read-string))))

(defn save! [{:keys [limit] :as m}]
  (->> (with-out-str (pr m))
       (spit (str "chosen-" limit ".edn"))))

(defn draw!  [limit reset?]
  (if-let [new-state (-> limit (load! reset?) pick)]
    (do (println (new-state :choice))
        (save! new-state))
    (println "sorry, all numbers have been picked")))

(defn run []
  (let [[limit reset? & more] (map clojure.edn/read-string *command-line-args*)]
    (if limit
      (draw! limit reset?)
      (println "usage: pick <limit> <reset?>"))
    (System/exit 0)))

(run)

;;example usage from powershell with clojure cli, assuming picker.clj is
;;in the current directory:
;; PS C:\Users\joinr> clj -M picker.clj 3
;; 1
;; PS C:\Users\joinr> clj -M picker.clj 3
;; 2
;; PS C:\Users\joinr> clj -M picker.clj 3
;; 3
;; PS C:\Users\joinr> clj -M picker.clj 3
;; sorry, all numbers have been picked
PS C:\Users\tom> clj -M picker.clj 3 true
3
PS C:\Users\tom> clj -M picker.clj 3
2
PS C:\Users\tom> clj -M picker.clj 3
1
PS C:\Users\tom> clj -M picker.clj 3
sorry, all numbers have been picked
PS C:\Users\tom>
;;PS C:\Users\joinr> clj -M picker.clj
;;usage: pick <limit> <reset?>
5 Likes

Thank you so much for your thoughtful response. You’ve given me a lot to go research and think about, particularly in regard to the reader and edn which I’m not familiar with except as buzzwords. I haven’t really had time to digest your code in any detail, but I have a couple of quick comments:

  1. One thing I didn’t like about my code was the “nested lets”. In something like C or C#, checking for early “bail out cases” like I did would avoid the use of nested ifs. It seems that something analogous was happening with my nested lets despite my usage of the “early bail outs”. To some degree you ameliorated this problem just by breaking things up into functions with their own scopes.

  2. You mentioned that you targeted clojure cli to run this as a script. To avoid confusing the issue, I didn’t mention that I’m actually running this under Windows like you, and that I’m using babashka with the recipe described here to create a self executing .bat file to run the code. I’ve never used clojure cli, so I’d be curious how the startup times for small scripts compared to babashka.

Again, thanks for your help!

In something like C or C#, checking for early “bail out cases” like I did would avoid the use of nested ifs.

You can use let as an imperative construct if you ignore the return or you have bindings that eventually throw an exception or similarly bail out (like System/exit). You can repackage your nested lets into one:

(defn quit! [msg]
  (println msg)
  (System/exit 0))


(let [limit (or (first *command-line-args*)
                (quit! "usage: pick <limit>"))
      file-name (str "chosen-" limit ".txt")
      chosen-numbers-str (if (.exists (clojure.java.io/file file-name))
                           (slurp file-name)
                           "")
      chosen-numbers     (set (clojure.string/split chosen-numbers-str #"\s+"))
      all-numbers-seq    (range 1 (+ (Integer/parseInt limit) 1))
      all-numbers        (set (map str all-numbers-seq))
      available-numbers  (clojure.set/difference all-numbers chosen-numbers)
      selected-number    (if (empty? available-numbers)
                           (quit! "sorry, all numbers have been picked")
                           (rand-nth (vec available-numbers)))]
  (println selected-number)
  (spit file-name (str selected-number "\n") :append true))

There are also useful macros like some->> and some-> that will pass along and short circuit when they hit a nil return. You can cook up macros to handle custom control flow (e.g. a special version of let that will quit if a binding fails maybe), or use libraries like GitHub - adambard/failjure: Monadic error utilities for general use in Clojure(script) projects for more sophisticated composed error values.

Shouldn’t a truly random number generator produce repeats?

Your picker code is very nice @joinr, thank you for sharing! But I believe running it with clj -M picker.clj 3 will not execute anything unless run is called by something. Adding (run) at the end of the script worked for me.

good catch, thanks. It was a copy/paste error. I updated the original.

1 Like

Cool, that makes sense. More things to investigate! Thanks.

A pure random generator should, but one that behaves the way I want shouldn’t :slightly_smiling_face: