Janet
I decided to give Janet another look - I’ve mentioned Janet before in this blog, and I have my thoughts on it. However, I have never actually interacted with the language that much - I only read its documentation and some code.
Recently, I found this interesting post: My Kind of REPL by Ian Henry. It’s an interesting post, and I really liked the Braille plotting library they mentioned there. So I wanted it for myself.
That’s not strong enough motivation to switch to Janet from Fennel or Clojure, so I decided to port this library to Clojure. Yes, no Fennel this time. To be honest, I noticed that my feelings towards Fennel are becoming a bit rusty. Perhaps Janet’s author felt the same thing and made Janet after Fennel?
Well, after a bit of searching, I found the library they’re talking about in the post: bytemap.
The name is a bit confusing, I was expecting something like braille-plot
or braillemap
at least.
But it doesn’t matter - what does matter, is the ability to plot stuff in the REPL.
And the library is neat, with cool tricks to render points to the Braille symbols using bit-ops!
Kudos to Ian for implementing it!
Now, I have a lot of experience porting Clojure code to Fennel and back, and I would say that the experience is seamless enough.
What I mean by that is that most of the forms can be used as is, or some simple text substitutions are required.
Most of these are substituting Fennel’s module separator .
with Clojure’s /
, and substituting ,
in macros with ~
.
There are more differences than that, of course, but one of the goals I had with fennel-cljlib and async.fnl libraries was to allow copying code from Clojure to Fennel and back with as little changes as possible.
These two libraries provide most of the important Clojure behavior, and some of Clojure syntax to Fennel.
Janet, on the other hand, says that it is not a Clojure port, and lists all differences that come with that. On the other hand, their standard library is far more rich than Fennels, and there are a lot of inspirations taken from Clojure as far as I can see. So that’s good.
I mentioned that apart from small syntactic changes, Fennel code can be ported to Clojure fairly easily. With Janet, the situation is a bit different. Say, here’s some Fennel code:
(fn [x]
(let [a (foo)
b (bar)
b (baz b)]
(qux a b x)))
Or was it Clojure code? In fact, this code will work in the same way in both languages. Now, let’s look at the same code in Janet:
(fn [x]
(def a (foo))
(def b (bar))
(def b (baz b))
(qux a b x))
Now, this code is different, however, it will work in Clojure, yet with a subtle difference.
What is the difference you ask?
Why, in Clojure a
and b
are global bindings!
Now, you might wonder, why I bring this up.
Well, I find this strange, but in Janet, all of the documentation is written using def
instead of let
which Janet also has.
And as a result, a lot of code in the wild is written like that.
That’s another mystery to me - why Janet author, who created Fennel before that, decided that using def
is the way to create local variables?
I mean, sure, let
introduces yet another scope into the code, but is it that bad?
Anyhow, you can write this code in Janet in the same way as in Clojure or Fennel, but I’m yet to see let
used in Janet code in the wild.
If you’re interested in the full source code of the port, you can find it here.
Porting Janet code to Clojure
Janet uses def
to establish immutable lexically-scoped bindings, and var
to create mutable bindings.
It’s a bit similar to Fennel, where the local
(a much better name, TBH) is used for the same purpose as def
in Janet, and var
does the same thing.
However, when porting to Clojure we should remember that we don’t have mutable bindings of any kind.
So my first steps were to change all of the code to have correct Clojure syntax and replace all var
definitions with the use of atom
.
For example, here’s such change illustrated on the plot
function:
;; Janet ;; Clojure
(defn plot [f [w h] x-scale y-scale &named axis] (defn plot [f [w h] x-scale y-scale
(default axis true) & {:keys [axis] :or {axis true}}]
(def canvas (new w h)) (let [canvas (new w h)
(def [w h] (bounds canvas)) [w h] (bounds canvas)]
(when axis (when axis
(for i 0 h (dotimes [i h]
(draw-point canvas [(/ w 2) i])) (draw-point canvas [(/ w 2) i]))
(for i 0 w (dotimes [i w]
(draw-point canvas [i (/ h 2)]))) (draw-point canvas [i (/ h 2)])))
# we don't want to clip the extreme values off the canvas, ;; we don't want to clip the extreme values off the canvas,
# so we narrow the y range slightly ;; so we narrow the y range slightly
(def y-scale (* y-scale (/ (+ h 1) h))) (let [y-scale (* y-scale (/ (+ h 1) h))
(var current-point nil) current-point (atom nil)]
(for i 0 w (dotimes [i w]
# x spans -0.5 to 0.5 (inclusive!) ;; x spans -0.5 to 0.5 (inclusive!)
(def x (- (/ i (- w 1)) 0.5)) (let [x (- (/ i (- w 1)) 0.5)
(def y (/ (f (* x 2 x-scale)) y-scale -2)) y (/ (f (* x 2 x-scale)) y-scale -2)
(def p [(* (+ x 0.5) (- w 1)) p [(* (+ x 0.5) (- w 1))
(* (+ y 0.5) h)]) (* (+ y 0.5) h)]]
(when current-point (when @current-point
(draw-line canvas current-point p)) (draw-line canvas @current-point p))
(set current-point p)) (reset! current-point p)))
(print-canvas canvas)) (print-canvas canvas))))
Here’s a quick demo:
braille-plot.core> (plot math/sin [40 10] math/PI 1)
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⢀⠤⠖⠊⠒⠒⠤⡀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⢀⠔⠁⠀⠀⠀⠀⠀⠀⠈⠢⡀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⢀⠔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⢆⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⢠⠊⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠱⡀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡷⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⢄
⠹⡉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⢉⠝⡏⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉
⠀⠘⢄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⠊⠀⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠈⠢⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡰⠁⠀⠀⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠑⢄⠀⠀⠀⠀⠀⠀⠀⠀⡠⠊⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠑⢤⣀⣀⢀⣀⠤⠊⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
nil
Changes in the code so far:
-
Janet uses a
default
macro to establish a default value, in case one wasn’t specified. Here it works with the&named
attribute in the argument list.In Clojure, we use rest syntax
&
together with the map destructuring syntax{}
to achieve the same style of argument list. And to supply default values for named arguments, map destructuring supports:or
modifier that itself specifies a map of names to default values.Hence the change from
&named axis
with(default axis true)
to{:keys [axis] :or {axis true}}
. -
for
is a list comprehension in Clojure, so it was changed todotimes
which has the closest semantic. -
Comments were changed to use
;;
instead of#
-
All inline
def
usage was replaced withlet
binding multiple symbols.This introduced more nesting, but it’s not a problem.
-
var
usage was replaced with the use ofatom
.To be clear, I should use
volatile!
here, but this is a temporary change, and more people are familiar withatom
. This change required changingset
to usereset!
as a means to update the atom. -
I also had to replace the
new
function name withcanvas
becausenew
is a special form in Clojure.
So even this small piece of code required a fair amount of changes.
The most annoying one was converting def
usage to an appropriate let
usage, as it involved a lot of manual copying.
By the way, the original Janet code changes a lot of values in place.
This happens all over the code, and because of that, I had to use atom
in a few more places.
However, most of these mutations are not necessary, and we can use things like Clojure’s update
, that instead of modifying a collection, return a new one with the modifications.
So the second change to this code was to make the canvas
immutable,
Now all of the functions that change the canvas accept it, and return a new, modified canvas:
(defn plot
([f] (plot f (canvas)))
([f {:keys [x-scale y-scale]
:as canvas}]
(let [[w h] (bounds canvas)
y-scale (* y-scale (/ (+ h 1) h))]
(loop [[x & xs] (range w)
current-point nil
canvas canvas]
(if x
(let [x (- (/ x (- w 1)) 0.5)
y (/ (f (* x 2 x-scale)) y-scale -2)
p [(* (+ x 0.5) (- w 1))
(* (+ y 0.5) h)]]
(recur xs p
(cond-> canvas
current-point (draw-line current-point p))))
canvas)))))
I’ve changed plot
to accept canvas
as the last argument instead of passing all of its options and creating it if it wasn’t passed.
So now we can just call (plot math/sin)
and get a sensible result.
This is another way to provide default arguments in Clojure - we create two bodies for the function, and if the function is called with less than the needed amount of arguments, it has a dedicated body that can call itself with additional arguments.
Neat!
I’ve moved out the axis creation code into a separate function draw-axis
because it makes more sense to create a blank canvas and draw axies on it afterward.
Since we’re passing the canvas object through, gradually creating newer versions of it with each function, we can have it as a separate step.
The final change here is the use of the loop
instead of the dotimes
.
This change is motivated by the fact that we need to change some state between iterations, but we don’t want to use mutable constructs such as atom
.
So instead, we create a binding current-point
and change it with each call to recur
.
And the same thing happens to the canvas
binding.
This is literally a recursion, except Clojure has to do it this way because JVM can’t optimize tail calls.
And, it works!
We can now pass the canvas through multiple plot
calls:
braille-plot.core> (->> (canvas :width 42 :height 20)
(plot #(math/sqrt (abs (- 1 (math/pow % 2)))))
(plot #(- (math/sqrt (abs (- 1 (math/pow % 2))))))
(plot #(/ (math/sqrt (abs (- 1 (math/pow % 2)))) 2))
(plot #(/ (- (math/sqrt (abs (- 1 (math/pow % 2))))) 2))
draw-axies
print-canvas)
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⣠⠤⠔⠒⠒⠒⠊⡗⠒⠒⠒⠢⠤⢄⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⠴⠒⠉⠁⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀⠀⠉⠒⠤⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⢀⠴⠊⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠑⠤⡀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⢀⠔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠢⡀⠀⠀⠀⠀
⠀⠀⠀⡠⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⢄⠀⠀⠀
⠀⠀⡔⠁⠀⠀⠀⠀⠀⢀⣀⣠⠤⠔⠒⠒⠒⠊⠉⠉⠉⡏⠉⠉⠑⠒⠒⠒⠢⠤⢄⣀⡀⠀⠀⠀⠀⠀⠈⢢⠀⠀
⠀⡰⠁⠀⠀⣀⠤⠒⠉⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠉⠒⠤⣀⠀⠀⠈⢆⠀
⢠⠃⢀⠔⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠢⡀⠘⡄
⢸⠔⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠢⡇
⡿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢿
⣏⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⡏⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⣹
⢹⢆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡰⡏
⠸⡀⠑⢄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡠⠊⢀⠇
⠀⢣⠀⠀⠉⠒⠤⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⠤⠒⠉⠀⠀⡜⠀
⠀⠈⢢⠀⠀⠀⠀⠀⠉⠑⠒⠲⠤⢄⣀⣀⣀⡀⠀⠀⠀⡇⠀⠀⢀⣀⣀⣀⡠⠤⠔⠒⠊⠉⠀⠀⠀⠀⠀⡔⠁⠀
⠀⠀⠀⠱⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠉⠉⠉⡏⠉⠉⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠎⠀⠀⠀
⠀⠀⠀⠀⠘⢄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡠⠃⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠑⢤⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠤⠊⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠈⠒⢤⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀⠀⠀⣀⠤⠒⠁⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠓⠲⠤⢄⣀⣀⣀⡀⣇⣀⣀⣀⡠⠤⠔⠒⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
nil
The rest of the code was converted similarly.
Uses of def
were converted to let
, and in-place mutations were replaced with the use of immutable data structures API.
ClojureScript came with its oddities.
Clojure 1.11.0 introduced the clojure.math
namespace that allows more portable code between implementations.
However, the clojure.string
namespace is still quite sparse.
For instance, here’s the function that generates the Braille symbols:
(defn- braille [byte]
(let [bytes (encode (+ 0x2800 byte))]
#?(:clj
(String. (byte-array bytes))
:cljs
;; TODO: maybe there's a better way?
(let [bytes (new js/Uint8Array (clj->js bytes))]
(.decode (new js/TextDecoder "utf-8") (clj->js bytes))))))
It is pretty straightforward for Clojure, but in ClojureScript we have some shenanigans. I program in ClojureScript quite infrequently, so this code is, perhaps, completely terrible, so reach me out if you know a better way of doing this.
Closing thoughts
Well, Janet still feels weird as a language.
I mean, it feels like its syntax, with regards to special characters, went through the Caesar cipher: #
for comments, ;
for splicing, @
for table mutability, ~
for quasiquote `
for long strings, |
for short function notation.
Its use of def
is, well, unconventional, and code style, in general, is not lispy enough.
I mean, Fennel isn’t a proper Lisp either, yet thanks to the runtime, it feels pretty natural, and its syntax doesn’t deviate too far from established conventions.
It feels more mature than Fennel, but quirky at the same time.
In the future, though, I’d like to take a closer look at Janet’s virtual machine. Perhaps it can be used as a target for a language that I will personally like more.