Emacs Lispでネストしたデータ構造へのアクセスを簡単に
JavaScriptのような言語では、以下のような構文でネストしたオブジェクトを作成し、変更、読み取りができます。
// 作成
const obj = {foo: {bar: {buz: 0}}};
// 変更
obj.foo.bar.buz = 42;
obj.foo.bar.buz++;
// 読み取り
console.log(obj.foo.bar.buz);
これらの構文は、作成のみ記法が違いますが、変更と読み取りはよく似た対称的な構文です。オブジェクトではないローカル変数を直接代入・読み取りするのと大きな違いはありません。
let foo = 42;
foo++;
console.log(foo);
……ところで、Emacs Lispではどうでしょうか。
(そもそも連想配列的なデータ構造にもいろいろあるのですが、ここではプロパティリストと呼ばれる記法を例とします)
;; 作成
(setq plist '(:foo (:bar (:buz 0))))
;; 読み取り
(plist-get (plist-get (plist-get plist :foo) :bar) :buz)
;; スレッディングマクロを使うと読みやすく… なってねえな
(require 'subr-x)
(thread-first plist
(plist-get :foo)
(plist-get :bar)
(plist-get :buz))
(require 'dash)
(-some-> plist
(plist-get :foo)
(plist-get :bar)
(plist-get :buz))
更新は… えー、ここには書きたくないほどめんどくさいです。
読み取りについても、ここではプロパティリスト(plist)という記法を使っているのでplist-get
関数を使っていますが、単方向リストを利用した連想配列のようなものとして、連想リスト(association lists/alist)という記法もあり、別の関数を使い分けなければいけません。
このめんどくささはEmacs Lispに限らず、Lisp族の明確な弱点だといえるのではないでしょうか。
多くの言語の連想配列や辞書と呼ばれるオブジェクトと同じようにEmacs Lispにもハッシュテーブルがあります。あるのですが、シンプルなリストで構築できるplistやalistと比べるとハッシュテーブルは作成のための便利な構文や関数もなく、内容の目視確認もめんどくさく、どうもEmacs Lispにとっては二級市民といった存在感です。
コンピュータサイエンス的にはハッシュテーブルが読み取り効率がよさそうに感じますが、現実にはたかだか数十要素にも満たないデータサイズを扱うことが多いので、プロパティリストや連想リストで扱って読み取りに線形スキャンが必要になったとしても、現代のコンピュータでは大した計算量にはならないでしょう。
……とはいえ、データ構造によって読み取りのために使う関数を使い分けるのも若干めんどくさいです。歴史的経緯により(alist-get key alist)
vs (plist-get plist property)
vs (gethash key table)
と、引数の順番に一貫性がないところに厳しみがあります。
digsマクロを作ろう
……と、ここまでのような内容について、Vim-jpコミュニティでこまもか(@comamoca)氏が困ってるのを見掛けました。
ここで私が思い浮かんだのは「RubyのHash#dig
がLispにも欲しいな」ということです。
Rubyも構文的にはhash[:foo][:bar][:baz]
のようにも書けますが、hash.dig(:foo, :bar, :baz)
と書くことで途中で掘り下げたときにnil
に当たってもエラーにならないようになっています。
Emacs Lisp的に書くとこうなります。
(digs plist :foo :bar :buz)
こまもか氏の提案とは並びが違いますが、可変個のパラメータは最後にした方が都合がいいのでこのようになっています。
ところで、ここまで挙げたプロパティリストは基本的にどんな値でもキーにできるのですが、Emacs Lispでは慣習的に、プロパティリストのキーは:keyword
形式、ハッシュテーブルはシンボルか文字列、連想リストのキーもシンボルか文字列が多いかなと思います。
特にEmacsではJSONのオブジェクトをデコードする時にどのデータ構造をオプションで変えることができ、ハッシュテーブルのキーは文字列、連想リストのキーはシンボルといったように型が定まります。
つまり、JSONからデコードされたオブジェクトであれば、キーの型によってどのデータ構造にアクセスしたいのかが一意に決まります。
(digs alist 'foo 'bar 'buz)
(digs plist :foo :bar :buz)
(digs hash "foo" "bar" "buz")
(digs list 0 1) ;; 数値アクセスされるのは配列/リスト
これらの実装は、alist
/plist
/hash
のような変数を実行時に判定するのではなく、キーによって静的に展開できる。つまりマクロにできるということです。ある処理を関数で定義するべきか── 一般には、関数で済むなら関数で解決すべきですが、ここではあえてマクロでやってみましょう。
マクロでは最終的にプログラムとして評価するコードをS式(リスト)として返せれば何をやってもいいのですが、ここでは関数型っぽく気取って、cl-reduce
を使ってdigs--expand-dwim
を再帰的適用しましょう。
そうやって実装するdigs
マクロの実装はこれだけです。
(defmacro digs (subject first-key &rest keys)
(cl-reduce #'digs--expand-dwim (cons first-key keys) :initial-value subject))
(defun digs--expand-dwim (subject key)
(cond
((stringp key) (digs--expand-hash subject key))
((integerp key) (digs--expand-elt subject key))
((keywordp key) (digs--expand-plist subject key))
((digs--expand-alist subject key))))
digs--expand-dwim
は関数で、キーの型ごとに別の関数に処理を横流しします。
(defun digs--expand-alist (subject key)
(let ((test (if (symbolp key) #'eq #'equal)))
`(alist-get ,key ,subject nil nil (function ,test))))
(defun digs--expand-plist (subject key)
(let* ((test (if (symbolp key) #'eq #'equal)))
`(plist-get ,subject ,key (function ,test))))
(defun digs--expand-hash (subject key)
`(when ,subject (gethash ,key ,subject)))
(defun digs--expand-elt (subject key)
`(elt ,subject ,key))
ここでは要素を取り出す関数呼び出しを表わすS式(リスト)を組み立てます。
これによって、subject
を芯にして玉ねぎの皮のように被さっていきます。
(digs plist :foo :bar :buz)
;; (cl-reduce #'digs--expand-dwim (cons first-key keys) :initial-value subject)
;;
;; (digs--expand-plist plist :foo)
;; -> '(plist-get plist :foo #'eq)
;; (digs--expand-plist 前の結果 :bar)
;; -> '(plist-get (plist-get plist :foo #'eq) :bar #'eq)
;; (digs--expand-plist 前の結果 :buz)
;; -> '(plist-get (plist-get (plist-get plist :foo #'eq) :bar #'eq) :buz #'eq)
わかりましたでしょうか…! これはマクロなので、コード上に (digs plist :foo :bar :buz)
と書いても、バイトコンパイルすると (plist-get (plist-get (plist-get subject :foo #'eq) :bar #'eq) :buz #'eq)
という展開されたコードが実行されるということです。
ここまでの話だと実用的な意味がないものを関数ではなくマクロで実装したということになるのですが、このコードには意味があります。次に進みましょう。
汎変数(generalized variables)
この記事の冒頭では、Lispの弱点は「読み取りと変更に対称性がない」ということだと言いました。その弱点を克服できるのが汎変数(generalized variables)という仕組みです。
これを使うと、setq
のようにsetf
に読み取り式を書くと、書き込み式に自動変換してくれるというハイテクな仕組みです。
(plist-get p :foo)
(setf (plist-get p :foo) 42)
この仕組みはとても賢いので、読み取りが入れ子に書かれていたとしても再帰的に展開してくれるのです。
(macroexpand-all '(setf (plist-get p :foo) 42))
;; => (let* ((p (cdr (plist-member p :foo nil))))
;; (if p (setcar p 42) (setq p (cons :foo (cons 42 p)))))
(macroexpand-all '(setf (plist-get (plist-get p :foo) :bar) 42))
;; => (let*
;; ((p (cdr (plist-member p :foo nil)))
;; (p (cdr (plist-member (car p) :bar nil))))
;; (if p (setcar p 42)
;; (if p (setcar p (cons :bar (cons 42 (car p))))
;; (setq p (cons :foo (cons (cons :bar (cons 42 (car p))) p))))))
詳細は実際にこれを実装をした@conao3の記事を読むとよいでしょう。
Elispでplist-getをsetfに対応させる方法 | Conao3 Note
やるべきことはこれだけです。
(gv-define-setter digs (store seq &rest keys)
(let* ((getter (cl-reduce #'digs--expand-dwim keys :initial-value seq)))
(macroexpand-all `(setf ,getter ,store))))
以上、終了!
えー、最初に述べた通り、(cl-reduce #'digs--expand-dwim keys :initial-value seq)
とか書くと '(plist-get (plist-get (plist-get plist :foo #'eq) :bar #'eq) :buz #'eq)
みたいなS式がとれるので、これを '(setf (plist-get (plist-get (plist-get plist :foo #'eq) :bar #'eq) :buz #'eq) 42)
みたいなリストに展開した上で macroexpand-all
に渡してマクロ展開して展開されたS式をそのまま返せばいいのです。
めんどくさい処理は既に実装されているので、シンプルな式に展開するだけであとはsetf
が勝手に処理してくれるのです。ブラボー。巨人の肩に乗ればこれだけ短いコードで済むのですね。
これを追加してやると、めでたくこういうコードが書けます。
(setq plist '(:foo (:bar (:buz 0))))
(setf (digs plist :foo :bar :buz) 42) ;; obj.foo.bar.buz = 42
(cl-incf (digs plist :foo :bar :buz)) ;; obj.foo.bar.buz++
(message "%S" (digs plist :foo :bar :buz)) ;; console.log(obj.foo.bar.buz)
さすがにJavaScriptと比較しちゃうと冗長ですが、読み取りと変更が同じ形になって、いままでと比べればかなり簡潔なコードになったのではないでしょうか。
これがLispの持つ「コードとデータが同じ形をしている」すなわち同図像性という性質です。嬉しいですね。
さきほど「実用的な意味がないものを関数ではなくマクロで実装した」のではないことを口走りましたが、実際には「データを動的処理するのではなくキーによって読み取り方法を静的に決定する」という方針に寄せて一貫した処理をしています。
まとめ
この実装で以下の課題を解決できるようになりました🎉
- 複数の読み取り関数の使い分け →
digs
に一本化 - ネストしたデータ構造の煩雑なキーアクセス →
digs
で複数のkeys
を再帰展開 - 読み取りと変更処理の非対称性 →
gv-define-setter
でsetf
サポート
ということで、コードはここにあります。
なんか怪しいところを見付けたら教えてくださいね。
let-alist
別解 僕がうるさいことを言う前にこまもか氏は自分でこれにたどりついていました。
Emacsに標準で入っているのでいつでも使えるのがメリットです。データ構造は連想リストしかサポートしていませんが、先述の通りJSONデコード時に最初から:object-type 'alist
を指定してしまえばいいので問題ではありません。
一方でこのアプローチでは読み取りは簡潔にできますが、変更処理に関してはサポートしていません。あと、なんとなく好みでないというか、若干の音楽性の違いみたいなのはあります。
蛇足
今回は自重しましたが、 (digs* plist[foo][bar][buz])
のような「Lispっぽくない配列アクセス」も実装すれば実現できます。
(setq plist '(:foo (:bar (:buz 0))))
(setf (digs* plist[foo][bar][buz]) 42)
(cl-incf (digs* plist[foo][bar][buz]))
(message "%S" (digs* plist[foo][bar][buz])
興味がある人は実装してみてくださいね。 (Pull Requestが送られてもマージ予定はありません)
Discussion