【Common Lisp】
plistの基本の使い方
(プロパティリストとシンボル)

  • plist(プロパティリスト)は、キーと値を交互に並べたフラットなリストで、list 関数かクォートで作れる。
  • 構造体を定義するほどでもない複数の値をまとめるのに手軽で、値の読み取りは getf、書き込みは setf getf、削除は remf で行う。
  • キーの読み取りは eq で比較されるため、キーワードシンボルを使う。
  • Common Lispのシンボルには、メタデータを付与できる symbol-plist がある。
  • plistは、キーワード引数リストとも同じ形をしており、apply でそのまま関数引数として渡せる。
用途関数・マクロ
読み取りgetf
書き込み・更新setf getfincf
削除remf
複数キー検索get-properties
走査loop for (key value) on plist
シンボルplist読み取りgetsymbol-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)
構造検索追加非破壊更新キーの型向いている場面
plistO(N)O(1)list* で模倣シンボルのみキーワード引数の受け渡し、小さな設定
alistO(N)O(1)acons で自然任意非破壊で履歴を保ちながら更新
hash tableO(1) 平均O(1) 平均不可(破壊的のみ)任意(:test 指定)エントリが多い、検索・更新が頻繁

1.1. プロパティとシンボル(symbol-plist)

plistは、「プロパティリスト(属性リスト)」の意味です。

Lispでは、もともとシンボルを使ってオブジェクト(概念や実体)を表現していました。
シンボルの属性(property)を記録するデータ構造としてよく使われたのが、plistです。

Common Lisp のすべてのシンボルには、空のplistを持っていて、シンボルオブジェクト自体に組み込まれた記憶領域があります。
symbol-plist はシンボルのplist全体をリストとして返します。

現代の処理系は、シンボルplistではなく専用のデータ構造で型情報や関数情報を管理しているため、多くの標準シンボルは空(NIL)ですが、loopandなど一部のマクロに 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 で値を書き込む

setfgetf を組み合わせると値を書き込みます。

キーが存在すれば上書きし、なければ先頭に追加します。

(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)

getfincf を組み合わせてカウンタを書くこともできます。

(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)

ここでは、namescore に、: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-valuesetf symbol-valueboundp
関数symbol-functionsetf symbol-functionfboundp
プロパティリストsymbol-plist / getsetf getget-properties
パッケージsymbol-package

値セルと関数セルが独立しているのは、Common Lisp が「Lisp-2」だからで、defun で定義した関数名と同名の変数を衝突なく共存させられます。

plist セルも値セルとは独立した領域です。
変数として使っているシンボルにメタデータを付与しても、変数の値は上書きされません。

plistの用途は、主に2つあります。

  • マクロやDSLがシンボルに情報を付与するとき。
    たとえば defstructdefclass など、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というシンボルに、handlerdescription というキーをそのまま登録し、「コマンド定義」としています。
ちょうど、標準のdefstructdefclass と同じ発想で、シンボル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はグローバルな状態なので書き込む場所が増えると管理が難しくなります。
そのため、マクロで登録処理を一か所にまとめるのが安全です。