Learning Clojure - Easy Challenge #3

To help me learn Clojure I've been using it as my language of choice to work through some of the programming challenges posed over at the DailyProgrammer subreddit. I'm recording my answers here so that I can better understand what I've written and so that I can refer back to them easily if I ever need to.

In the last challenge I built a working-hours calculator to tell me how many hours were left in my working week. This time I was challenged to build a Caesar cipher that can encrypt and decrypt messages that you pass to it. Here's my code:

(def letters (map #(hash-map :letter  (str (char %))
                                      :number (Integer. %))
                           (range 0 126)))

(defn wrap-number
  "Wraps numbers around the range of numbers available in letters"
  [number]
  (let [max (count letters)
        min 1]
    (if (> number max)
      (recur (- number max))
      (if (< number min)
        (recur (+ max number))
        number))))

(defn get-cipherset
  "Returns a set of characters with the number associated with each letter offset"
  [baseset offset]
  (map #(assoc-in %
                  [:number]
                  (wrap-number (+ (:number %) offset)))
       baseset))

(defn get-new-letter
  "Takes two sets of characters and switches between them based on the associated number"
  [letter oldset newset]
  (:letter
   (first
    (filter #(= (:number %) 
                (:number
                 (first
                  (filter (fn [rec] (= (:letter rec)
                                       letter))
                          oldset))))
            newset))))

(defn encrypt
  [text offset]
  (let [cipherset (get-cipherset letters offset)]
    (clojure.string/join (map #(get-new-letter (str %) letters cipherset) text))))

(defn decrypt
  [text offset]
  (let [cipherset (get-cipherset letters offset)]
    (clojure.string/join (map #(get-new-letter (str %) cipherset letters) text))))

(defn easy-challenge-3
  []
  (let [key 15]
    (decrypt
     (encrypt "This is my answer to easy challenge #3" key)
     key)))

This is a bigger chunk of Clojure code than I've written so far. Let's dig into it.

The first thing I've done is to define a symbol letters, which maps over the range of numbers 0 through 126 (the ASCII character codes) with a function that transforms the number into a hash-map containing a :letter and a :number key value pairs, where the :letter value is the string representation of the character representation of the number in question. So for instance, take the number 67; applying char to it would turn that into the uppercase character \C, which once you apply str to it becomes "C". Applying the mapping function to the number 67 would return the hash-map {:letter "C", :number 67}. The Var letters then returns a seq of hash-maps, for the numbers 0-126, giving us the character set a-z, A-Z, the numbers 0-9, and a whole bunch of special characters which should give us a pretty good character set to work from. Encrypting anything outside of that character set won't work.

A Caesar cipher works by offsetting the letters in a string by a number of letters. So offsetting "A" by 3 characters could become either "D" of "X" depending on which direction you offset it by. The image to the right that I found on Wikipedia helps explain it better than I can. So to encrypt the letter "A" is easy, because all you have to do is take the numeric representation of "A" (which is 1 in this case) and increment it by the number of characters you want to shift (which is 3 in this case, making the new number 4), and then convert that number back into character form, which is "D" in this case. Anyway, one of the problems you find when you're trying to do this is that wrapping a letter at the edge of character set is more difficult, because trying to map forward a character like, say, "Z", means not simply adding 3 to the numeric value of "Z", but instead wrapping around back to the start, so that "Z" ends up as "C".

To do this, I wrote a function called wrap-number, which takes in the numeric value of a letter once it's had the offset applied, and wraps that number around the available character set if necessary. So for instance, with a character set of 10 passing it the number 5 would return 5, because 5 is within the bounds of the character set. Passing in the value 12 however would lead to wrapping, because the number 12 is outside the character set 1-10. In this case, wrap-number would turn 12 into 2. The function will wrap as many times as it needs to, because it is recursive, so that even if we passed in the number 22, it would wrap once to get a value that was still outside the boundaries of the set, and then wrap again to get 2. This means you can offset by any number you like and still end up with a mapped number.

The second function in the solution is called get-cipherset. It takes in a baseset (a seq of maps containing :letter and :number pairs, like in letters) and an offset, and maps through the baseset to create a new seq of maps, but where the :number value of the map has been updated to account for the offset taking into account the wrapping done by wrap-number. The result of all of this is that supposing you have a baseset of letters, and an offset of 5, you would end up with a set of characters that looked just like that of letters, except that all the values on :number were increased by 5, except for the last few, which would have wrapped. As example:

letters
;;=> (...
;;=> {:letter "A", :number 65} 
;;=> {:letter "B", :number 66} 
;;=> {:letter "C", :number 67} 
;;=> ...)

(get-cipherset letters 5)
;;=> (...
;;=> {:letter "A", :number 70} 
;;=> {:letter "B", :number 71} 
;;=> {:letter "C", :number 72} 
;;=> ...)

This gets us to the point of having a base set of characters, and a cipher set of characters. Now we need to be able to translate letters between the two of them. To do this, the get-new-letter function takes in a letter, an oldset, and a newset, and does the translation by filtering for the letter in the oldset, and using the :number of that letter to filter for its corresponding record in the newset, and then returning the :letter value of that new record. This function doesn't care if we're going forwards or backwards, encrypting or decrypting, it simply translates between the two sets. This means that simply by swapping the order that we pass in the two character sets as arguments, we can encrypt or decrypt letters as we feel fit.

And that's exactly what we do in our encrypt and decrypt functions. They take in the text to alter, and the offset that we want, generate a cipherset using that offset, and then map over the text, using get-new-letter to translate between the character sets. The only difference being the order in which we send get-new-letter its arguments.

Lastly, the function easy-challenge-3 provides an example usage of the code, encrypting and then decrypting the phrase "This is my answer to easy challenge #3", using the key of 15.

How do I think I did?

I'm happy with my solution and the breakdown of my functions, and at this stage to me this feels like quite a neat solution. I'm about 80% sure that I could hugely simplify the code of get-new-letter though, and I'm begging to think that keeping the sets as maps of :letter and :number is probably unnecessary. I didn't consider until this write-up that I could just convert the characters I needed into numbers, apply the offset, and then convert them back to characters, instead of holding large sequences of maps in memory. I'll maybe revisit this and see if I can cut down the code I'd need in this case. I also learned as part of this that using the recur special form instead of using the function name is the preferred way of doing recursion, so that will help greatly going forward.