これは LibreOffice Advent Calendar 2020 の4日目の記事です。
(追記 2020-12-07 空いているようだったので Lisp Advent Calendar 2020 にも参加してみました)
- シンプルな Lisp インタプリタ mal (make a lisp) を LibreOffice Basic に移植しました。
- 実用についてはあまり考えていません。デモ・PoC っぽいつもりです。
- できあがったばかりなのでコードは汚いです。リファクタリングなどはこれから。
できたもの
- Optional 以外のテストはすべて通って、セルフホスト(LibreOffice Basic で書いた mal インタプリタ上で mal で書かれた mal インタプリタが動く)までできた段階。
- テストでカバーできていない部分はいろいろ漏れがあると思います。例外まわりとか。
- VBA互換機能は不使用
- Ruby のコードが含まれていますが、インタプリタの本体部分を動かす分には不要です。
- mal のテストフレームワークに乗せるために標準入出力に繋がった REPL が必要だったため、アダプタを Ruby で書きました。
- 非効率で遅いのと、Basic でできることの制約があるため実用についてはうーんどうでしょうねという感じ。
- まじめに実用したい場合は Java版 を使うのが良い気がします
- advent calendar に間に合わせるために急いで作り、できあがったばかりなのでコードはまだ汚いです(大事なので何度も言いたい)。LibreOffice Basic 自体にも不慣れなので……。リファクタリングなどはこれから。
mal について
mal について簡単に。
- kanaka/mal: mal - Make a Lisp
- Lisp インタプリタ実装学習者向け(?)のシンプルな実装
- たとえば Ruby 版だと全部合わせても 534行
- シンプルとはいえ末尾呼び出しの最適化(TCO)やユーザ定義マクロ、try/catch が利用可能
- 多数の言語に移植されている
- 2020-12-03 現在で 86 言語
- その中に mal 自身で書かれたセルフホスト版もある
- 実装を11ステップに分けたガイドがあり、
各ステップ毎のユニットテストが用意されている - Clojure を参考にしている
といったあたりがポイントでしょうか。
より詳しくは、たとえば下記の記事を参照してください。
- 72のプログラミング言語に移植された埋め込み可能なLISP言語の紹介 (複数言語を使った開発プロジェクトで同じコード/ロジックを再利用できます。JavaScript編) - Qiita
- Make a Lisp で Lisp 処理系を学んでつくる (with Crystal) - はやくプログラムになりたい
実行の例
ファイルを開いて実行
※ ファイル内の Basic マクロを実行する都合上セキュリティの設定をゆるめる必要があります。
ツールバーから ツール>オプション
>LibreOffice>セキュリティ>マクロセキュリティ
サンプルとして mal-demo.fods
というファイルを用意しました。 LibreOffice Calc でファイルを開いて [RUN]
ボタンを押すとライフゲームが動きます。押すたびに1ターン進みます。
私の環境だと1ターンに12〜13秒かかりました
(↓Firefox だとアニメgifが再生されない場合があるようです。ファイルをクリックして画像だけ表示すると動くものが見れます。)
処理に時間がかかりすぎていて途中で中断したいといった場合は
ツール>マクロ>マクロの編集
実行>停止(Shift+F5)
で止められます。
矩形のテキストを書き換えて [RUN]
ボタンを押すと任意の式を実行することができます。
以下 2点、この方法で実行する場合の留意点です。不便ですが、ちょっとしたお試し用ということで簡易な作りで済ませています。
- 単一の式のみ実行可
- 1回の実行が終わると環境がリセットされる(変数や関数を定義しても次に
[RUN]
ボタンを押して実行する時には消えている)
複数の式は実行できませんが、下記のように (do ... )
で囲むことで1つの式として実行できます。
(do
(def! a 1)
(def! b 2)
(println (+ a b))
)
以下、ためしに何かコピペして動かしてみたいという人のためのサンプルコードです。
;; 四則演算
(/ (- (+ 515 (* -87 311)) 296) 27)
;; 変数の定義と参照
(do
(def! x 123)
x
)
;; フィボナッチ関数の定義と実行
(do
(def! fib (fn* [N] (if (= N 0) 1 (if (= N 1) 1 (+ (fib (- N 1)) (fib (- N 2)))))))
(fib 6)
)
;; if
(if true 7 8)
;; read
(read-string "(+ 1 2)")
;; read + eval
(eval (read-string "(+ 1 2)"))
;; throw / try / catch
(try*
(throw "something wrong")
(catch* exc (prn "exc is:" exc))
)
;; 外部ファイルを読んで表示
(println (slurp "sample.mal"))
;; 外部ファイルをロードし、その中で定義されている関数を使う
(do
(load-file "example/mylib.mal")
(my-func)
)
もっといろんなコード例を見たいという方は mal のテストコード を参照してください。
ターミナルで REPL を実行
(2020-12-24 追記) Docker を利用し、ターミナルで REPL を実行できるようにしました。
参考: DockerでLibreOffice Basicマクロを実行する
まず docker
ブランチにスイッチしてイメージをビルドします。
git checkout docker
./tasks.sh docker-build
ビルドできたら下記のコマンドで REPL を動かせます。終了するときは Ctrl-C を押してください。
./tasks.sh docker-repl
LibreOffice 向けの組み込み関数
せっかく LibreOffice Basic で作ってますから LibreOffice の操作したいですよね、ということでいくつか適当な組み込み関数を用意してみました。
- cell-get / cell-set
- セルの内容の読み書き(Calc用)
- get-ci-max / get-ri-max
- シートの列・行の最大インデックスの取得(Calc用)
- msgbox / wait
- LibreOffice Basic の MsgBox, Wait に対応
- file-write
- execute-dispatch
これらを使ったサンプルです。
(let*
[
sheet-name "Sheet1"
numseq (fn* [from to] ; from <= x < to
(if (<= to from)
()
(cons from (numseq (+ from 1) to))))
join (fn* [delim xs]
(cond
(= (count xs) 0) ""
(= (count xs) 1) (str (first xs))
"else" (str (first xs) delim (join delim (rest xs)))))
ci-max (get-ci-max sheet-name)
ri-max (get-ri-max sheet-name)
ris (numseq 0 ri-max)
cis (numseq 0 ci-max)
pr-json (fn* [val]
(if (nil? val)
"null"
(pr-str val)))
row-to-line (fn* [ri]
(str "[ "
(join ", "
(map
(fn* [ci] (pr-json (cell-get sheet-name ci ri)))
cis))
" ]"))
text (join "\n" (map row-to-line ris))
]
(file-write "array_table.txt" (str text "\n"))
)
シート Sheet1 の内容を次のような JSON の配列っぽいフォーマットでファイルに出力します。
[ 123, null, null, null, null ]
[ null, null, null, "■", null ]
[ "A3", null, null, null, "■" ]
[ "A4", null, "■", "■", "■" ]
参考: LibreOffice Calc Basic fun!!!: データが入力されている最終行・最終列を求める
execute-dispatch のサンプル。「マクロの記録」で生成されたコードで目にするアレです。
(do
(execute-dispatch "GoToCell" "ToPoint" "B7")
(execute-dispatch "EnterString" "StringName" "FDSA")
)
実装面のメモ
外部エディタでの編集
Basic の IDE ではなく Emacs で外部のファイルを編集し、それらのファイルをテンプレートに埋め込んで fods ファイルを生成する形で作業を進めました。
ロガー
単にファイルに追記していくだけのもの。素朴だけどあると便利というかないとつらい。
基本的にはこのログを見て、ヘッドレス実行だと見れない・見にくい情報がある場合やステップ実行したい場合に Basic IDE のデバッガを併用するという感じでデバッグしてました。
例外
例外機構を持たない言語で書かれた mal 実装のうち、自分が読めそうなものということで C言語版をチラ見したり。
本来の処理が埋もれて見づらくなるのが嫌、ログ出力の追加など後から一括して変更できるようにしておきたい、といった理由により Ruby 側の前処理でコメントを置換することにしました。
Sub foo
例外が発生する可能性のある処理
' CHECK_ERROR
後続の処理
End Sub
↓ コメント行 ' CHECK_ERROR
を置換
Sub Foo
例外が発生する可能性のある処理
' ----------------
If mal_error_exists() Then
' デバッグ用にログを出したり
Exit Sub ' 後続の処理を実行せずに呼び出し元に戻る
End If
' ----------------
後続の処理
End Sub
GoTo をうまく使うと Basic っぽくてよかったかもしれません(?)。
関数のディスパッチ
-
LibreOffice Basic には高階関数がないけど、どうする?
-
名前(関数名)で振り分ける
- 関数名と処理本体の紐づけをいつどこでやるか、という問題
-
2種類の関数
-
fn*
で作った関数- env と処理本体を持つが名前を持たない
- 名前との紐付けは
def!
によって行われる
- 本体なしの関数オブジェクト
- env と名前を持つが処理本体を持たない
- core にある組み込みの関数はこっち
- こっちは実行時に名前を見て振り分け
-
その他細かいの
- TCO のループが回るときに env と ast が変化してしまうので、EVAL の引数だけ明示的に
ByVal
で値渡しする必要がある- 最初参照渡ししていてデバッグがちょっと大変だった
- env に id を持たせて、env と関数をダンプした時に表示
- デバッグ時にどの関数がどの env を参照しているのか分からないとつらい
- Ruby と LibreOffice Basic 間の通信
- とりあえずファイルをポーリングして、それっぽく動いたのでそのまま進めた(もっと良い方法があると思います)
- map/連想配列/辞書型 的なデータ構造
- ググると
CreateObject("Scripting.Dictionary")
を使う方法が出てくるが、これは Linux だと動かない - 仕方ないので配列を使って自前実装していたが、
com.sun.star.container.EnumerableMap
が使えると知り、これを使って書き換えたら(ライフゲーム全体の計測で)3倍くらい速くなった
- ググると
感想など
- 自分としては mal の実装自体が目的だったので満足
- 高階関数のない言語での実装、TCO、例外機構については具体的にどう実装すればよいか自分にとっては自明ではなかったため、勉強になりました。やってよかった。
- このくらいの実装量で十分立派な言語のインタプリタが作れてしまうので、あらためて Lisp はおもしろい言語だなーと思いました。
- mal はいいぞ
環境
- Ubuntu Linux 18.04
- LibreOffice 6.0.7.3
せっかくなので去年のその後の話
去年の advent calendar で LibreOffice Drawのodgファイルから図形の情報を抜き出して使う という記事を書きましたが、これを使ってその後下記のようなものを作りました。
リレー式論理回路シミュレータを自作して1bit CPUまで動かした
回路図を LibreOffice Draw で描いて、それをプログラムから読んで動かしています。
こっちはちゃんと実用になりました。よかったですね。
Advent Calendar 14日目にこういうのも書きました。ご興味あればこちらもどうぞ:
他に LibreOffice 関連で書いたもの