Zenn
🪖

Emacs Lispでネストしたデータ構造へのアクセスを簡単に

2025/02/18に公開
1

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にも欲しいな」ということです。

https://docs.ruby-lang.org/ja/latest/method/Hash/i/dig.html

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)という仕組みです。

https://qiita.com/kawabata@github/items/9a1a1e211c57a56578d8

これを使うと、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

https://conao3.com/blog/2020-3b6b-d1d0/

やるべきことはこれだけです。

(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-settersetfサポート

ということで、コードはここにあります。

https://github.com/zonuexe/digs.el

なんか怪しいところを見付けたら教えてくださいね。

別解 let-alist

僕がうるさいことを言う前にこまもか氏は自分でこれにたどりついていました。

https://qiita.com/kosh04/items/0df6edbbd6ac4efa1b8b

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が送られてもマージ予定はありません)

1

Discussion

ログインするとコメントできます