December 17, 2022

Introducing the Exercism Clojure Representer

What is a representer? You can read about them in this article, or just watch Jeremy explain it in this video:

Representer video

The problem statement is very simple: The heart of the Exercism community is all about mentorship. We have received hundreds of thousands of exercise submissions for the Clojure track alone, many of which are quite similar as I intend to highlight in this article. This results in our mentors having to repeat themselves a lot, which makes poor use of what is one of our biggest strengths - the human connection. We want to flip the situation around by allowing them to share their knowledge where it can be much more valuable - the uncommon submissions.

The strategy we have chosen to accomplish this is to develop a system of analyzing code to identify the most commonly used approaches, and building a UI that allows our most trusted mentors to submit automated comments to be displayed whenever that approach is used. This is done via the new Automation tab in the Mentoring dashboard:

Automation tab

This has enormous potential for maximizing the effect of their effort - instead of repeating the same advice each time, they simply provide it once.

Clojure Representer implementation

The Clojure Representer works by taking the student's solution and applying a series of normalizations, each accounting for differences that do not affect the approach used, such as whitespace, macros, and variable names.

Most of the work is done using clojure.tools.analyzer, with additional processing done with rewrite-clj. We make use of the analyzer for its macroexpansion capability, as well as its ability to emit Clojure forms from the generated AST. Specifically, we use the emit-hygienic-form function which includes a pass called uniquify which normally replaces all the local variables with unique identifiers, but we use a local version that is modified slightly so that instead of making the names unique, it replaces each occurrance with a generic placeholder name.

In a future post we may dive more into the implementation, but that's the basic idea. Here, what I'd like to do is just show it in action by demonstrating its practical use - grouping the solutions into common approaches.

Common approaches to the two-fer exercise

I ran the last 500 submissions to two-fer through the representer and processed them into a sequence of maps consisting of only the solutions resulting in unique representations, and sorted them by the number of times used. The exercise is very simple, it teaches the concept of default parameters. Here are the 7 most common occurrances, which together account for 365 of them:

Approach 1 - 142 submissions

(defn two-fer 
  ([] "One for you, one for me.") 
  ([name] (str "One for " name ", one for me.")))

(two-fer "Alice")
(two-fer)

The most common solution (just by a hair) is a standard multi-arity function which either returns a string concatenated with the supplied argument, or a default string if none supplied.

Approach 2 - 138 submissions

(defn two-fer 
  ([] (two-fer "you")) 
  ([name] (str "One for " name ", one for me.")))

Slightly less often used it is a solution that is very similar, except a bit of repitition is eliminated by having the first arity call the second one, passing the default parameter that way.

Approach 3 - 48 submissions

(require '[goog.string.format])

(defn format
  [fmt & args]
  (apply goog.string/format fmt args))

(defn two-fer 
  ([] (two-fer "you")) 
  ([name] (format "One for %s, one for me." name)))

It is like the second approach, but the variable is interpolated using format instead of concatenated with str.

(The format function using goog.string.format was removed from Clojurescript, so we need to define it for this snippet to work. This is not necessary for the Clojure solution.)

Approach 4 - 25 submissions

(defn two-fer 
  ([] "One for you, one for me.") 
  ([name] (format "One for %s, one for me." name)))

This is like the first approach, but using format instead of str.

Approach 5 - 5 submissions

(defn- _two-fer [name] 
  (str "One for " name ", one for me."))
   
(defn two-fer 
  ([] (_two-fer "you")) 
  ([name] (_two-fer name)))

This solution uses a helper function that builds the string, allowing the main function to be simplified down to a dispatch function. While this may not seem like a huge advantage here, it is a useful pattern to understand for when dealing with more complex functions later.

Approach 6 - 4 submissions

(defn two-fer [& [name]] 
  (format "One for %s, one for me." (or name "you")))

This one uses a variadic function with destructuring, taking a variable number of arguments instead of writing a separate function body for each arity. It uses or to return name if it is non-nil, otherwise "you".

Approach 7 - 3 submissions

(defn two-fer [& name] 
  (str "One for " (or (first name) "you") ", one for me."))

Like the variadic solution above, but building it with str instead of format, and without the destructuring.

This illustrates the value of the representer. By simply writing a bit of tooling that identifies these top 7 approaches, we can provide automated feedback for 75% of our submissions!

To support ongoing work on the Exercism Clojure track, you can become a patron. I also often livestream development on my YouTube channel. Thanks for reading!

Tags: exercism