Although I’d seen Janet before, we finally got to know each other a few days ago and I’m heads over heels in love! Though 2x slower as a a CLI than Common Lisp, 1 MB vs. 60 MB executables and 1/3-1/4 the LoC are hard to argue with.
Closures are a bit different
# The expected form doesn't work:
(defn timer [t] (fn [] (set! t (+ t 1))))
# In Janet, params are constants (as if made by def). So you must shadow:
(defn timer [t] (var t t) (fn [] (set t (+ t 1))))
When you’re not sure what a function is named
I wanted to make nested maps from an array of strings. Perusing Janet’s formal and informal documentation, I failed to find something like Clojure’s assoc-in. I tried:
# time janet: .679s .649s .029s
# time jpm build: 15.779s 14.920s .533s
(map |(merge-into
verses
@{(get (string/split "\t" $) 1) (table)})
v)
(map |(merge-into
(verses (get (string/split "\t" $) 1))
@{(get (string/split "\t" $) 2) (table)})
v)
(map (fn (x) (put
((verses (get (string/split "\t" x) 1)) (get (string/split "\t" x) 2))
(get (string/split "\t" x) 3)
(get (string/split "\t" x) 4)))
v)
N.b. janet executes the program as a script while jpm build compiles it. We are only comparing marshalling times, as the final data structure is the same.
I made an ugly macro expanding to this. I then thought to implement Clojure’s assoc-in more directly:
# time janet: .411s .38s .031s
# time jpm build: 14.392s 113.781s.469s
(defn assoc-in [m ks v]
``Insert (and make) deeply nested tables, based on Clojure's. Use like: (var t (table)) (assoc-in t [:a :b :c :d] "e")``
(if (empty? ks)
v
(let [k (first ks)
nested (or (get m k) @{})]
(put m k (assoc-in nested (slice ks 1) v)))))
After this fun exploration, Caleb wrote Janet calls it put-in, with an unexpectedly imperative implementation here. A perfect drop-down replacement for my assoc-in, it exhibits the same (honestly irrelevant) performance characteristics.
# time janet: 0.383s 0.354s 0.29s
# time jpm build: 14.753s 15.064s .0591s
(var verses (table))
(var v (string/split "\n" (slurp "kjv.tsv")))
(defn insert-verse (abbrev ch verse quote) (put-in verses [abbrev ch verse] quote))
(map
(fn (x)
(let
[line-as-array (string/split "\t" x)
abbrev (get line-as-array 1)
ch (get line-as-array 2)
verse (get line-as-array 3)
quote (get line-as-array 4)]
(insert-verse abbrev ch verse quote)))
v)
Are Immutable DS faster to access?
No, but I still wanted to investigate. I added these type casts, which did not noticeably impact marshal times:
(def verses (table/to-struct verses))
# ...
(var abbrev-array (tuple ;(keys abbrev-set)))
For actual execution, hyperfine "./test-janet gen 3:4" showed no appreciable difference:
Mutable: 48.1 ms ± 4.8 ms [User: 38.1 ms, System: 9.8 ms]
Immutable: 47.0 ms ± 3.5 ms [User: 37.4 ms, System: 9.5 ms]
For good measure, as a script, hyperfine "janet verse-reader.janet gen 3:4" gives:
Just slow: 246.3 ms ± 63.7 ms [User: 233.8 ms, System: 12.2 ms]
Image Based Development
Making an image is easy!
### in a repl
(def my-module @{:public true})
# (var voice [:unvoiced :voiced]) # do stuff here...
(spit "test.jimage" (make-image (curenv)))
janet -i test.jimage will run it. But how do you resume it?
(defn restore-image [image]
(loop [[k v] :pairs image]
(put (curenv) k v)))
(restore-image (load-image (slurp "test.jimage")))
Discovering this was somewhat painful but the community is very helpful!
This means there’s still work to create an image-driven development flow (and determine whether it’s better than sending source code to the REPL.)
Closures even stay synced across loads!
(defn timer [t]
(var t t)
[(fn [] (set t (+ t 1)))
(fn [] (set t (+ t 2)))])
(def tx (timer 0))
# call like this:
((tx 0))
((tx 1))
After saving the image as above, you can exit and start a new REPL, load the image and they will stay synced!
me:~$ janet
Janet 1.38.0-local linux/x64/gcc - '(doc)' for help
repl:1:> (defn timer [t]
repl:2:(> (var t t)
repl:3:(> [(fn [] (set t (+ t 1)))
repl:4:([> (fn [] (set t (+ t 2)))])
<function timer>
repl:5:> (def tx (timer 0))
(<function 0x5E0A7C67FBF0> <function 0x5E0A7C67FC50>)
repl:6:> ((tx 0))
1
repl:7:> ((tx 1))
3
repl:8:> (def my-module @{:public true})
@{:public true}
repl:9:> (var voice [:unvoiced :voiced])
(:unvoiced :voiced)
repl:10:> (spit "test.jimage" (make-image (curenv)))
nil
repl:11:>
me:~$ janet
Janet 1.38.0-local linux/x64/gcc - '(doc)' for help
repl:1:> (defn restore-image [image]
repl:2:(> (loop [[k v] :pairs image]
repl:3:((> (put (curenv) k v)))
<function restore-image>
repl:4:>
repl:5:> (restore-image (load-image (slurp "test.jimage")))
nil
repl:6:> ((tx 0))
4
repl:7:> ((tx 0))
5
repl:8:> ((tx 1))
7
repl:9:> ((tx 1))
9
repl:10:>
How does it do this? I don’t know! Looking at the image:
@{_ @{:value @{:public true}} my-module @{:source-map ("repl" 10 1) :value @{:public true}} timer @{:doc "(timer t)\n\n" :source-map ("repl" 1 1) :value <function timer>} tx @{:source-map ("repl" 6 1) :value (<function 0x63B32169BF30> <function 0x63B32169C110>)}}
and current environment with (curenv):
@{_ @{:value <cycle 0>} my-module @{:source-map ("repl" 10 1) :value @{:public true}} restore-image @{:doc "(restore-image image)\n\n" :source-map ("repl" 1 1) :value <function restore-image>} timer @{:doc "(timer t)\n\n" :source-map ("repl" 1 1) :value <function timer>} tx @{:source-map ("repl" 6 1) :value (<function 0x63B321651220> <function 0x63B321651410>)}}
I thought the “function hashes” were only sometimes changing. But in IRC, Wolfdog explained:
<function 0x............>are just the address the object points to (see src/core/pp.c, function janet_to_string_b)
The first number after "repl" is the line it was defined on. I don’t know the 2nd yet.
Limits of Image Based Development
If you start a repl with janet -d, (dyn 'symb) will show you symb’s metadata, including its :source-form. While it works for locally defined forms, (get (dyn '+) :source-form) is nil. Potentially, one could build Janet while preserving such debug information.
C code can’t be marshalled into .jimage files, leading to projects like jayson.