- plist(プロパティリスト)は、キーと値を交互に並べたフラットなリストで、
list関数かクォートで作れる。 - 構造体を定義するほどでもない複数の値をまとめるのに手軽で、値の読み取りは
getf、書き込みはsetf getf、削除はremfで行う。 - キーの読み取りは
eqで比較されるため、キーワードシンボルを使う。 - Common Lispのシンボルには、メタデータを付与できる symbol-plist がある。
- plistは、キーワード引数リストとも同じ形をしており、
applyでそのまま関数引数として渡せる。
| 用途 | 関数・マクロ |
|---|---|
| 読み取り | getf |
| 書き込み・更新 | setf getf、incf |
| 削除 | remf |
| 複数キー検索 | get-properties |
| 走査 | loop for (key value) on plist |
| シンボルplist読み取り | get、symbol-plist |
| シンボルplist書き込み | setf get |
| シンボルplist削除 | remprop |
1. plistとは何か
plistは、キーと値を交互に並べたフラットなリストです。
'(:name "alice" :score 85 :level 3)Code language: Lisp (lisp)
キーには通常キーワードシンボルを使い、list で作るか、値が定数なら ' でクォートして作ります。
plistが便利なのは、リストが「キーワードと値の順で並べる」というルールに従っていれば、便利な関数が使えるからです。
; 動的に値を含める場合は list を使う
(defun make-player (name score)
(list :name name :score score :active t))
(make-player "alice" 85)
;=> (:NAME "alice" :SCORE 85 :ACTIVE T)Code language: Lisp (lisp)
alistがコンスセルのリストであるのに対し、plistは単純なリストで、コンスセルの入れ子構造を持ちません。
(defun alist->plist (alist)
(loop for (key . value) in alist
collect key
collect value))
(alist->plist '((:name . "alice") (:score . 85) (:level . 3)))
;=> (:NAME "alice" :SCORE 85 :LEVEL 3)Code language: Lisp (lisp)
| 構造 | 検索 | 追加 | 非破壊更新 | キーの型 | 向いている場面 |
|---|---|---|---|---|---|
| plist | O(N) | O(1) | list* で模倣 | シンボルのみ | キーワード引数の受け渡し、小さな設定 |
| alist | O(N) | O(1) | acons で自然 | 任意 | 非破壊で履歴を保ちながら更新 |
| hash table | O(1) 平均 | O(1) 平均 | 不可(破壊的のみ) | 任意(:test 指定) | エントリが多い、検索・更新が頻繁 |
1.1. プロパティとシンボル(symbol-plist)
plistは、「プロパティリスト(属性リスト)」の意味です。
Lispでは、もともとシンボルを使ってオブジェクト(概念や実体)を表現していました。
シンボルの属性(property)を記録するデータ構造としてよく使われたのが、plistです。
Common Lisp のすべてのシンボルには、空のplistを持っていて、シンボルオブジェクト自体に組み込まれた記憶領域があります。symbol-plist はシンボルのplist全体をリストとして返します。
現代の処理系は、シンボルplistではなく専用のデータ構造で型情報や関数情報を管理しているため、多くの標準シンボルは空(NIL)ですが、loopやandなど一部のマクロに plist が登録されています。
(symbol-plist 'loop)
;=> (SB-DISASSEM::INSTRUCTIONS (#<SB-DISASSEM:INSTRUCTION LOOP(SHORT-JUMP) {12001ECC83}>))Code language: Lisp (lisp)
手元のSBCLでは、plistが登録されているシンボルは、8つだけでした。
;; 何か入っているシンボルを探す
(loop for sym being each present-symbol
in (find-package :common-lisp)
when (symbol-plist sym)
collect sym)
;;=> (BREAK SET POP AND OR LOOP PUSH NOT)Code language: Lisp (lisp)
2. plistを作って値を読み書きする
2.1. getf で値を読み取る
plistは、getf でキーに対応する値を得ることができます。
(defparameter *player*
'(:name "alice" :score 85 :level 3))
(defun player-name (player)
(getf player :name))
(player-name *player*)
;=> "alice"Code language: Lisp (lisp)
キーが存在しない場合は nil を返します。
2.2. plistのデフォルト値(getf)
デフォルト値は、getfを使うときに第3引数として渡します。
そうすると、キーが存在しないときには、nil の代わりにデフォルト値を返せます。
(getf *player* :rank)
;=> NIL
(getf *player* :rank 999)
;=> 999Code language: Lisp (lisp)
2.3. setf getf で値を書き込む
setf と getf を組み合わせると値を書き込みます。
キーが存在すれば上書きし、なければ先頭に追加します。
(defparameter *config* (list :debug nil :timeout 30))
; 上書き
(setf (getf *config* :timeout) 60)
*config*
;=> (:DEBUG NIL :TIMEOUT 60)
; 新規追加
(setf (getf *config* :retry) 3)
*config*
;=> (:RETRY 3 :DEBUG NIL :TIMEOUT 60)Code language: Lisp (lisp)
getf と incf を組み合わせてカウンタを書くこともできます。
(defparameter *stats* (list :hits 0 :misses 0))
(defun record-hit (stats)
(incf (getf stats :hits)))
(record-hit *stats*)
(record-hit *stats*)
*stats*
;=> (:HITS 2 :MISSES 0)Code language: Lisp (lisp)
2.4. plistを走査する(loop … on)
plistを走査するときには、loop で2要素ずつ取り出すのが定番です。
(defun print-plist (plist)
(loop for (key value) on plist by #'cddr
do (format t "~a: ~a~%" key value)))
(print-plist '(:name "alice" :score 85 :level 3))
; :NAME: alice
; :SCORE: 85
; :LEVEL: 3Code language: Lisp (lisp)
on 節はリストの残り部分から分配束縛で2要素を取り出し、by #'cddr で2要素ずつ進みます。
たとえば、キーと値を別々のリストにまとめたいときもこのパターンで書けます。
(defun plist-keys (plist)
(loop for (key) on plist by #'cddr
collect key))
(defun plist-values (plist)
(loop for (key value) on plist by #'cddr
collect value))Code language: Lisp (lisp)
3. 実践パターン
3.1. 複数の関連オブジェクトをまとめて返す
plistが便利なのは、関数が役割の異なる複数のオブジェクトを返したいときです。
多値だと呼び出し側が窮屈だし、構造体を定義するほどでもない、という場面に向いています。
たとえば、文字変換テーブルをエンコーダとデコーダのペアとして返す例です。
(defun make-rule-tables (str)
(loop for ch across str
for normal-code from (char-code #\a)
with encoder = (make-hash-table)
with decoder = (make-hash-table)
do (setf (gethash ch encoder) (code-char normal-code))
(setf (gethash (code-char normal-code) decoder) ch)
finally (return (list :encoder encoder :decoder decoder))))
(defparameter *rules* (make-rule-tables "cab"))
; 呼び出し側は getf でキーを指定して取り出す
(defparameter *enc* (getf *rules* :encoder))
(defparameter *dec* (getf *rules* :decoder))Code language: Lisp (lisp)
plistで返せば、呼び出し側は let* で、必要なものだけ取り出せます。
; plistで返した場合
(let* ((words (read-words))
(rules (make-rule-tables str))
(enc (getf rules :encoder))
(dec (getf rules :decoder))
(encoded (mapcar (lambda (s) (encode s enc)) words)))
(sort encoded #'string<=))Code language: Lisp (lisp)
これが多値で返すと、 multiple-value-bind が必要でネストが積み重なりがちです。
; 多値で返した場合
(let ((words (read-words)))
(multiple-value-bind (enc dec)
(make-rule-tables str)
(let ((encoded (mapcar (lambda (s) (encode s enc)) words)))
(sort encoded #'string<=))))Code language: Lisp (lisp)
3.2. キーワード引数のデフォルト値パターン
getf のデフォルト値引数を使うと、存在しないキーに対して設定値を返す関数を書けます。
(defparameter *defaults*
'(:timeout 30 :retry 3 :log-level :warn))
(defun get-config (key &optional (config '()))
(or (getf config key)
(getf *defaults* key)))
(get-config :timeout '(:log-level :debug))
;=> 30 ; config にないのでデフォルトが返る
(get-config :log-level '(:log-level :debug))
;=> :DEBUG ; config の値が優先されるCode language: Lisp (lisp)
4. 関数引数・局所変数に plist を渡すパターン
alistやハッシュテーブルにはない、plistの特徴としてキーワード引数リストは同じ形をしていることがあります。
Common Lisp のキーワード引数の関数呼び出しは、plistと同じ構造になっています。:key1 val1 :key2 val2 という並びが共通のため、apply でplistをそのまま &key を持つ関数に渡せます。
(defun connect (&key (host "localhost") (port 80) (ssl nil))
(format t "~a:~a ssl=~a~%" host port ssl))
(defparameter *opts*
'(:host "example.com" :port 443 :ssl))
(apply #'connect *opts*)
;; (connect :host "example.com" :port 443 :ssl) と同等
;; example.com:443 ssl=TCode language: Lisp (lisp)
パラメータ設定を plist で蓄積して、後でまとめて関数に渡すときに便利です。
これは、関数の &key 引数の受け渡しも内部的にはキーと値を交互に並べたリストとして処理されているからです。
コードとデータが同じ形で表現されるというLispの性質が、ここで実用的な形として現れています。
4.1. &allow-other-keysでキー引数を転送する
さらに、中継関数で &rest args &key ... &allow-other-keys を組み合わせると、必要なキーだけ取り出しつつ残りをそのまま転送できます。
; :host だけ確認して、残りはそのまま connect に流す
(defun connect-with-log (&rest args &key host &allow-other-keys)
(format t "connecting to ~a~%" host)
(apply #'connect args))
(apply #'connect-with-log *opts*)
; connecting to example.com
; example.com:443 ssl=TCode language: Lisp (lisp)
全引数を列挙し直さなくてよいため、引数が増えても connect-with-log の定義を変える必要がありません。
4.2. destructuring-bind &key でplistを分解束縛
複数の変数にplistから同時に束縛することもできます。
destructuring-bind に &key を使うと、キーに対応した変数にplistから値を束縛できます。
(destructuring-bind
(&key name score &allow-other-keys)
'(:level 3 :name "alice" :score 85)
(format t "~a さんのスコアは ~a です~%" name score))
;=> alice さんのスコアは 85 ですCode language: Lisp (lisp)
ここでは、name、score に、:nameキーの値、:scoreキーの値をまとめて束縛しています。
getf を複数回書く代わりに、受け取りたいキーを宣言的に並べられるのがメリットです。
ただし、未知のキーを含むplistを受け取るときは &allow-other-keys を付ける必要があります。
5. getf は 文字列はキーにできない
plistのキーでは、文字列は使えず、基本的にキーワードシンボル :key を使います。
これは、getf は、:test で比較関数を変えられず、常に eq でキーを比較するからです。
文字列などは同じ内容でも別オブジェクトになる場合があり、動作は未定義になるのです。
; 文字列キーは eq で比較できない
(defparameter *p2* (list "x" 10 "y" 20))
(getf *p2* "x")
;=> NIL ; 見つからないCode language: Lisp (lisp)
alist の assoc や ハッシュテーブルの make-hash-tableは、:test #'equal で文字列をキーにできますが、plistはできません。
5.1. getf は 最初に見つけたキーの値を返す
plistに同じキーが複数あるとき、getf は左から検索して最初に一致したキーだけを返します。
後ろのものは無視されます。
(getf '(:x 1 :x 2 :x 3) :x)
;=> 1 ; 最初の値だけ返るCode language: Lisp (lisp)
この性質は、「キーのシャドウイング」として、オブジェクト指向設計での「オーバライド(上書き)」のような使い方もできます。
5.2. list* によるシャドウイング
list* は、新しいキーと値を先頭に追加することができます。
すると、後ろの同名キーは無視されるので、元のplistを変更せずに値を上書きしたように見せかけられます。
(defparameter *defaults* '(:timeout 30 :retry 3 :debug nil))
(defparameter *custom*
(list* :timeout 60 *defaults*))
*custom*
;=> (:TIMEOUT 60 :TIMEOUT 30 :RETRY 3 :DEBUG NIL)
(getf *custom* :timeout) ;=> 60
(getf *defaults* :timeout) ;=> 30Code language: Lisp (lisp)
ただし、重複キーが増えていくため、大量の更新には向きません。
alistが acons で先頭追加して非破壊更新するのと同じ発想です。
5.3. getf が nil を返したとき
getf はキーが存在しない場合もキーの値が nil の場合も、どちらも nil を返します。
第2戻り値がないため、gethash のように存在確認できません。
(defparameter *flags* (list :debug nil :verbose t))
(getf *flags* :debug) ;=> NIL :debug は存在するが値が nil
(getf *flags* :missing) ;=> NIL :missing は存在しないCode language: Lisp (lisp)
5.4. get-properties でキーを探す
get-properties は、plistと探すキーのリストを受け取り、特に、keyがあるかどうかを探すのに使われます。
(defun key-exists-p (plist key)
(nth-value 0 (get-properties plist (list key))))
(key-exists-p *flags* :debug) ;=> :DEBUG
(key-exists-p *flags* :missing) ;=> NILCode language: Lisp (lisp)
最初に見つかったキー・値・その位置以降のリストを3つの多値で返します。
(get-properties [plist] ['(:key1 ...)])
;;=> key value restCode language: Lisp (lisp)
getf を複数回呼ぶより効率的で、複数キーのうちどれが存在するかを一度に調べられます。
; いずれかのキーが存在するか確認する
(defun any-key-p (plist keys)
(nth-value 0 (get-properties plist keys)))
(any-key-p *config* '(:ssl :tls)) ;=> NIL
(any-key-p *config* '(:debug :verbose)) ;=> :DEBUGCode language: Lisp (lisp)
(defparameter *config*
'(:host "localhost" :port 8080 :debug t))
(multiple-value-bind (key value tail)
(get-properties *config* '(:port :debug))
(format t "key=~a value=~a~%" key value))
; key=:PORT value=8080Code language: Lisp (lisp)
ひとつも見つからなければ3つとも nil を返します。
5.5. remf でキーと値を削除する
remf はキーとそれに対応する値をplistから取り除きます(remove)。
remf の返り値は、見つけて削除したかどうかの真偽値で、破壊的な操作で変数を直接書き換えます。
(defparameter *player* (list :name "alice" :score 85 :level 3))
(remf *player* :level)
;=> TCode language: Lisp (lisp)
6. シンボルのプロパティリスト
Common Lisp のすべてのシンボルは、作成時から空のplistを持っています。
これはリストとしてのplistとは別の仕組みで、シンボルオブジェクト自体に組み込まれた記憶領域です。
(symbol-plist 'loop)
;=> (SB-DISASSEM::INSTRUCTIONS (#<SB-DISASSEM:INSTRUCTION LOOP(SHORT-JUMP) {12001ECC83}>))Code language: Lisp (lisp)
Common Lisp のシンボルは、5つの属性(セル)を持つデータ構造です。
| セル | 読み取り | 書き込み | 存在確認 |
|---|---|---|---|
| 名前 | symbol-name | — | — |
| 値 | symbol-value | setf symbol-value | boundp |
| 関数 | symbol-function | setf symbol-function | fboundp |
| プロパティリスト | symbol-plist / get | setf get | get-properties |
| パッケージ | symbol-package | — | — |
値セルと関数セルが独立しているのは、Common Lisp が「Lisp-2」だからで、defun で定義した関数名と同名の変数を衝突なく共存させられます。
plist セルも値セルとは独立した領域です。
変数として使っているシンボルにメタデータを付与しても、変数の値は上書きされません。
plistの用途は、主に2つあります。
- マクロやDSLがシンボルに情報を付与するとき。
たとえばdefstructやdefclassなど、Lispシステムが動く基盤として使われています。 - シンボルそのものをキーとしてデータを付与するとき。
コマンドのディスパッチテーブルや、関数シンボルへのメタデータ付与に使います。
6.1. get と setf get でシンボルplistを操作する
シンボルに組み込まれたplistは、symbol-plist [シンボル]で取得できます。
しかし、(getf (symbol-plist symbol) key) のように書くのは大変なので、getf とは別にシンボルplist専用の読み取り関数 get が用意されています。
; シンボルにメタデータを付与する
(setf (get 'process-order :priority) :high)
(setf (get 'process-order :version) 2)
(setf (get 'process-order :deprecated) nil)
(get 'process-order :priority) ;=> :HIGH
(get 'process-order :retry-count 0) ;=> 0 デフォルト値Code language: Lisp (lisp)
getは、getf同様に、デフォルト値も指定できます。
getは、シンボル自体を渡せるため、symbol-plist を経由する手間がありません。
ユーザーが自分で作ったplistには getf を使い、シンボルに付与したメタデータには get を使うのが自然な使い分けです。
; get: シンボルのplistを内部で取り出して検索する
(get 'my-symbol :key)
; getf: 渡されたリストを検索する
(getf some-plist :key)
; 以下は同じ結果になる
(get 'my-symbol :key)
(getf (symbol-plist 'my-symbol) :key)Code language: Lisp (lisp)
6.2. remprop でシンボルplistからキーを削除する
シンボルplist版のキーの削除は、remprop です。
(remprop 'process-order :deprecated)
(symbol-plist 'process-order) ;=> (:VERSION 2 :PRIORITY :HIGH)Code language: Lisp (lisp)
6.3. シンボルへのメタデータ付与
シンボルのplistは、シンボル名に情報を直接持たせるのに使えます。
たとえば、defcommand というマクロを定義して、コマンドシンボルにハンドラと説明を結び付ける例です。
(defmacro defcommand (name handler description)
`(progn
(setf (get ',name :handler) ,handler)
(setf (get ',name :description) ,description)))
(defun quit-handler () (format t "bye~%"))
(defcommand cmd-quit #'quit-handler "セッションを終了します")Code language: Lisp (lisp)
このdefcommandマクロでは、cmd-quitというシンボルに、handlerと description というキーをそのまま登録し、「コマンド定義」としています。
ちょうど、標準のdefstruct や defclass と同じ発想で、シンボルplistを使って型情報を管理しているわけです。
このdescriptionを表示する help 機能をコマンドとして追加します。
(defun show-help ()
(loop for sym in '(cmd-quit cmd-help)
do (format t "~a: ~a~%"
sym
(get sym :description))))
(defun help-handler () (show-help))
(defcommand cmd-help #'help-handler "ヘルプを表示します")Code language: Lisp (lisp)
そして、このコマンドを実行します。
(defun dispatch (cmd)
(let ((handler (get cmd :handler)))
(if handler
(funcall handler)
(format t "unknown command: ~a~%" cmd))))
(dispatch 'cmd-help)
; cmd-quit: セッションを終了します
; cmd-help: ヘルプを表示しますCode language: Lisp (lisp)
このようなコマンド・ディスパッチの設計は、たとえば、(Emacs Lispですが)Emacsでは関数シンボルに interactive プロパティを付与することで、ユーザーが呼び出せるコマンドにしています。
ただし、シンボルのplistはグローバルな状態なので書き込む場所が増えると管理が難しくなります。
そのため、マクロで登録処理を一か所にまとめるのが安全です。