はじめに
Clojure(Lisp系JVM言語)でLinuxコマンドっぽい名前の候補を生成するツールを作った。
作ってみてどうだったか、作成を通しての所感とか書きます。
成果物
meme(mei mei:命名)というツールです。メメって読んでます。
OpenJDK10でしか動作確認していません。
以下のように使います。
java -jar meme-1.0.0-SNAPSHOT-standalone.jar "global regular expression print"
5:glreexpr
5:glrepr
5:glrexp
5:glrexpr
5:grexpr
6:glreep
6:glreepr
6:glreexp
6:glrep
6:greepr
6:greexp
6:greexpr
6:grepr
6:grexp
7:greep
7:grep
数値は大きい方が重みがついている(= よりLinuxコマンドっぽい)ということになります。
一応"global regular expression print"に対する命名候補の中ではgrepは一番高いみたいです。
memeの由来の "mei mei"を実行したら以下のようになります。
(meme自体はツールではなく自分で名付けた)
java -jar target/meme-1.0.4-SNAPSHOT-standalone.jar "mei mei"
6:mm
8:mem
8:meme
8:mme
一番高いみたいです。
ですが、 sed の場合は...
java -jar target/meme-1.0.4-SNAPSHOT-standalone.jar "stream editor"
7:sted
8:sed
8:ste
12:se
上から2番目ですね。
その他色々。
java -jar target/meme-1.0.4-SNAPSHOT-standalone.jar "matsuya gyumeshi" | tail -n 3 | paste - - -
7:mgy 8:mag 11:mg
java -jar target/meme-1.0.4-SNAPSHOT-standalone.jar "yoshinoya gyudon" | tail -n 3 | paste - - -
7:ygy 7:yogy 8:yog
java -jar target/meme-1.0.4-SNAPSHOT-standalone.jar "yes precure five" | tail -n 3 | paste - - -
7:ypf 7:ypfi 7:yprf
ううーん........。
重み付けのロジック
由来となる1つ以上の単語の先頭数文字(任意)を取り出して総当りで組み合わせる。
組み合わせで生成された単語に対して、それぞれ以下のような判定を加えることで重みを付与する。
- 常用単語(英語)
- 発音しやすい(母音を含む)
- コマンド名がある程度短い
なんだこのガバガバ重み付けは。
なぜ作ったか
僕は普段趣味でコンパクトなCLIツールを作っています。
- 平均値、中央値、パーセンタイル値を計算するツール
-
コンビネータの計算をするツール
作る時に使っているのはだいたいGo言語です。
CLIを作る時に一番最初に悩むのは、設計でも実装でも役に立つかどうかでもなくて、作るツールの名前です(少なくとも僕の中では)。
これが決まらないとGitHubのリポジトリがいつまでも作れないし、Goで書くのだとするとパッケージ名が決まらなくてファイルを分けづらいです。
個人的にはあんまり名前にこだわりはないのですが、かといって適当すぎる名前は避けたい...。
そんな時に、それっぽい名前候補をだしてくれて、ある程度その名前に重みを付けて順位付けされて確認できるようになったら、ツール作成時の初動を妨げずに済むのでは?と思って作りました。
まぁ、9割くらい自分用です。
なぜClojureを使ったか
理由は2つです。
- 強力と言われているシーケンス処理がどれほど強力なのか確かめたかった
- 一瞬だけ触った時に**「この言語書いてて楽しいな」**ってなったから
シーケンス処理
シーケンス処理用の関数が充実していて関数型言語でまず取り上げられるmap, filter, reduceをはじめ、Clojure(1.3-1.4)のリスト/ベクタ(シーケンス)操作関数一覧
などにもあるような多彩な関数を組み合わせて目的を素早く達成できる、という噂に惹かれたからです。
実際これらの関数は強力ですし、それ以外にも大量に存在する組み込み関数だけでだいたいのことができてしまうくらい充実で汎用性の高いものが揃っています。
今回僕がClojureでツールを作っていて一番感激したのは、「リストとリストの総当り組み合わせをする」という処理の実装の部分を非常に簡潔に記述できたことです。以下がそのコードです。
(reduce #(for [x %1 y %2] (str x y)) [["a" "b"]["x" "y"]])
;; ("ax" "ay" "bx" "by")
(reduce #(for [x %1 y %2] (str x y)) [["a" "b"]["c" "d"]["x" "y"]])
;; ("acx" "acy" "adx" "ady" "bcx" "bcy" "bdx" "bdy")
書いてて楽しい
これが一番の理由です。前述のとおり普段はGoで空き時間に楽しくツールを書いているのですが、他の言語の刺激を欲しくなってきて、Go(というよりC言語派生の言語)から全然別ベクトルの言語を書いてみたかったんです。
実際のコードで、以下のような処理を書けたときは脳汁が止まらなかった。
(defn weight-words
"コマンド名候補リストに重みを付けてマップとして返す"
[round-words common-words]
(->> round-words
(filter #(< 1 (count %))) ; 1文字より多い単語だけにフィルタ
(map #(weight/weight % common-words round-words)) ; 重みをつけたハッシュマップに変換
; weight/weightは自作の関数
(sort-by :name) ; ハッシュマップのnameキーでソート
(sort-by :weight))) ; ハッシュマップのweightキーでソート
作ってみてどうだったか
JVM起動が遅すぎてCLIツール作成に向かない
はい、これは作る前からわかっていました。
常駐アプリとかでしたらまだ良いのでしょうけれど、都度ターミナルからコマンド起動する用途のCLIには
実行可能jarはJVM起動が遅すぎて使えないと思います。これはClojureで書いてもJavaで書いても多分そんなに変わらない。
以下に "matsuya gyumeshi" の1000回の処理時間を集計してみました。
seq 1000 \
| while read -r i; do
time java -jar target/meme-1.0.4-SNAPSHOT-standalone.jar "matsuya gyumeshi" >/dev/null
done 2>&1 \
| tee matsuya.log
# arthとtransは自前のツール
# arthで集計
# transで行列入れ替え
cat matsuya.log | grep user | cut -f 2 | cut -d m -f 2 | tr -d s | arth -H | trans
※単位はミリ秒
genre | value |
---|---|
count | 1000 |
min | 2.259 |
max | 4.081 |
sum | 2712.215 |
avg | 2.712215 |
median | 2.677 |
95percentile | 3.168 |
最小でも2秒、最大で4秒かかっている。
実際のところJVM起動後の処理でどれだけ時間がかかっているのか?
meme.core=> (def x "matsuya gyumeshi")
# 'meme.core/x
meme.core=> (-main x)
7:magy
7:mgy
8:mag
11:mg
nil
meme.core=> (time (-main x))
7:magy
7:mgy
8:mag
11:mg
"Elapsed time: 116.077167 msecs"
nil
main関数の処理時間は100ミリ秒、なので1,900ミリ秒以上はJVMの起動に時間がかかっている、ということに...。
これはホントに遅すぎました。
シェルと連携する系のCLIツールをJVM言語で書くのは適当ではないですね...。
プロンプトを表示して対話的に操作するようなタイプのCLIならClojureで書いても問題なさそうだとは思いますが
CLI作成が趣味なだけにホントに残念です。
Exceptionが追跡しにくい
JVM言語あるあるな気がしますが、大量のExceptionを吐くのでエラー発生行を特定しにくかったです...。
関数の途中の値を確認するにはどうやればよいのだろう
関数自体のテストとかならテストコードを書くなりlein repl
して関数名を指定するなりすれば確認できますが、
関数内の途中の値を確認する方法がわからない。
途中にprintlnすると以降の関数にnilが渡ってしまう。
回避するためには副作用の発生を可能にするdo関数でラップする必要があるが、毎回書くのはかなりだるい。
Goにはdelveといったデバッガがあってそれで途中の値を追跡するのをよくやりますが
EmacsのプラグインだとREPLで途中まで選択して計算させる、とかいった芸当が可能なんだろうか...。
やっぱり楽しい
Clojureで実装するの、やっぱり楽しいです。
短いコード量でバシッと目的の処理が実装できることの快感。
Goで泥臭くforで値を取り出したりするのに慣れていた身には全然別の世界が広がっていました。
総コード量は以下の通りでした。
find src -name \*.clj | xargs wc -l
38 src/meme/word.clj
49 src/meme/weight.clj
71 src/meme/core.clj
158 合計
find src test -name \*.clj | xargs wc -l
38 src/meme/word.clj
49 src/meme/weight.clj
71 src/meme/core.clj
36 test/meme/word_test.clj
30 test/meme/core_test.clj
58 test/meme/weight_test.clj
282 合計
総コード量はテストコード込で300行弱、結構少ない気がします。
Goで実装し直してみたときにどうなるか気になります。
僕の普段の趣味を実現するには残念ながらClojureはマッチしませんでしたが
それ以外の用途でしたら使ってみたいと思いました。もっと流行って欲しい。
全然別の言語ですが同じJVM言語のKotlinでネイティブコードを生成するという記事がありましたがClojureでも同じようにネイティブコード生成できるようになったらなぁ...。
今後
1単語のみの場合にどう候補をだすか
由来としたい単語が1つだけのときに、どういった文字を候補とするかについてが未実装です。
当初は由来とする単語内に含まれる常用単語も候補として追加するといった実装を含めていました。
(catの由来はcon cat enateだったりしたので)
しかしそれを取り入れた時に2つ問題が発生しました。
- 候補になる名前の数が非常に多くなった
- 2文字、3文字くらいの同じ単語が候補の重み上位に頻出するようになった
解決策についてまだ検討もついていません。
今後の課題です。
まとめの前に
普段僕がGoばかり使ってるせいでGoを引き合いにだすことが多くなってしまいましたが、Goも大好きです。
早い処理速度とクロスコンパイルで環境依存を極力減らしたバイナリが一個作れて並列処理も簡単に書けるのには毎日感謝しています。
誰かに使ってもらうためのツールを作るにあたって、「相手の環境を汚さないこと」「どこでも動くこと」の2つは非常に重要で、Goはその両方を満たしています。
その点ではやはりこれからもGoでCLIを作り続けるだろうなぁと思います。
(RustやNimも気になっているので、そちらもそのうち使ってみたい)
まとめ
- ClojureでのCLI作成を通しての所感を述べた
- 残念ながらCLIツールをClojureで書くのはパフォーマンス的に現実的でないことがわかった
- ただし対話式のものや、GUIアプリなど起動してからの操作時間が長いものに関してはその限りではない
- Clojureは楽しい
Clojureもっと流行ってほしい!